diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e9de88..70f372da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Update pystac dependency to 0.7 and shapely to 2.0 ([#441](https://github.com/stac-utils/stactools/pull/441)) +- Make `stac copy --copy-assets` copy assets to collections ([#437](https://github.com/stac-utils/stactools/pull/437)) ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 09143f36..71945279 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "lxml>=4.9.2", "numpy>=1.23.0", "pyproj>=3.3", - "pystac[validation]>=1.7.0", + "pystac[validation]>=1.8.2", "rasterio>=1.3.2", "shapely>=2.0.1", "stac-check>=1.3.2", diff --git a/src/stactools/cli/commands/add_asset.py b/src/stactools/cli/commands/add_asset.py index ec546a3a..4cfdbc7d 100644 --- a/src/stactools/cli/commands/add_asset.py +++ b/src/stactools/cli/commands/add_asset.py @@ -3,11 +3,11 @@ import click import pystac import pystac.utils -from stactools.core import add_asset_to_item +from stactools.core import add_asset def _add_asset( - item_path: str, + owner_path: str, asset_key: str, asset_path: str, title: Optional[str] = None, @@ -17,23 +17,23 @@ def _add_asset( move_assets: bool = False, ignore_conflicts: bool = False, ) -> None: - item = pystac.read_file(item_path) - if not isinstance(item, pystac.Item): - raise click.BadArgumentUsage(f"{item_path} is not a STAC Item") + owner = pystac.read_file(owner_path) + if not isinstance(owner, (pystac.Item, pystac.Collection)): + raise click.BadArgumentUsage(f"{owner_path} is not a STAC Item or Collection") asset = pystac.Asset(asset_path, title, description, media_type, roles) - item = add_asset_to_item( - item, + owner = add_asset( + owner, asset_key, asset, move_assets=move_assets, ignore_conflicts=ignore_conflicts, ) - item.save_object() + owner.save_object() def create_add_asset_command(cli: click.Group) -> click.Command: - @cli.command("add-asset", short_help="Add an asset to an item.") - @click.argument("item_path") + @cli.command("add-asset", short_help="Add an asset to an item or collection.") + @click.argument("owner_path") @click.argument("asset_key") @click.argument("asset_path") @click.option("--title", help="Optional title of the asset") @@ -55,7 +55,7 @@ def create_add_asset_command(cli: click.Group) -> click.Command: @click.option( "--move-assets", is_flag=True, - help="Move asset to the target Item's location.", + help="Move asset to the target Item or Collection's location.", ) @click.option( "--ignore-conflicts", @@ -67,7 +67,7 @@ def create_add_asset_command(cli: click.Group) -> click.Command: ), ) def add_asset_command( - item_path: str, + owner_path: str, asset_key: str, asset_path: str, title: Optional[str] = None, @@ -78,7 +78,7 @@ def add_asset_command( ignore_conflicts: bool = False, ) -> None: _add_asset( - pystac.utils.make_absolute_href(item_path), + pystac.utils.make_absolute_href(owner_path), asset_key, pystac.utils.make_absolute_href(asset_path), title, diff --git a/src/stactools/cli/commands/copy.py b/src/stactools/cli/commands/copy.py index e0520a63..01bc22b4 100644 --- a/src/stactools/cli/commands/copy.py +++ b/src/stactools/cli/commands/copy.py @@ -8,7 +8,11 @@ def create_move_assets_command(cli: click.Group) -> click.Command: @cli.command( - "move-assets", short_help="Move or copy assets in a STAC to the Item locations." + "move-assets", + help=( + "Move or copy assets in a STAC catalog to the locations of the " + "items or collections that own them." + ), ) @click.argument("catalog_path") @click.option("-c", "--copy", help="Copy assets instead of moving.", is_flag=True) @@ -68,7 +72,7 @@ def create_copy_command(cli: click.Group) -> click.Command: "--copy-assets", "-a", is_flag=True, - help="Copy all item assets to be alongside the new item location.", + help="Copy all asset files to be alongside the new location.", ) @click.option( "-l", diff --git a/src/stactools/core/__init__.py b/src/stactools/core/__init__.py index 4c6326e2..b298c35b 100644 --- a/src/stactools/core/__init__.py +++ b/src/stactools/core/__init__.py @@ -1,9 +1,10 @@ from stactools.core.add import add_item -from stactools.core.add_asset import add_asset_to_item +from stactools.core.add_asset import add_asset, add_asset_to_item from stactools.core.add_raster import add_raster_to_item from stactools.core.copy import ( copy_catalog, move_all_assets, + move_asset_file, move_asset_file_to_item, move_assets, ) @@ -13,12 +14,14 @@ __all__ = [ "add_item", + "add_asset", "add_asset_to_item", "add_raster_to_item", "copy_catalog", "layout_catalog", "merge_all_items", "merge_items", + "move_asset_file", "move_asset_file_to_item", "move_assets", "move_all_assets", diff --git a/src/stactools/core/add_asset.py b/src/stactools/core/add_asset.py index fc951c5d..41b24859 100644 --- a/src/stactools/core/add_asset.py +++ b/src/stactools/core/add_asset.py @@ -1,40 +1,43 @@ import logging +import warnings +from typing import Union, cast -from pystac import Asset, Item +from pystac import Asset, Collection, Item from pystac.utils import is_absolute_href, make_relative_href -from stactools.core.copy import move_asset_file_to_item +from stactools.core.copy import move_asset_file logger = logging.getLogger(__name__) -def add_asset_to_item( - item: Item, +def add_asset( + owner: Union[Collection, Item], key: str, asset: Asset, move_assets: bool = False, ignore_conflicts: bool = False, -) -> Item: - """Adds an asset to an item. +) -> Union[Collection, Item]: + """Adds an asset to an item or collection. Args: - item (Item): The PySTAC Item to which the asset will be added. + owner (Item or Collection): The PySTAC Item or Collecitonto which the asset + will be added. key (str): The unique key of the asset. asset (Asset): The PySTAC Asset to add. - move_assets (bool): If True, move the asset file alongside the target item. + move_assets (bool): If True, move the asset file alongside the target owner. ignore_conflicts (bool): If True, asset with the same key will not be added, and asset file that would overwrite an existing file will not be moved. If False, either of these situations will throw an error. Returns: - Item: Returns an updated Item with the added Asset. - This operation mutates the Item. + owner: Returns an updated Item or Collection with the added Asset. + This operation mutates the owner. """ - item_href = item.get_self_href() + owner_href = owner.get_self_href() asset_href = asset.get_absolute_href() - if key in item.assets: + if key in owner.assets: if not ignore_conflicts: raise Exception( - f"Target item {item} already has an asset with key {key}, " + f"Target {owner} already has an asset with key {key}, " "cannot add asset in from {asset_href}" ) else: @@ -43,23 +46,54 @@ def add_asset_to_item( f"Asset {asset} must have an href to be added. The href " "value should be an absolute path or URL." ) - if not item_href and move_assets: + if not owner_href and move_assets: + raise ValueError(f"Target {owner} must have an href to move an asset to it") + if not owner_href and not is_absolute_href(asset.href): raise ValueError( - f"Target Item {item} must have an href to move an asset to the item" - ) - if not item_href and not is_absolute_href(asset.href): - raise ValueError( - f"Target Item {item} must have an href to add " + f"Target {owner} must have an href to add " "an asset with a relative href" ) if move_assets: - new_asset_href = move_asset_file_to_item( - item, asset_href, ignore_conflicts=ignore_conflicts + new_asset_href = move_asset_file( + owner, asset_href, ignore_conflicts=ignore_conflicts ) else: - if not is_absolute_href(asset.href) and item_href is not None: - asset_href = make_relative_href(asset_href, item_href) + if not is_absolute_href(asset.href) and owner_href is not None: + asset_href = make_relative_href(asset_href, owner_href) new_asset_href = asset_href asset.href = new_asset_href - item.add_asset(key, asset) - return item + owner.add_asset(key, asset) + return owner + + +def add_asset_to_item( + item: Item, + key: str, + asset: Asset, + move_assets: bool = False, + ignore_conflicts: bool = False, +) -> Item: + """Adds an asset to an item. + + Args: + item (Item): The PySTAC Item to which the asset will be added. + key (str): The unique key of the asset. + asset (Asset): The PySTAC Asset to add. + move_assets (bool): If True, move the asset file alongside the target item. + ignore_conflicts (bool): If True, asset with the same key will not be added, + and asset file that would overwrite an existing file will not be moved. + If False, either of these situations will throw an error. + + Returns: + Item: Returns an updated Item with the added Asset. + This operation mutates the Item. + """ + warnings.warn( + "'add_asset_to_item' is deprecated. Use 'add_asset' instead", DeprecationWarning + ) + return cast( + Item, + add_asset( + item, key, asset, move_assets=move_assets, ignore_conflicts=ignore_conflicts + ), + ) diff --git a/src/stactools/core/copy.py b/src/stactools/core/copy.py index 58c19e12..d61678b6 100644 --- a/src/stactools/core/copy.py +++ b/src/stactools/core/copy.py @@ -1,33 +1,34 @@ import logging import os -from typing import Optional +import warnings +from typing import Optional, Union import fsspec from fsspec.core import split_protocol from fsspec.registry import get_filesystem_class -from pystac import Catalog, CatalogType, Item +from pystac import Catalog, CatalogType, Collection, Item from pystac.utils import is_absolute_href, make_absolute_href, make_relative_href logger = logging.getLogger(__name__) -def move_asset_file_to_item( - item: Item, +def move_asset_file( + owner: Union[Item, Collection], asset_href: str, asset_subdirectory: Optional[str] = None, copy: bool = False, ignore_conflicts: bool = False, ) -> str: - """Moves an asset file to be alongside an item. + """Moves an asset file to be alongside its owner. Args: - item (Item): - The PySTAC Item to perform the asset transformation on. + owner (Item or Collection): + The PySTAC Item or Collection to perform the asset transformation on. asset_href (str): The absolute HREF to the asset file. asset_subdirectory (str or None): A subdirectory that will be used to store the assets. If not supplied, the assets will be moved or copied to the same directory - as their item. + as their owner. copy (bool): If False this function will move the asset file; if True, the asset file will be copied. @@ -38,24 +39,24 @@ def move_asset_file_to_item( Returns: str: The new absolute href for the asset file. """ - item_href = item.get_self_href() - if item_href is None: + owner_href = owner.get_self_href() + if owner_href is None: raise ValueError( - f"Self HREF is not available for item {item.id}. This operation " - "requires that the Item HREFs are available." + f"Self HREF is not available for {owner}. This operation " + "requires that the HREFs are available." ) # TODO this shouldn't have to be absolute if not is_absolute_href(asset_href): raise ValueError("asset_href must be absolute.") - item_dir = os.path.dirname(item_href) + owner_dir = os.path.dirname(owner_href) fname = os.path.basename(asset_href) if asset_subdirectory is None: - target_dir = item_dir + target_dir = owner_dir else: - target_dir = os.path.join(item_dir, asset_subdirectory) + target_dir = os.path.join(owner_dir, asset_subdirectory) new_asset_href = os.path.join(target_dir, fname) if asset_href != new_asset_href: @@ -120,18 +121,53 @@ def _op3(dry_run: bool = False) -> None: return new_asset_href -def move_assets( +def move_asset_file_to_item( item: Item, + asset_href: str, asset_subdirectory: Optional[str] = None, - make_hrefs_relative: bool = True, copy: bool = False, ignore_conflicts: bool = False, -) -> Item: - """Moves an Item's assets to be alongside that item. +) -> str: + """Moves an asset file to be alongside an item. Args: item (Item): The PySTAC Item to perform the asset transformation on. + asset_href (str): The absolute HREF to the asset file. + asset_subdirectory (str or None): + A subdirectory that will be used to store the assets. If not + supplied, the assets will be moved or copied to the same directory + as their item. + copy (bool): + If False this function will move the asset file; if True, the asset + file will be copied. + ignore_conflicts (bool): + If the asset destination file already exists, this function will + throw an error unless ignore_conflicts is True. + + Returns: + str: The new absolute href for the asset file. + """ + warnings.warn( + "'move_asset_file_to_item' is deprecated. Use 'move_asset_file' instead", + DeprecationWarning, + ) + return move_asset_file(item, asset_href, asset_subdirectory, copy, ignore_conflicts) + + +def move_assets( + owner: Optional[Union[Item, Collection]] = None, + asset_subdirectory: Optional[str] = None, + make_hrefs_relative: bool = True, + copy: bool = False, + ignore_conflicts: bool = False, + item: Optional[Item] = None, +) -> Union[Item, Collection]: + """Moves an Item or Collection's assets to be alongside it. + + Args: + owner (Item or Collection): + The PySTAC Item or Collection to perform the asset transformation on. asset_subdirectory (str or None): A subdirectory that will be used to store the assets. If not supplied, the assets will be moved or copied to the same directory @@ -148,27 +184,34 @@ def move_assets( Returns: Item: - Returns an updated catalog or collection. This operation mutates + Returns an updated item or collection. This operation mutates the Item. """ - item_href = item.get_self_href() - if item_href is None: + if item is not None: + warnings.warn( + "item is a deprecated option on this function. Use 'owner' instead", + DeprecationWarning, + ) + owner = item + if owner is None: + raise TypeError("move_assets missing 1 required positional argument: 'owner'") + + owner_href = owner.get_self_href() + if owner_href is None: raise ValueError( - f"Self HREF is not available for item {item.id}. This operation " - "requires that the Item HREFs are available." + f"Self HREF is not available for {owner}. This operation " + "requires that HREFs are available." ) - for asset in item.assets.values(): + for asset in owner.assets.values(): abs_asset_href = asset.get_absolute_href() if abs_asset_href is None: raise ValueError( - f"Asset {asset.title} HREF is not available for item {item.id}. " - "This operation " - "requires that the Asset HREFs are available." + f"Asset {asset.title} HREF is not available for {owner}. " + "This operation requires that the Asset HREFs are available." ) - - new_asset_href = move_asset_file_to_item( - item, + new_asset_href = move_asset_file( + owner, abs_asset_href, asset_subdirectory=asset_subdirectory, copy=copy, @@ -176,11 +219,11 @@ def move_assets( ) if make_hrefs_relative: - asset.href = make_relative_href(new_asset_href, item_href) + asset.href = make_relative_href(new_asset_href, owner_href) else: asset.href = new_asset_href - return item + return owner def move_all_assets( @@ -190,7 +233,7 @@ def move_all_assets( copy: bool = False, ignore_conflicts: bool = False, ) -> Catalog: - """Moves assets in a catalog to be alongside the items that own them. + """Moves assets in a catalog to be alongside the item or collections that own them. Args: catalog (Catalog or Collection): @@ -199,7 +242,7 @@ def move_all_assets( asset_subdirectory (str or None): A subdirectory that will be used to store the assets. If not supplied, the assets will be moved or copied to the same directory - as their item. + as their owner. make_assets_relative (bool): If True, will make the asset HREFs relative to the assets. If false, the asset will be an absolute href. @@ -221,6 +264,11 @@ def move_all_assets( item, asset_subdirectory, make_hrefs_relative, copy, ignore_conflicts ) + for collection in catalog.get_all_collections(): + move_assets( + collection, asset_subdirectory, make_hrefs_relative, copy, ignore_conflicts + ) + return catalog @@ -239,12 +287,14 @@ def copy_catalog( catalog.set_root(catalog) dest_directory = make_absolute_href(dest_directory) - catalog.normalize_hrefs(dest_directory, skip_unresolved=not resolve_links) if copy_assets: + catalog.make_all_asset_hrefs_absolute() + catalog.normalize_hrefs(dest_directory, skip_unresolved=not resolve_links) catalog = move_all_assets(catalog, copy=True, make_hrefs_relative=True) if publish_location is not None: catalog.normalize_hrefs(publish_location, skip_unresolved=not resolve_links) catalog.save(catalog_type, dest_directory) else: + catalog.normalize_hrefs(dest_directory, skip_unresolved=not resolve_links) catalog.save(catalog_type) diff --git a/src/stactools/core/merge.py b/src/stactools/core/merge.py index 77ece8ad..7aea86e6 100644 --- a/src/stactools/core/merge.py +++ b/src/stactools/core/merge.py @@ -5,7 +5,7 @@ from pystac.layout import BestPracticesLayoutStrategy from pystac.utils import is_absolute_href, make_relative_href from shapely.geometry import mapping, shape -from stactools.core.copy import copy_catalog, move_asset_file_to_item +from stactools.core.copy import copy_catalog, move_asset_file from stactools.core.copy import move_assets as do_move_assets @@ -51,7 +51,7 @@ def merge_items( if asset_href is None: raise ValueError(f"Asset {asset.title} must have an HREF for merge") if move_assets: - new_asset_href = move_asset_file_to_item( + new_asset_href = move_asset_file( target_item, asset_href, ignore_conflicts=ignore_conflicts ) else: diff --git a/tests/cli/commands/test_cases.py b/tests/cli/commands/test_cases.py new file mode 100644 index 00000000..840e62a8 --- /dev/null +++ b/tests/cli/commands/test_cases.py @@ -0,0 +1,204 @@ +import os +from datetime import datetime + +import pystac +from pystac import ( + Asset, + Catalog, + Collection, + Extent, + Item, + MediaType, + SpatialExtent, + TemporalExtent, +) +from pystac.extensions.label import ( + LabelClasses, + LabelCount, + LabelExtension, + LabelOverview, + LabelType, +) + +from tests import test_data + +TEST_LABEL_CATALOG = { + "country-1": { + "area-1-1": { + "dsm": "area-1-1_dsm.tif", + "ortho": "area-1-1_ortho.tif", + "labels": "area-1-1_labels.geojson", + }, + "area-1-2": { + "dsm": "area-1-2_dsm.tif", + "ortho": "area-1-2_ortho.tif", + "labels": "area-1-2_labels.geojson", + }, + }, + "country-2": { + "area-2-1": { + "dsm": "area-2-1_dsm.tif", + "ortho": "area-2-1_ortho.tif", + "labels": "area-2-1_labels.geojson", + }, + "area-2-2": { + "dsm": "area-2-2_dsm.tif", + "ortho": "area-2-2_ortho.tif", + "labels": "area-2-2_labels.geojson", + }, + }, +} + +RANDOM_GEOM = { + "type": "Polygon", + "coordinates": [ + [ + [-2.5048828125, 3.8916575492899987], + [-1.9610595703125, 3.8916575492899987], + [-1.9610595703125, 4.275202171119132], + [-2.5048828125, 4.275202171119132], + [-2.5048828125, 3.8916575492899987], + ] + ], +} + +RANDOM_BBOX = [ + RANDOM_GEOM["coordinates"][0][0][0], + RANDOM_GEOM["coordinates"][0][0][1], + RANDOM_GEOM["coordinates"][0][1][0], + RANDOM_GEOM["coordinates"][0][1][1], +] + +RANDOM_EXTENT = Extent( + spatial=SpatialExtent.from_coordinates(RANDOM_GEOM["coordinates"]), + temporal=TemporalExtent.from_now(), +) # noqa: E126 + + +class TestCases: + __test__ = False + + @staticmethod + def get_path(rel_path): + return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", rel_path)) + + @staticmethod + def all_test_catalogs(): + return [ + TestCases.planet_disaster(), + TestCases.test_case_1(), + TestCases.test_case_2(), + TestCases.test_case_3(), + TestCases.test_case_4(), + TestCases.test_case_5(), + TestCases.test_case_7(), + TestCases.test_case_8(), + ] + + @staticmethod + def planet_disaster() -> Collection: + return pystac.read_file( + test_data.get_path("data-files/planet-disaster/collection.json") + ) + + @staticmethod + def test_case_1(): + return Catalog.from_file( + test_data.get_path("data-files/catalogs/test-case-1/catalog.json") + ) + + @staticmethod + def test_case_2(): + return Catalog.from_file( + test_data.get_path("data-files/catalogs/test-case-2/catalog.json") + ) + + @staticmethod + def test_case_3(): + root_cat = Catalog( + id="test3", description="test case 3 catalog", title="test case 3 title" + ) + + image_item = Item( + id="imagery-item", + geometry=RANDOM_GEOM, + bbox=RANDOM_BBOX, + datetime=datetime.utcnow(), + properties={}, + ) + + image_item.add_asset( + "ortho", Asset(href="some/geotiff.tiff", media_type=MediaType.GEOTIFF) + ) + + overviews = [ + LabelOverview.create( + "label", + counts=[LabelCount.create("one", 1), LabelCount.create("two", 2)], + ) + ] + + label_item = Item( + id="label-items", + geometry=RANDOM_GEOM, + bbox=RANDOM_BBOX, + datetime=datetime.utcnow(), + properties={}, + ) + + label_extension = LabelExtension.ext(label_item, add_if_missing=True) + label_extension.apply( + label_description="ML Labels", + label_type=LabelType.VECTOR, + label_properties=["label"], + label_classes=[LabelClasses.create(classes=["one", "two"], name="label")], + label_tasks=["classification"], + label_methods=["manual"], + label_overviews=overviews, + ) + label_extension.add_source(image_item, assets=["ortho"]) + + root_cat.add_item(image_item) + root_cat.add_item(label_item) + + return root_cat + + @staticmethod + def test_case_4(): + """Test case that is based on a local copy of the Tier 1 dataset from + DrivenData's OpenCities AI Challenge. + See: https://www.drivendata.org/competitions/60/building-segmentation-disaster-resilience + """ # noqa: E501 + return Catalog.from_file( + test_data.get_path("data-files/catalogs/test-case-4/catalog.json") + ) + + @staticmethod + def test_case_5(): + """Based on a subset of https://cbers.stac.cloud/""" + return Catalog.from_file( + test_data.get_path("data-files/catalogs/test-case-5/catalog.json") + ) + + @staticmethod + def test_case_6(): + """Contains local assets with relative hrefs""" + return Catalog.from_file( + test_data.get_path("data-files/catalogs/test-case-6/catalog.json") + ) + + @staticmethod + def test_case_7(): + """Test case 4 as STAC version 0.8.1""" + return Catalog.from_file( + test_data.get_path("data-files/catalogs/label_catalog_0_8_1/catalog.json") + ) + + @staticmethod + def test_case_8(): + """Planet disaster data example catalog, 1.0.0-beta.2""" + return pystac.read_file( + test_data.get_path( + "data-files/catalogs/" "planet-example-1.0.0-beta.2/collection.json" + ) + ) diff --git a/tests/cli/commands/test_copy.py b/tests/cli/commands/test_copy.py index 3492dbe5..b9e2b552 100644 --- a/tests/cli/commands/test_copy.py +++ b/tests/cli/commands/test_copy.py @@ -141,3 +141,13 @@ def test_copy_using_no_resolve_links(tmp_path: Path) -> None: assert result.exit_code == 0 assert os.listdir(tmp_path) == ["catalog.json"] + + +def test_copy_collection_with_assets(tmp_path: Path) -> None: + cat_path = test_data.get_path("data-files/catalogs/collection-assets/catalog.json") + + runner = CliRunner() + result = runner.invoke(cli, ["copy", cat_path, str(tmp_path), "-a"]) + assert result.exit_code == 0 + + assert (tmp_path / "sentinel-2" / "metadata.xml").exists() diff --git a/tests/core/test_add_asset.py b/tests/core/test_add_asset.py index abeede00..76824ba3 100644 --- a/tests/core/test_add_asset.py +++ b/tests/core/test_add_asset.py @@ -1,8 +1,10 @@ import os +import shutil +from pathlib import Path import pystac import pytest -from stactools.core import add_asset_to_item +from stactools.core import add_asset, add_asset_to_item from tests import test_data @@ -15,14 +17,14 @@ def item() -> pystac.Item: def test_add_asset_to_item(item: pystac.Item) -> None: """Test adding an asset to an item without moving the asset""" assert "test-asset" not in item.assets - asset = pystac.Asset( test_data.get_path("data-files/core/byte.tif"), "test", "placeholder asset", roles=["thumbnail", "overview"], ) - item = add_asset_to_item(item, "test-asset", asset) + with pytest.warns(DeprecationWarning, match="Use 'add_asset' instead"): + item = add_asset_to_item(item, "test-asset", asset) asset = item.assets["test-asset"] assert isinstance(asset, pystac.Asset), asset @@ -33,28 +35,38 @@ def test_add_asset_to_item(item: pystac.Item) -> None: assert asset.roles == ["thumbnail", "overview"] -def test_add_asset_move(tmp_item_path: str, tmp_asset_path: str) -> None: - """Test adding an asset to an item and moving it to the item""" - item = pystac.Item.from_file(tmp_item_path) +@pytest.mark.parametrize( + "path", + [ + "data-files/core/simple-item.json", + "data-files/catalogs/collection-assets/sentinel-2/collection.json", + ], +) +def test_add_asset_move(path: str, tmp_path: Path, tmp_asset_path: str) -> None: + """Test adding and moving an asset to an item or collection""" + src = test_data.get_path(path) + dst = obj_path = tmp_path / "obj.json" + shutil.copyfile(src, str(dst)) + asset = pystac.Asset( tmp_asset_path, "test", "placeholder asset", roles=["thumbnail", "overview"], ) - item = add_asset_to_item( - item, "test-asset", asset, move_assets=True, ignore_conflicts=True - ) + obj = pystac.read_file(obj_path) - asset = item.assets["test-asset"] + obj = add_asset(obj, "test-asset", asset, move_assets=True, ignore_conflicts=True) + + asset = obj.assets["test-asset"] assert isinstance(asset, pystac.Asset), asset assert asset.href is not None, asset.to_dict() assert os.path.isfile(asset.href), asset.to_dict() asset_absolute_href = asset.get_absolute_href() assert asset_absolute_href - item_self_href = item.get_self_href() - assert item_self_href - assert os.path.dirname(asset_absolute_href) == os.path.dirname(item_self_href) + obj_self_href = obj.get_self_href() + assert obj_self_href + assert os.path.dirname(asset_absolute_href) == os.path.dirname(obj_self_href) def test_add_and_move_with_missing_item_href(item: pystac.Item) -> None: @@ -69,7 +81,7 @@ def test_add_and_move_with_missing_item_href(item: pystac.Item) -> None: roles=["thumbnail", "overview"], ) with pytest.raises(ValueError): - add_asset_to_item(item, "test-asset", asset, move_assets=True) + add_asset(item, "test-asset", asset, move_assets=True) def test_add_with_missing_item_href_relative_asset_href(item: pystac.Item) -> None: @@ -84,7 +96,7 @@ def test_add_with_missing_item_href_relative_asset_href(item: pystac.Item) -> No roles=["thumbnail", "overview"], ) with pytest.raises(ValueError): - add_asset_to_item(item, "test-asset", asset) + add_asset(item, "test-asset", asset) def test_add_with_missing_item_href_absolute_asset_href(item: pystac.Item) -> None: @@ -98,7 +110,7 @@ def test_add_with_missing_item_href_absolute_asset_href(item: pystac.Item) -> No "placeholder asset", roles=["thumbnail", "overview"], ) - add_asset_to_item(item, "test-asset", asset) + add_asset(item, "test-asset", asset) asset = item.assets["test-asset"] assert isinstance(asset, pystac.Asset), asset @@ -109,7 +121,7 @@ def test_missing_asset_href(item: pystac.Item) -> None: "", "test", "placeholder asset", roles=["thumbnail", "overview"] ) with pytest.raises(ValueError): - add_asset_to_item(item, "test-asset", asset) + add_asset(item, "test-asset", asset) def test_add_asset_ignore_conflict(item: pystac.Item) -> None: @@ -123,6 +135,6 @@ def test_add_asset_ignore_conflict(item: pystac.Item) -> None: ) with pytest.raises(Exception): - add_asset_to_item(item, "thumbnail", asset) - item = add_asset_to_item(item, "thumbnail", asset, ignore_conflicts=True) + add_asset(item, "thumbnail", asset) + item = add_asset(item, "thumbnail", asset, ignore_conflicts=True) assert item.assets["thumbnail"].title == "Thumbnail" diff --git a/tests/data-files/catalogs/collection-assets/catalog.json b/tests/data-files/catalogs/collection-assets/catalog.json new file mode 100644 index 00000000..7347d6a0 --- /dev/null +++ b/tests/data-files/catalogs/collection-assets/catalog.json @@ -0,0 +1,19 @@ +{ + "id": "examples", + "type": "Catalog", + "stac_version": "1.0.0", + "description": "This catalog is a simple demonstration of an example catalog", + "links": [ + { + "rel": "root", + "href": "./catalog.json", + "type": "application/json" + }, + { + "rel": "child", + "href": "./sentinel-2/collection.json", + "type": "application/json", + "title": "Collection with no items, only assets" + } + ] +} diff --git a/tests/data-files/catalogs/collection-assets/sentinel-2/collection.json b/tests/data-files/catalogs/collection-assets/sentinel-2/collection.json new file mode 100644 index 00000000..08952e7f --- /dev/null +++ b/tests/data-files/catalogs/collection-assets/sentinel-2/collection.json @@ -0,0 +1,229 @@ +{ + "type": "Collection", + "stac_version": "1.0.0", + "stac_extensions": [ + "https://stac-extensions.github.io/eo/v1.1.0/schema.json", + "https://stac-extensions.github.io/projection/v1.0.0/schema.json", + "https://stac-extensions.github.io/view/v1.0.0/schema.json" + ], + "id": "sentinel-2", + "title": "Sentinel-2 MSI: MultiSpectral Instrument, Level-1C", + "description": "Sentinel-2 is a wide-swath, high-resolution, multi-spectral\nimaging mission supporting Copernicus Land Monitoring studies,\nincluding the monitoring of vegetation, soil and water cover,\nas well as observation of inland waterways and coastal areas.\n\nThe Sentinel-2 data contain 13 UINT16 spectral bands representing\nTOA reflectance scaled by 10000. See the [Sentinel-2 User Handbook](https://sentinel.esa.int/documents/247904/685211/Sentinel-2_User_Handbook)\nfor details. In addition, three QA bands are present where one\n(QA60) is a bitmask band with cloud mask information. For more\ndetails, [see the full explanation of how cloud masks are computed.](https://sentinel.esa.int/web/sentinel/technical-guides/sentinel-2-msi/level-1c/cloud-masks)\n\nEach Sentinel-2 product (zip archive) may contain multiple\ngranules. Each granule becomes a separate Earth Engine asset.\nEE asset ids for Sentinel-2 assets have the following format:\nCOPERNICUS/S2/20151128T002653_20151128T102149_T56MNN. Here the\nfirst numeric part represents the sensing date and time, the\nsecond numeric part represents the product generation date and\ntime, and the final 6-character string is a unique granule identifier\nindicating its UTM grid reference (see [MGRS](https://en.wikipedia.org/wiki/Military_Grid_Reference_System)).\n\nFor more details on Sentinel-2 radiometric resoltuon, [see this page](https://earth.esa.int/web/sentinel/user-guides/sentinel-2-msi/resolutions/radiometric).\n", + "license": "proprietary", + "keywords": [ + "copernicus", + "esa", + "eu", + "msi", + "radiance", + "sentinel" + ], + "providers": [ + { + "name": "European Union/ESA/Copernicus", + "roles": [ + "producer", + "licensor" + ], + "url": "https://sentinel.esa.int/web/sentinel/user-guides/sentinel-2-msi" + } + ], + "extent": { + "spatial": { + "bbox": [ + [ + -180, + -56, + 180, + 83 + ] + ] + }, + "temporal": { + "interval": [ + [ + "2015-06-23T00:00:00Z", + null + ] + ] + } + }, + "assets": { + "metadata_iso_19139": { + "roles": [ + "metadata", + "iso-19139" + ], + "href": "./metadata.xml", + "title": "ISO 19139 metadata", + "type": "application/vnd.iso.19139+xml" + } + }, + "summaries": { + "datetime": { + "minimum": "2015-06-23T00:00:00Z", + "maximum": "2019-07-10T13:44:56Z" + }, + "platform": [ + "sentinel-2a", + "sentinel-2b" + ], + "constellation": [ + "sentinel-2" + ], + "instruments": [ + "msi" + ], + "view:off_nadir": { + "minimum": 0, + "maximum": 100 + }, + "view:sun_elevation": { + "minimum": 6.78, + "maximum": 89.9 + }, + "gsd": [ + 10, + 30, + 60 + ], + "proj:epsg": [ + 32601, + 32602, + 32603, + 32604, + 32605, + 32606, + 32607, + 32608, + 32609, + 32610, + 32611, + 32612, + 32613, + 32614, + 32615, + 32616, + 32617, + 32618, + 32619, + 32620, + 32621, + 32622, + 32623, + 32624, + 32625, + 32626, + 32627, + 32628, + 32629, + 32630, + 32631, + 32632, + 32633, + 32634, + 32635, + 32636, + 32637, + 32638, + 32639, + 32640, + 32641, + 32642, + 32643, + 32644, + 32645, + 32646, + 32647, + 32648, + 32649, + 32650, + 32651, + 32652, + 32653, + 32654, + 32655, + 32656, + 32657, + 32658, + 32659, + 32660 + ], + "eo:bands": [ + { + "name": "B1", + "common_name": "coastal", + "center_wavelength": 4.439 + }, + { + "name": "B2", + "common_name": "blue", + "center_wavelength": 4.966 + }, + { + "name": "B3", + "common_name": "green", + "center_wavelength": 5.6 + }, + { + "name": "B4", + "common_name": "red", + "center_wavelength": 6.645 + }, + { + "name": "B5", + "center_wavelength": 7.039 + }, + { + "name": "B6", + "center_wavelength": 7.402 + }, + { + "name": "B7", + "center_wavelength": 7.825 + }, + { + "name": "B8", + "common_name": "nir", + "center_wavelength": 8.351 + }, + { + "name": "B8A", + "center_wavelength": 8.648 + }, + { + "name": "B9", + "center_wavelength": 9.45 + }, + { + "name": "B10", + "center_wavelength": 1.3735 + }, + { + "name": "B11", + "common_name": "swir16", + "center_wavelength": 1.6137 + }, + { + "name": "B12", + "common_name": "swir22", + "center_wavelength": 2.2024 + } + ] + }, + "links": [ + { + "rel": "parent", + "href": "../catalog.json" + }, + { + "rel": "root", + "href": "../catalog.json" + }, + { + "rel": "license", + "href": "https://scihub.copernicus.eu/twiki/pub/SciHubWebPortal/TermsConditions/Sentinel_Data_Terms_and_Conditions.pdf", + "title": "Legal notice on the use of Copernicus Sentinel Data and Service Information" + } + ] + } diff --git a/tests/data-files/catalogs/collection-assets/sentinel-2/metadata.xml b/tests/data-files/catalogs/collection-assets/sentinel-2/metadata.xml new file mode 100644 index 00000000..975cdc46 --- /dev/null +++ b/tests/data-files/catalogs/collection-assets/sentinel-2/metadata.xml @@ -0,0 +1,391 @@ + + + + EOP:ESA:Sentinel-2 + + + eng + + + series + + + + + ESA/ESRIN + + + pointOfContact + + + + + + + contactesrin@esa.int + + + + + + + pointOfContact + + + + + 2017-04-30 + + + ISO19115 + + + 2005/Cor.1:2006 + + + + + + + Sentinel-2 Products + + + + + 2015-06-23 + + + + + + + + + + + 2018-10-09 + + + revision + + + + + + + EOP:ESA:Sentinel-2 + + + http://earth.esa.int + + + + + + + The Sentinel-2 mission is a land monitoring constellation of two satellites that provide high resolution optical imagery and provide continuity for the current SPOT and Landsat missions. The mission provides a global coverage of the Earth's land surface every 10 days with one satellite and 5 days with 2 satellites, making the data of great use in on-going studies. The satellites are equipped with the state-of-the-art MSI (Multispectral Imager) instrument, that offers high-resolution optical imagery. + + + + + ESA/ESRIN + + + pointOfContact + + + + + + + +39 06 941801 + + + +39 06 94180280 + + + + + + + Largo Galileo Galilei 1 + + + Frascati (Roma) + + + 00044 + + + Italy + + + contactesrin@esa.int + + + + + + + http://www.esa.int + + + + + + + originator + + + + + + + land + + + land cover + + + chlorophyll + + + natural disaster + + + + + + + + GEMET - INSPIRE Themes, Version 1.0 + + + + + 2008-06-01 + + + publication + + + + + + + + + + + + + + + + + + EARTH SCIENCE > LAND SURFACE > LAND USE/LAND COVER + + + EARTH SCIENCE > HUMAN DIMENSIONS > NATURAL HAZARDS + + + + + + + + NASA/Global Change Master Directory (GCMD) Earth Science Keywords. Version 8.0.0.0.0 + + + + + 2013 + + + publication + + + + + + + http://idn.ceos.org/ + + + + + + + + + + + FedEO + + + SCIHUB + + + DIF10 + + + + + + + + otherRestrictions + + + Sentinel data products are made available systematically and free of charge to all data users including the general public, scientific and commercial users. + + + + + eng + + + geoscientificInformation + + + + + + + + 2015-06-23 + + + + + + + + + + + + + -180 + + + 180 + + + -90 + + + 90 + + + + + + + + + + + + + + + https://sentinel.esa.int/web/sentinel/missions/sentinel-2 + + + ESA Sentinel Online + + + + + + + + + + + + https://scihub.copernicus.eu/ + + + Sentinels Scientific Data Hub + + + Sentinels Scientific Data Hub + + + + + + + + + + https://fedeo.esa.int/opensearch/description.xml?parentIdentifier=EOP:ESA:SCIHUB:S2&sensorType=OPTICAL + + + FedEO Clearinghouse + + + FedEO Clearinghouse + + + + + + + + + + + + + + + + series + + + + + + + + + + + COMMISSION REGULATION (EU) No 1089/2010 of 23 November 2010 implementing Directive 2007/2/EC of the European Parliament and of the Council as regards interoperability of spatial data sets and services + + + + + 2010-12-08 + + + + + + + + + + not tested + + + false + + + + + + + + + Sentinel-2 is a multispectral operational imaging mission within the GMES (Global Monitoring for Environment and Security) program, jointly implemented by the EC (European Commission) and ESA (European Space Agency) for global land observation (data on vegetation, soil and water cover for land, inland waterways and coastal areas, and also provide atmospheric absorption and distortion data corrections) at high resolution with high revisit capability to provide enhanced continuity of data so far provided by SPOT-5 and Landsat-7. + + + + + + \ No newline at end of file