diff --git a/src/pystac/__init__.py b/src/pystac/__init__.py index b356c563a..97c8e619c 100644 --- a/src/pystac/__init__.py +++ b/src/pystac/__init__.py @@ -13,10 +13,10 @@ ) from .extent import Extent, SpatialExtent, TemporalExtent from .functions import get_stac_version, read_dict, set_stac_version -from .io import DefaultReader, DefaultWriter, read_file, write_file +from .io import read_file, write_file from .item import Item from .link import Link -from .render import DefaultRenderer, Renderer +from .media_type import MediaType from .stac_object import STACObject @@ -26,32 +26,31 @@ def __getattr__(name: str) -> Any: from .stac_io import StacIO return StacIO + else: + raise AttributeError(name) __all__ = [ "Asset", + "ItemAsset", "Catalog", "Collection", - "Container", "DEFAULT_STAC_VERSION", - "DefaultReader", - "DefaultRenderer", - "DefaultWriter", - "Extent", - "Item", - "ItemAsset", - "Link", + "Container", "PystacError", "PystacWarning", - "Renderer", - "STACObject", - "SpatialExtent", "StacError", "StacWarning", + "Extent", + "SpatialExtent", "TemporalExtent", "get_stac_version", "read_dict", - "read_file", "set_stac_version", + "read_file", "write_file", + "Item", + "Link", + "MediaType", + "STACObject", ] diff --git a/src/pystac/asset.py b/src/pystac/asset.py index 62f284b14..9807b2746 100644 --- a/src/pystac/asset.py +++ b/src/pystac/asset.py @@ -1,7 +1,7 @@ from __future__ import annotations import copy -from typing import Any +from typing import Any, Protocol, runtime_checkable from typing_extensions import Self @@ -77,10 +77,8 @@ def to_dict(self) -> dict[str, Any]: return d -class AssetsMixin: - """A mixin for things that have assets (Collections and Items)""" +@runtime_checkable +class Assets(Protocol): + """A protocol for things that have assets (Collections and Items)""" assets: dict[str, Asset] - - def add_asset(self, key: str, asset: Asset) -> None: - raise NotImplementedError diff --git a/src/pystac/constants.py b/src/pystac/constants.py index 83de952ef..afab58cf3 100644 --- a/src/pystac/constants.py +++ b/src/pystac/constants.py @@ -17,13 +17,13 @@ ITEM_TYPE = "Feature" """The type field of a JSON STAC Item.""" -CHILD_REL = "child" +CHILD = "child" """The child relation type, for links.""" -ITEM_REL = "item" +ITEM = "item" """The item relation type, for links.""" -PARENT_REL = "parent" +PARENT = "parent" """The parent relation type, for links.""" -ROOT_REL = "root" +ROOT = "root" """The root relation type, for links.""" -SELF_REL = "self" +SELF = "self" """The self relation type, for links.""" diff --git a/src/pystac/container.py b/src/pystac/container.py index 9c6028d3b..33d89294b 100644 --- a/src/pystac/container.py +++ b/src/pystac/container.py @@ -3,8 +3,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Iterator -from . import io -from .decorators import v2_deprecated +from . import deprecate +from .constants import CHILD, ITEM from .io import Write from .item import Item from .link import Link @@ -50,9 +50,7 @@ def walk(self) -> Iterator[tuple[Container, list[Container], list[Item]]]: """ children: list[Container] = [] items: list[Item] = [] - for link in filter( - lambda link: link.is_child() or link.is_item(), self.iter_links() - ): + for link in self.iter_links(CHILD, ITEM): stac_object = link.get_stac_object() if isinstance(stac_object, Container): children.append(stac_object) @@ -125,43 +123,33 @@ def get_collections(self, recursive: bool = False) -> Iterator[Collection]: if recursive and isinstance(stac_object, Container): yield from stac_object.get_collections(recursive=recursive) - def render( + def save( self, - root: str | Path, + catalog_type: Any = None, + dest_href: str | Path | None = None, + stac_io: Any = None, + *, + writer: Write | None = None, ) -> None: - """Renders this container and all of its children and items. - - See the [pystac.render][] documentation for more. - - Args: - root: The directory at the root of the rendered filesystem tree. - """ - # TODO allow renderer customization - from .render import DefaultRenderer - - renderer = DefaultRenderer(str(root)) - renderer.render(self) - - def save(self, writer: Write | None = None) -> None: - """Saves this container and all of its children. - - This will error if any objects don't have an `href` set. Use - [Container.render][pystac.Container.render] to set those `href` values. - - Args: - writer: The writer that will be used for the save operation. If not - provided, this container's writer will be used. - """ + if catalog_type: + deprecate.argument("catalog_type") + if dest_href: + deprecate.argument("dest_href") + if stac_io: + deprecate.argument("stac_io") if writer is None: writer = self.writer - io.write_file(self, writer=writer) + + self.save_object(stac_io=stac_io, writer=writer) for stac_object in self.get_children_and_items(): if isinstance(stac_object, Container): - stac_object.save(writer) + stac_object.save( + writer=writer + ) # TODO do we need to pass through any of the deprecated arguments? else: - io.write_file(stac_object, writer=writer) + stac_object.save_object(writer=writer) - @v2_deprecated("Use render() and then save()") + @deprecate.function("Use render() and then save()") def normalize_and_save( self, root_href: str, diff --git a/src/pystac/decorators.py b/src/pystac/deprecate.py similarity index 56% rename from src/pystac/decorators.py rename to src/pystac/deprecate.py index 588517140..9403b9803 100644 --- a/src/pystac/decorators.py +++ b/src/pystac/deprecate.py @@ -3,7 +3,24 @@ from typing import Any, Callable -def v2_deprecated(message: str) -> Callable[..., Any]: +def argument(name: str) -> None: + warnings.warn( + f"Argument {name} is deprecated in PySTAC v2.0 and will be removed in a future " + "version.", + FutureWarning, + ) + + +def module(name: str) -> None: + warnings.warn( + f"Module pystac.{name} is deprecated in PySTAC v2.0 " + "and will be removed in a future " + "version.", + FutureWarning, + ) + + +def function(message: str) -> Callable[..., Any]: def decorator(f: Callable[..., Any]) -> Callable[..., Any]: @wraps(f) def wrapper(*args: Any, **kwargs: Any) -> Any: diff --git a/src/pystac/extent.py b/src/pystac/extent.py index ee5c7a6cf..79bc8d69b 100644 --- a/src/pystac/extent.py +++ b/src/pystac/extent.py @@ -7,8 +7,8 @@ from typing_extensions import Self +from . import deprecate from .constants import DEFAULT_BBOX, DEFAULT_INTERVAL -from .decorators import v2_deprecated from .errors import StacWarning from .types import PermissiveBbox, PermissiveInterval @@ -57,7 +57,7 @@ def from_dict(cls: type[Self], d: dict[str, Any]) -> Self: return cls(**d) @classmethod - @v2_deprecated("Use the constructor instead") + @deprecate.function("Use the constructor instead") def from_coordinates( cls: type[Self], coordinates: list[Any], diff --git a/src/pystac/functions.py b/src/pystac/functions.py index 9fb423f31..9dc63024c 100644 --- a/src/pystac/functions.py +++ b/src/pystac/functions.py @@ -1,11 +1,11 @@ from typing import Any +from . import deprecate from .constants import DEFAULT_STAC_VERSION -from .decorators import v2_deprecated from .stac_object import STACObject -@v2_deprecated("Use DEFAULT_STAC_VERSION instead.") +@deprecate.function("Use DEFAULT_STAC_VERSION instead.") def get_stac_version() -> str: """**DEPRECATED** Returns the default STAC version. @@ -24,7 +24,7 @@ def get_stac_version() -> str: return DEFAULT_STAC_VERSION -@v2_deprecated( +@deprecate.function( "This function is a no-op. Use `Container.set_stac_version()` to modify the STAC " "version of an entire catalog." ) @@ -38,7 +38,7 @@ def set_stac_version(version: str) -> None: """ -@v2_deprecated("Use STACObject.from_dict instead") +@deprecate.function("Use STACObject.from_dict instead") def read_dict(d: dict[str, Any]) -> STACObject: """**DEPRECATED** Reads a STAC object from a dictionary. diff --git a/src/pystac/io.py b/src/pystac/io.py index b30a29986..96fac70c8 100644 --- a/src/pystac/io.py +++ b/src/pystac/io.py @@ -1,8 +1,8 @@ """Input and output. In PySTAC v2.0, reading and writing STAC objects has been split into separate -protocols, [Read][pystac.io.Read] and [Write][pystac.io.Write] classes. This -should be a transparent operation for most users: +protocols, [Read][pystac.io.Read] and [Write][pystac.io.Write]. This should be +transparent for most users: ```python catalog = pystac.read_file("catalog.json") @@ -25,31 +25,37 @@ from pathlib import Path from typing import Any, Protocol +from . import deprecate from .errors import PystacError from .stac_object import STACObject -def read_file(href: str | Path, reader: Read | None = None) -> STACObject: +def read_file( + href: str | Path, + stac_io: Any = None, + *, + reader: Read | None = None, +) -> STACObject: """Reads a file from a href. - Uses the default [Reader][pystac.DefaultReader]. - Args: href: The href to read + reader: The [Read][pystac.Read] to use for reading Returns: The STAC object - - Examples: - >>> item = pystac.read_file("item.json") """ + if stac_io: + deprecate.argument("stac_io") return STACObject.from_file(href, reader=reader) def write_file( - stac_object: STACObject, + obj: STACObject, + include_self_link: bool | None = None, + dest_href: str | Path | None = None, + stac_io: Any = None, *, - href: str | Path | None = None, writer: Write | None = None, ) -> None: """Writes a STAC object to a file, using its href. @@ -57,39 +63,31 @@ def write_file( If the href is not set, this will throw and error. Args: - stac_object: The STAC object to write + obj: The STAC object to write + dest_href: The href to write the STAC object to + writer: The [Write][pystac.Write] to use for writing """ + if include_self_link is not None: + deprecate.argument("include_self_link") + if stac_io: + deprecate.argument("stac_io") + if writer is None: writer = DefaultWriter() - if href is None: - href = stac_object.href - if href is None: - raise PystacError(f"cannot write {stac_object} without an href") - data = stac_object.to_dict() - if isinstance(href, Path): - writer.write_json_to_path(data, href) + + if dest_href is None: + dest_href = obj.href + if dest_href is None: + raise PystacError(f"cannot write {obj} without an href") + d = obj.to_dict() + if isinstance(dest_href, Path): + writer.write_json_to_path(d, dest_href) else: - url = urllib.parse.urlparse(href) + url = urllib.parse.urlparse(dest_href) if url.scheme: - writer.write_json_to_url(data, href) + writer.write_json_to_url(d, dest_href) else: - writer.write_json_to_path(data, Path(href)) - - -def make_absolute_href(href: str, base: str | None) -> str: - if urllib.parse.urlparse(href).scheme: - return href # TODO file:// schemes - - if base: - if urllib.parse.urlparse(base).scheme: - raise NotImplementedError("url joins not implemented yet, should be easy") - else: - if base.endswith("/"): # TODO windoze - return str((Path(base) / href).resolve(strict=False)) - else: - return str((Path(base).parent / href).resolve(strict=False)) - else: - raise NotImplementedError + writer.write_json_to_path(d, Path(dest_href)) class Read(Protocol): diff --git a/src/pystac/item.py b/src/pystac/item.py index 618e66ce5..24d7e8377 100644 --- a/src/pystac/item.py +++ b/src/pystac/item.py @@ -5,14 +5,14 @@ import warnings from typing import Any, Sequence -from .asset import Asset, AssetsMixin +from .asset import Asset from .constants import ITEM_TYPE from .errors import StacWarning from .link import Link from .stac_object import STACObject -class Item(STACObject, AssetsMixin): +class Item(STACObject): """An Item is a GeoJSON Feature augmented with foreign members relevant to a STAC object. @@ -102,6 +102,9 @@ def __init__( def get_fields(self) -> dict[str, Any]: return self.properties + def add_asset(self, key: str, asset: Asset) -> None: + self.assets[key] = asset + def _to_dict(self) -> dict[str, Any]: d: dict[str, Any] = { "type": self.get_type(), diff --git a/src/pystac/link.py b/src/pystac/link.py index 928160ce8..ae94bc83f 100644 --- a/src/pystac/link.py +++ b/src/pystac/link.py @@ -4,7 +4,7 @@ from typing_extensions import Self -from .constants import CHILD_REL, ITEM_REL, PARENT_REL, ROOT_REL, SELF_REL +from .constants import CHILD, ITEM, PARENT, ROOT, SELF from .errors import PystacError if TYPE_CHECKING: @@ -25,23 +25,23 @@ def from_dict( @classmethod def root(cls: type[Self], root: STACObject) -> Self: - return cls(href=root.href, rel=ROOT_REL, stac_object=root) + return cls(href=root.href, rel=ROOT, stac_object=root) @classmethod def parent(cls: type[Self], parent: STACObject) -> Self: - return cls(href=parent.href, rel=PARENT_REL, stac_object=parent) + return cls(href=parent.href, rel=PARENT, stac_object=parent) @classmethod def child(cls: type[Self], child: STACObject) -> Self: - return cls(href=child.href, rel=CHILD_REL, stac_object=child) + return cls(href=child.href, rel=CHILD, stac_object=child) @classmethod def item(cls: type[Self], item: Item) -> Self: - return cls(href=item.href, rel=ITEM_REL, stac_object=item) + return cls(href=item.href, rel=ITEM, stac_object=item) @classmethod def self(cls: type[Self], stac_object: STACObject) -> Self: - return cls(href=stac_object.href, rel=SELF_REL, stac_object=stac_object) + return cls(href=stac_object.href, rel=SELF, stac_object=stac_object) def __init__( self, @@ -67,19 +67,19 @@ def __init__( # TODO extra fields def is_root(self) -> bool: - return self.rel == ROOT_REL + return self.rel == ROOT def is_parent(self) -> bool: - return self.rel == PARENT_REL + return self.rel == PARENT def is_child(self) -> bool: - return self.rel == CHILD_REL + return self.rel == CHILD def is_item(self) -> bool: - return self.rel == ITEM_REL + return self.rel == ITEM def is_self(self) -> bool: - return self.rel == SELF_REL + return self.rel == SELF def get_stac_object(self) -> STACObject: if self._stac_object is None: diff --git a/src/pystac/media_type.py b/src/pystac/media_type.py new file mode 100644 index 000000000..4f69148a7 --- /dev/null +++ b/src/pystac/media_type.py @@ -0,0 +1,30 @@ +from pystac.utils import StringEnum + + +class MediaType(StringEnum): + """A list of common media types that can be used in STAC Asset and Link metadata.""" + + COG = "image/tiff; application=geotiff; profile=cloud-optimized" + FLATGEOBUF = "application/vnd.flatgeobuf" # https://github.com/flatgeobuf/flatgeobuf/discussions/112#discussioncomment-4606721 # noqa + GEOJSON = "application/geo+json" + GEOPACKAGE = "application/geopackage+sqlite3" + GEOTIFF = "image/tiff; application=geotiff" + HDF = "application/x-hdf" # Hierarchical Data Format versions 4 and earlier. + HDF5 = "application/x-hdf5" # Hierarchical Data Format version 5 + HTML = "text/html" + JPEG = "image/jpeg" + JPEG2000 = "image/jp2" + JSON = "application/json" + PARQUET = "application/x-parquet" # https://github.com/opengeospatial/geoparquet/issues/115#issuecomment-1181549523 + PNG = "image/png" + TEXT = "text/plain" + TIFF = "image/tiff" + KML = "application/vnd.google-earth.kml+xml" + XML = "application/xml" + PDF = "application/pdf" + ZARR = "application/vnd+zarr" # https://github.com/openMetadataInitiative/openMINDS_core/blob/v4/instances/data/contentTypes/zarr.jsonld + NETCDF = "application/netcdf" # https://github.com/Unidata/netcdf/issues/42#issuecomment-1007618822 + + +#: Media types that can be resolved as STAC Objects +STAC_JSON = [None, MediaType.GEOJSON, MediaType.JSON] diff --git a/src/pystac/render.py b/src/pystac/render.py index 4c144ce5f..7bd945671 100644 --- a/src/pystac/render.py +++ b/src/pystac/render.py @@ -1,115 +1,31 @@ -"""Use renderers to build links and set hrefs on STAC objects, their children, -and their items. - -STAC catalogs look like trees: - -```mermaid -graph TD - catalog["Catalog"] --> collection["Collection"] --> item["Item"] -``` - -Via `links`, STAC objects can "live" anywhere ... their locations on a -filesystem or in blob storage don't have to mirror their tree structure. In -PySTAC v2.0, STAC "trees" are mapped onto hrefs via a process called "rendering": - -```python -catalog = Catalog("an-id", "a description") -collection = Collection("a-collection", "the collection") -item = Item("an-item") -catalog.add_child(collection) -collection.add_item(item) - -catalog.render("/pystac") - -assert catalog.get_self_href() == "/pystac/catalog.json" -assert collection.get_self_href() == "/pystac/a-collection/collection.json" -assert item.get_self_href() == "/pystac/a-collection/an-item/an-item.json" -``` - -Note: - In PySTAC v1.x, setting hrefs and links was handled in - [layout](https://pystac.readthedocs.io/en/stable/api/layout.html). -""" - -from abc import ABC, abstractmethod +from typing import Protocol from .catalog import Catalog from .collection import Collection -from .container import Container from .item import Item -from .link import Link from .stac_object import STACObject -class Renderer(ABC): - """The base class for all renderers.""" +class Render(Protocol): + def get_href(self, stac_object: STACObject, base: str) -> str: + """Returns a STAC object's href.""" - def __init__(self, root: str): - """Creates a new renderer, rooted at the location provided.""" - self.root = root + def get_file_name(self, stac_object: STACObject) -> str: + """Returns a STAC object's file name.""" - def render(self, stac_object: STACObject) -> str: - """Render a single STAC object by modifying its self href and links. - Returns: - This object's href - """ - from .catalog import Catalog - from .collection import Collection - from .item import Item +class BestPracticesRenderer: + """The default renderer, based on STAC [best practices](https://github.com/radiantearth/stac-spec/blob/master/best-practices.md#catalog-layout).""" - if (parent_link := stac_object.get_parent_link()) and parent_link.href: - # TODO is this true for every renderer? - base = parent_link.href.rsplit("/", 1)[0] + "/" + stac_object.id - else: - base = self.root + def get_href(self, stac_object: STACObject, base: str) -> str: + return "/".join((base, stac_object.id, self.get_file_name(stac_object))) + def get_file_name(self, stac_object: STACObject) -> str: if isinstance(stac_object, Item): - href = self.get_item_href(stac_object, base) + return f"{stac_object.id}.json" elif isinstance(stac_object, Catalog): - href = self.get_catalog_href(stac_object, base) + return "catalog.json" elif isinstance(stac_object, Collection): - href = self.get_collection_href(stac_object, base) + return "collection.json" else: raise Exception("unreachable") - stac_object.href = href - stac_object.set_self_link() - - root_link = stac_object.get_root_link() - if isinstance(stac_object, Container): - if root_link is None: - root_link = Link.root(stac_object) - for link in filter( - lambda link: link.is_child() or link.is_item(), stac_object.iter_links() - ): - leaf = link.get_stac_object() - leaf.set_link(root_link) - leaf.set_link(Link.parent(stac_object)) - link.href = self.render(leaf) - - return href - - @abstractmethod - def get_item_href(self, item: Item, base: str) -> str: - """Returns an item's href.""" - - @abstractmethod - def get_collection_href(self, collection: Collection, base: str) -> str: - """Returns a collection's href.""" - - @abstractmethod - def get_catalog_href(self, catalog: Catalog, base: str) -> str: - """Returns a catalog's href.""" - - -class DefaultRenderer(Renderer): - """The default renderer, based on STAC [best practices](https://github.com/radiantearth/stac-spec/blob/master/best-practices.md#catalog-layout).""" - - def get_item_href(self, item: Item, base: str) -> str: - return base + "/" + item.id + ".json" - - def get_catalog_href(self, catalog: Catalog, base: str) -> str: - return base + "/catalog.json" - - def get_collection_href(self, collection: Collection, base: str) -> str: - return base + "/collection.json" diff --git a/src/pystac/stac_io.py b/src/pystac/stac_io.py index 255c371e0..1cf22dcdb 100644 --- a/src/pystac/stac_io.py +++ b/src/pystac/stac_io.py @@ -1,13 +1,8 @@ -import warnings - from typing_extensions import Self -warnings.warn( - "The stac_io module, and all StacIO classes, are deprecated as of pystac v2.0 " - "and will be removed in a future version. Use pystac.Reader and pystac.Writer " - "instead.", - FutureWarning, -) +from . import deprecate + +deprecate.module("stac_io") class StacIO: diff --git a/src/pystac/stac_object.py b/src/pystac/stac_object.py index 6dd76224f..57ebb5bd2 100644 --- a/src/pystac/stac_object.py +++ b/src/pystac/stac_object.py @@ -8,14 +8,18 @@ from typing_extensions import Self +from . import deprecate, utils +from .asset import Assets from .constants import ( CATALOG_TYPE, + CHILD, COLLECTION_TYPE, DEFAULT_STAC_VERSION, + ITEM, ITEM_TYPE, - PARENT_REL, - ROOT_REL, - SELF_REL, + PARENT, + ROOT, + SELF, ) from .errors import PystacError, StacError from .link import Link @@ -24,6 +28,7 @@ from .catalog import Catalog from .container import Container from .io import Read, Write + from .render import Render class STACObject(ABC): @@ -43,11 +48,17 @@ def get_type(cls: type[Self]) -> str: def from_file( cls: type[Self], href: str | Path, + stac_io: Any = None, + *, reader: Read | None = None, - writer: Write | None = None, ) -> Self: """Reads a STAC object from a JSON file. + Args: + href: The file to read + reader: The reader to use for the read. This will be saved as the + `.reader` attribute of the read object. + Raises: StacError: Raised if the object's type does not match the calling class. @@ -57,6 +68,10 @@ def from_file( """ from .io import DefaultReader + if stac_io: + deprecate.argument("stac_io") + # TODO build reader + if reader is None: reader = DefaultReader() @@ -68,10 +83,13 @@ def from_file( d = reader.read_json_from_url(href) else: d = reader.read_json_from_path(Path(href)) + if not isinstance(d, dict): raise PystacError(f"JSON is not a dict: {type(d)}") - stac_object = cls.from_dict(d, href=str(href), reader=reader, writer=writer) + stac_object = cls.from_dict(d) + stac_object.href = str(href) + stac_object.reader = reader if isinstance(stac_object, cls): return stac_object else: @@ -81,13 +99,10 @@ def from_file( def from_dict( cls: type[Self], d: dict[str, Any], - *, href: str | None = None, root: Catalog | None = None, - migrate: bool = False, - preserve_dict: bool = True, # TODO deprecation warning - reader: Read | None = None, - writer: Write | None = None, + migrate: bool | None = None, + preserve_dict: bool | None = None, ) -> Self: """Creates a STAC object from a dictionary. @@ -109,37 +124,43 @@ def from_dict( >>> # Use this when you know you have a catalog >>> catalog = Catalog(**d) """ + if href is not None: + deprecate.argument("href") + if root is not None: + deprecate.argument("root") + if migrate is not None: + deprecate.argument("migrate") + else: + migrate = False + if preserve_dict is not None: + deprecate.argument("preserve_dict") + else: + preserve_dict = True + if type_value := d.get("type"): if type_value == CATALOG_TYPE: from .catalog import Catalog stac_object: STACObject = Catalog( - **d, href=href, reader=reader, writer=writer + **d, + href=href, ) elif type_value == COLLECTION_TYPE: from .collection import Collection - stac_object = Collection(**d, href=href, reader=reader, writer=writer) + stac_object = Collection(**d, href=href) elif type_value == ITEM_TYPE: from .item import Item - stac_object = Item(**d, href=href, reader=reader, writer=writer) + stac_object = Item(**d, href=href) else: - raise StacError(f"unknown type field: {type_value}") - + raise StacError(f"invalid type field: {type_value}") if isinstance(stac_object, cls): if root: - warnings.warn( - "The `root` argument is deprecated in PySTAC v2 and " - "will be removed in a future version. Prefer to use " - "`stac_object.set_link(Link.root(catalog))` " - "after object creation.", - FutureWarning, - ) stac_object.set_link(Link.root(root)) return stac_object else: - raise PystacError(f"Expected {cls} but got a {type(stac_object)}") + raise PystacError(f"expected {cls} but got a {type(stac_object)}") else: raise StacError("missing type field on dictionary") @@ -209,7 +230,7 @@ def __init__( you don't want a self link, but you still want to track an object's location. """ - if href is None and (self_link := self.get_link(SELF_REL)): + if href is None and (self_link := self.get_link(SELF)): self.href = self_link.href else: self.href = href @@ -220,37 +241,37 @@ def read_file(self, href: str) -> STACObject: This method will resolve relative hrefs by using this STAC object's href or, if not set, its `self` link. """ - from . import io base = self.href - if base is None and (link := self.get_link(SELF_REL)): + if base is None and (link := self.get_link(SELF)): base = link.href - href = io.make_absolute_href(href, base) - return STACObject.from_file(href, reader=self.reader, writer=self.writer) + href = utils.make_absolute_href(href, base) + stac_object = STACObject.from_file(href, reader=self.reader) + stac_object.writer = self.writer + return stac_object def save_object( self, include_self_link: bool | None = None, dest_href: str | Path | None = None, stac_io: Any = None, + *, + writer: Write | None = None, ) -> None: from . import io if include_self_link is not None: - warnings.warn( - "The include_self_link argument to `save_object` is deprecated as of " - "PySTAC v2.0 and will be removed in a future version. Prefer to add " - "or remove a self link directly before calling `save_object`.", - FutureWarning, - ) + deprecate.argument("include_self_link") self.set_self_link() if stac_io: - warnings.warn("Discarding StacIO. TODO either use it or error") + deprecate.argument("stac_io") if not dest_href: dest_href = self.href + if not writer: + writer = self.writer if dest_href: - io.write_file(self, href=dest_href, writer=self.writer) + io.write_file(self, dest_href=dest_href, writer=self.writer) else: raise PystacError("cannot save an object without an href") @@ -258,7 +279,7 @@ def get_root(self) -> Container | None: """Returns the container at this object's root link, if there is one.""" from .container import Container - if link := self.get_link(ROOT_REL): + if link := self.get_root_link(): stac_object = link.get_stac_object() if isinstance(stac_object, Container): return stac_object @@ -273,9 +294,18 @@ def get_link(self, rel: str) -> Link | None: def get_fields(self) -> dict[str, Any]: return self.extra_fields - def iter_links(self, rel: str | None = None) -> Iterator[Link]: + def iter_links(self, *rels: str) -> Iterator[Link]: + """Iterate over links, optionally filtering by one or more relation types. + + Examples: + + >>> from pystac import CHILD, ITEM + >>> links = list(item.iter_links()) + >>> child_links = list(item.iter_links(CHILD)) + >>> sub_links = list(item.iter_links(CHILD, ITEM)) + """ for link in self._links: - if rel is None or rel == link.rel: + if not rels or link.rel in rels: yield link def set_link(self, link: Link) -> None: @@ -289,6 +319,69 @@ def set_self_link(self) -> None: else: self.set_link(Link.self(self)) + @deprecate.function("Prefer to set href directly, and then use `render()`") + def set_self_href(self, href: str | None = None) -> None: + self.href = href + if self.href: + self.render(include_self_link=True) + else: + self.remove_links(SELF) + + def migrate(self) -> None: + # TODO do more + self.stac_version = DEFAULT_STAC_VERSION + + def render( + self, + root: str | Path | None = None, + *, + renderer: Render | None = None, + include_self_link: bool = True, + use_relative_asset_hrefs: bool = False, + ) -> None: + from .container import Container + + if renderer is None: + from .render import BestPracticesRenderer + + renderer = BestPracticesRenderer() + + if isinstance(self, Assets) and self.assets: + for asset in self.assets.values(): + asset.href = utils.make_absolute_href(asset.href, self.href) + + if root: + self.href = str(root) + "/" + renderer.get_file_name(self) + if include_self_link: + self.set_self_link() + + if isinstance(self, Container): + if not self.href: + raise PystacError( + "cannot render a container if the STAC object's href is None " + "and no root is provided" + ) + elif "/" in self.href: + base = self.href.rsplit("/", 1)[0] + else: + base = "." + + root_link = self.get_root_link() + if root_link is None: + root_link = Link.root(self) + for link in self.iter_links(CHILD, ITEM): + leaf = link.get_stac_object() + leaf.set_link(root_link) + leaf.set_link(Link.parent(self)) + leaf.href = renderer.get_href(leaf, base) + link.href = leaf.href + leaf.render() + + if use_relative_asset_hrefs and isinstance(self, Assets) and self.assets: + for asset in self.assets.values(): + assert self.href + asset.href = utils.make_relative_href(asset.href, self.href) + def remove_links(self, rel: str) -> None: self._links = [link for link in self._links if link.rel != rel] @@ -297,10 +390,10 @@ def add_link(self, link: Link) -> None: self._links.append(link) def get_root_link(self) -> Link | None: - return self.get_link(ROOT_REL) + return self.get_link(ROOT) def get_parent_link(self) -> Link | None: - return self.get_link(PARENT_REL) + return self.get_link(PARENT) def to_dict( self, include_self_link: bool | None = None, transform_hrefs: bool | None = None diff --git a/src/pystac/utils.py b/src/pystac/utils.py new file mode 100644 index 000000000..3b2a3f3c0 --- /dev/null +++ b/src/pystac/utils.py @@ -0,0 +1,236 @@ +# TODO refactor + +import os.path +import posixpath +import urllib.parse +from enum import Enum +from pathlib import Path +from typing import cast +from urllib.parse import ParseResult + + +class StringEnum(str, Enum): + def __repr__(self) -> str: + return repr(self.value) + + def __str__(self) -> str: + return cast(str, self.value) + + +def is_absolute_href(href: str) -> bool: + parsed = safe_urlparse(href) + return parsed.scheme not in ["", "file"] or os.path.isabs(parsed.path) + + +def safe_urlparse(href: str) -> ParseResult: + parsed = urllib.parse.urlparse(href) + if parsed.scheme != "" and ( + href.lower().startswith(f"{parsed.scheme}:\\") + or ( + href.lower().startswith(f"{parsed.scheme}:/") + and not href.lower().startswith(f"{parsed.scheme}://") + ) + ): + return ParseResult( + scheme="", + netloc="", + path="{}:{}".format( + # We use this more complicated formulation because parsed.scheme + # converts to lower-case + href[: len(parsed.scheme)], + parsed.path, + ), + params=parsed.params, + query=parsed.query, + fragment=parsed.fragment, + ) + + # Windows drives sometimes get parsed as the netloc and sometimes + # as part of the parsed.path. + if parsed.scheme == "file" and os.name == "nt": + if parsed.netloc: + path = f"{parsed.netloc}{parsed.path}" + elif parsed.path.startswith("/") and ":" in parsed.path: + path = parsed.path[1:] + else: + path = parsed.path + + return ParseResult( + scheme=parsed.scheme, + netloc="", + path=path, + params=parsed.params, + query=parsed.query, + fragment=parsed.fragment, + ) + else: + return parsed + + +def make_absolute_href( + source_href: str, start_href: str | None = None, start_is_dir: bool = False +) -> str: + if start_href is None: + start_href = os.getcwd() + start_is_dir = True + + source_href = make_posix_style(source_href) + start_href = make_posix_style(start_href) + + parsed_start = safe_urlparse(start_href) + parsed_source = safe_urlparse(source_href) + + if parsed_source.scheme not in ["", "file"] or parsed_start.scheme not in [ + "", + "file", + ]: + return _make_absolute_href_url(parsed_source, parsed_start, start_is_dir) + else: + return _make_absolute_href_path(parsed_source, parsed_start, start_is_dir) + + +def _make_absolute_href_path( + parsed_source: ParseResult, + parsed_start: ParseResult, + start_is_dir: bool = False, +) -> str: + # If the source is already absolute, just return it + if os.path.isabs(parsed_source.path): + return urllib.parse.urlunparse(parsed_source) + + # If the start path is not a directory, get the parent directory + start_dir = ( + parsed_start.path if start_is_dir else os.path.dirname(parsed_start.path) + ) + + # Join the start directory to the relative path and find the absolute path + abs_path = make_posix_style( + os.path.abspath(os.path.join(start_dir, parsed_source.path)) + ) + + # Account for the normalization of abspath for + # things like /vsitar// prefixes by replacing the + # original start_dir text when abspath modifies the start_dir. + if not start_dir == make_posix_style(os.path.abspath(start_dir)): + abs_path = abs_path.replace( + make_posix_style(os.path.abspath(start_dir)), start_dir + ) + + if parsed_source.scheme or parsed_start.scheme: + abs_path = f"file://{abs_path}" + + return abs_path + + +def _make_absolute_href_url( + parsed_source: ParseResult, + parsed_start: ParseResult, + start_is_dir: bool = False, +) -> str: + # If the source is already absolute, just return it + if parsed_source.scheme != "": + return urllib.parse.urlunparse(parsed_source) + + # If the start path is not a directory, get the parent directory + if start_is_dir: + start_dir = parsed_start.path + else: + # Ensure the directory has a trailing slash so urljoin works properly + start_dir = parsed_start.path.rsplit("/", 1)[0] + "/" + + # Join the start directory to the relative path and find the absolute path + abs_path = urllib.parse.urljoin(start_dir, parsed_source.path) + abs_path = abs_path.replace("\\", "/") + + return urllib.parse.urlunparse( + ( + parsed_start.scheme, + parsed_start.netloc, + abs_path, + parsed_source.params, + parsed_source.query, + parsed_source.fragment, + ) + ) + + +def make_posix_style(href: str | Path) -> str: + _href = str(os.fspath(href)) + return _href.replace("\\\\", "/").replace("\\", "/") + + +def make_relative_href( + source_href: str, start_href: str, start_is_dir: bool = False +) -> str: + source_href = make_posix_style(source_href) + start_href = make_posix_style(start_href) + + parsed_source = safe_urlparse(source_href) + parsed_start = safe_urlparse(start_href) + if not ( + parsed_source.scheme == parsed_start.scheme + and parsed_source.netloc == parsed_start.netloc + ): + return source_href + + if parsed_start.scheme in ["", "file"]: + return _make_relative_href_path(parsed_source, parsed_start, start_is_dir) + else: + return _make_relative_href_url(parsed_source, parsed_start, start_is_dir) + + +def _make_relative_href_path( + parsed_source: ParseResult, + parsed_start: ParseResult, + start_is_dir: bool = False, +) -> str: + # If the start path is not a directory, get the parent directory + start_dir = ( + parsed_start.path if start_is_dir else os.path.dirname(parsed_start.path) + ) + + # Strip the leading slashes from both paths + start_dir = start_dir.lstrip("/") + source_path = parsed_source.path.lstrip("/") + + # posixpath doesn't play well with windows drive letters, so we have to use + # the os-specific path library for the relpath function. This means we can + # only handle windows paths on windows machines. + relpath = make_posix_style(os.path.relpath(source_path, start_dir)) + + # Ensure we retain a trailing slash from the original source path + if parsed_source.path.endswith("/"): + relpath += "/" + + if relpath != "./" and not relpath.startswith("../"): + relpath = "./" + relpath + + return relpath + + +def _make_relative_href_url( + parsed_source: ParseResult, + parsed_start: ParseResult, + start_is_dir: bool = False, +) -> str: + # If the start path is not a directory, get the parent directory + start_dir = ( + parsed_start.path if start_is_dir else os.path.dirname(parsed_start.path) + ) + + # Strip the leading slashes from both paths + start_dir = start_dir.lstrip("/") + source_path = parsed_source.path.lstrip("/") + + # Get the relative path + rel_url = posixpath.relpath(source_path, start_dir) + + # Ensure we retain a trailing slash from the original source path + if parsed_source.path.endswith("/"): + rel_url += "/" + + # Prepend the "./", if necessary + if rel_url != "./" and not rel_url.startswith("../"): + rel_url = "./" + rel_url + + return rel_url diff --git a/tests/test_item.py b/tests/test_item.py index 1449d440d..443971513 100644 --- a/tests/test_item.py +++ b/tests/test_item.py @@ -39,8 +39,9 @@ def test_warn_transform_hrefs() -> None: Item("an-id").to_dict(transform_hrefs=True) -def test_from_dict_migrate() -> None: +def test_migrate() -> None: d = Item("an-id").to_dict() d["stac_version"] = "1.0.0" - item = Item.from_dict(d, migrate=True) + item = Item.from_dict(d) + item.migrate() item.stac_version == "1.1.0" diff --git a/tests/test_render.py b/tests/test_render.py index b0e7193c9..84d2759f6 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -1,11 +1,4 @@ -import pytest - -from pystac import Catalog, Collection, DefaultRenderer, Item, Renderer, STACObject - - -@pytest.fixture -def renderer() -> Renderer: - return DefaultRenderer("/pystac") +from pystac import Catalog, Collection, Item, STACObject def assert_link(stac_object: STACObject, rel: str, href: str) -> None: @@ -14,42 +7,23 @@ def assert_link(stac_object: STACObject, rel: str, href: str) -> None: assert link.href == href -def test_solo_item_render(renderer: Renderer) -> None: - item = Item("an-id") - renderer.render(item) - assert_link(item, "self", "/pystac/an-id.json") - - -def test_solo_catalog_render(renderer: Renderer) -> None: - catalog = Catalog("an-id", "a description") - renderer.render(catalog) - assert_link(catalog, "self", "/pystac/catalog.json") - - -def test_solo_collection_render(renderer: Renderer) -> None: - collection = Collection("an-id", "a description") - renderer.render(collection) - assert_link(collection, "self", "/pystac/collection.json") - - -def test_child_catalog_render(renderer: Renderer) -> None: +def test_child_catalog_render() -> None: catalog = Catalog("parent", "parent catalog") child = Catalog("child", "child catalog") catalog.add_child(child) - renderer.render(catalog) - assert catalog.href == "/pystac/catalog.json" + catalog.render("/pystac") child_link = next(catalog.iter_links("child")) assert child_link.href assert child.href == "/pystac/child/catalog.json" -def test_full_tree_render(renderer: Renderer) -> None: +def test_full_tree_render() -> None: catalog = Catalog("parent", "parent catalog") child = Collection("child", "child collection") item = Item("an-id") catalog.add_child(child) child.add_item(item) - renderer.render(catalog) + catalog.render("/pystac") assert catalog.href == "/pystac/catalog.json" assert child.href == "/pystac/child/collection.json" assert item.href == "/pystac/child/an-id/an-id.json" diff --git a/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/5b922d42-9a77-4f79-a672-86096f7f849e/cf73ec1a-d790-4b59-b077-e101738571ed/cf73ec1a-d790-4b59-b077-e101738571ed.json b/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/5b922d42-9a77-4f79-a672-86096f7f849e/cf73ec1a-d790-4b59-b077-e101738571ed/cf73ec1a-d790-4b59-b077-e101738571ed.json new file mode 100644 index 000000000..b7094ab45 --- /dev/null +++ b/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/5b922d42-9a77-4f79-a672-86096f7f849e/cf73ec1a-d790-4b59-b077-e101738571ed/cf73ec1a-d790-4b59-b077-e101738571ed.json @@ -0,0 +1,914 @@ +{ + "type": "Feature", + "stac_version": "1.1.0", + "id": "cf73ec1a-d790-4b59-b077-e101738571ed", + "properties": { + "label:description": "Labels in layer", + "label:type": "vector", + "datetime": "2019-08-07T20:37:10Z", + "label:classes": [ + { + "name": "label", + "classes": [ + "Passenger Vehicle", + "Bus", + "Truck" + ] + } + ], + "label:properties": [ + "label" + ], + "label:tasks": [ + "detection" + ], + "label:methods": [ + "manual" + ] + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -86.75155098633836, + 35.686645216009545 + ], + [ + -86.75155102590682, + 35.68664554635556 + ], + [ + -86.75155102590682, + 35.90922661159095 + ], + [ + -86.75155098633836, + 35.90922694104629 + ], + [ + -86.75155058459285, + 35.90922697349481 + ], + [ + -86.62304646828477, + 35.90922697349481 + ], + [ + -86.62304606653927, + 35.90922694104629 + ], + [ + -86.6230460269708, + 35.90922661159095 + ], + [ + -86.6230460269708, + 35.87775813030909 + ], + [ + -86.62304585436203, + 35.87775669258231 + ], + [ + -86.623045343169, + 35.87775531010654 + ], + [ + -86.62304451303658, + 35.87775403600955 + ], + [ + -86.62304339586625, + 35.877752919254185 + ], + [ + -86.62304203459027, + 35.87775200275673 + ], + [ + -86.62304048152171, + 35.87775132173768 + ], + [ + -86.62303879634413, + 35.877750902368206 + ], + [ + -86.62303704381796, + 35.87775076076447 + ], + [ + -86.43439019325102, + 35.87775076076447 + ], + [ + -86.43438979150552, + 35.87775072830352 + ], + [ + -86.43438975193705, + 35.87775039872192 + ], + [ + -86.43438975193705, + 35.810302095824625 + ], + [ + -86.43438979150552, + 35.81030176597282 + ], + [ + -86.43439019325102, + 35.810301733485254 + ], + [ + -86.56561991663378, + 35.810301733485254 + ], + [ + -86.56562166915995, + 35.81030159176542 + ], + [ + -86.56562335433752, + 35.81030117205213 + ], + [ + -86.56562490740609, + 35.81030049047472 + ], + [ + -86.56562626868205, + 35.81029957322583 + ], + [ + -86.56562738585238, + 35.8102984555548 + ], + [ + -86.56562821598482, + 35.810297180413116 + ], + [ + -86.56562872717785, + 35.810295796803736 + ], + [ + -86.56562889978662, + 35.81029435789799 + ], + [ + -86.56562889978662, + 35.80805379492521 + ], + [ + -86.56562893935508, + 35.8080534650644 + ], + [ + -86.56562934110059, + 35.808053432575946 + ], + [ + -86.5929614057691, + 35.808053432575946 + ], + [ + -86.59296315829528, + 35.80805329085224 + ], + [ + -86.59296484347286, + 35.8080528711275 + ], + [ + -86.59296639654143, + 35.808052189531494 + ], + [ + -86.59296775781738, + 35.80805127225758 + ], + [ + -86.5929688749877, + 35.808050154556064 + ], + [ + -86.59296970512014, + 35.80804887937959 + ], + [ + -86.59297021631318, + 35.80804749573245 + ], + [ + -86.59297038892194, + 35.80804605678745 + ], + [ + -86.59297038892194, + 35.72487368215667 + ], + [ + -86.59297021631318, + 35.72487224176095 + ], + [ + -86.59296970512014, + 35.72487085671881 + ], + [ + -86.5929688749877, + 35.72486958025665 + ], + [ + -86.59296775781738, + 35.72486846142819 + ], + [ + -86.59296639654143, + 35.724867543229394 + ], + [ + -86.59296484347286, + 35.72486686094612 + ], + [ + -86.59296315829528, + 35.72486644079816 + ], + [ + -86.5929614057691, + 35.72486629893155 + ], + [ + -86.59023668132238, + 35.72486629893155 + ], + [ + -86.59023627957687, + 35.72486626641034 + ], + [ + -86.59023624000841, + 35.72486593621692 + ], + [ + -86.59023624000841, + 35.68664554635556 + ], + [ + -86.59023627957687, + 35.686645216009545 + ], + [ + -86.59023668132238, + 35.68664518347332 + ], + [ + -86.75155058459285, + 35.68664518347332 + ], + [ + -86.75155098633836, + 35.686645216009545 + ] + ], + [ + [ + -86.57382236337436, + 35.857516052580706 + ], + [ + -86.57382411590054, + 35.857515910942126 + ], + [ + -86.57382580107813, + 35.85751549146944 + ], + [ + -86.57382735414667, + 35.85751481028278 + ], + [ + -86.57382871542264, + 35.857513893559734 + ], + [ + -86.57382983259296, + 35.85751277652946 + ], + [ + -86.5738306627254, + 35.85751150211878 + ], + [ + -86.57383117391844, + 35.85751011930261 + ], + [ + -86.5738313465272, + 35.857508681221766 + ], + [ + -86.5738313465272, + 35.85527512323118 + ], + [ + -86.57383117391844, + 35.855273685111264 + ], + [ + -86.5738306627254, + 35.85527230225746 + ], + [ + -86.57382983259296, + 35.85527102781209 + ], + [ + -86.57382871542264, + 35.85526991075136 + ], + [ + -86.57382735414667, + 35.8552689940033 + ], + [ + -86.57382580107813, + 35.85526831279802 + ], + [ + -86.57382411590054, + 35.855267893313886 + ], + [ + -86.57382236337436, + 35.85526775167143 + ], + [ + -86.57110618076653, + 35.85526775167143 + ], + [ + -86.57110442824035, + 35.855267893313886 + ], + [ + -86.57110274306277, + 35.85526831279802 + ], + [ + -86.57110118999421, + 35.8552689940033 + ], + [ + -86.57109982871825, + 35.85526991075136 + ], + [ + -86.57109871154792, + 35.85527102781209 + ], + [ + -86.57109788141548, + 35.85527230225746 + ], + [ + -86.57109737022245, + 35.855273685111264 + ], + [ + -86.57109719761368, + 35.85527512323118 + ], + [ + -86.57109719761368, + 35.857508681221766 + ], + [ + -86.57109737022245, + 35.85751011930261 + ], + [ + -86.57109788141548, + 35.85751150211878 + ], + [ + -86.57109871154792, + 35.85751277652946 + ], + [ + -86.57109982871825, + 35.857513893559734 + ], + [ + -86.57110118999421, + 35.85751481028278 + ], + [ + -86.57110274306277, + 35.85751549146944 + ], + [ + -86.57110442824035, + 35.857515910942126 + ], + [ + -86.57110618076653, + 35.857516052580706 + ], + [ + -86.57382236337436, + 35.857516052580706 + ] + ], + [ + [ + -86.6312484737114, + 35.742860087824354 + ], + [ + -86.63124830110263, + 35.7428586477421 + ], + [ + -86.6312477899096, + 35.742857263001376 + ], + [ + -86.63124695977717, + 35.742855986817 + ], + [ + -86.63124584260684, + 35.742854868232016 + ], + [ + -86.63124448133088, + 35.742853950233034 + ], + [ + -86.63124292826232, + 35.742853268098244 + ], + [ + -86.63124124308473, + 35.742852848041714 + ], + [ + -86.63123949055856, + 35.74285270620598 + ], + [ + -86.6285233079507, + 35.74285270620598 + ], + [ + -86.62852155542451, + 35.742852848041714 + ], + [ + -86.62851987024693, + 35.742853268098244 + ], + [ + -86.62851831717838, + 35.742853950233034 + ], + [ + -86.62851695590241, + 35.742854868232016 + ], + [ + -86.62851583873208, + 35.742855986817 + ], + [ + -86.62851500859965, + 35.742857263001376 + ], + [ + -86.62851449740661, + 35.7428586477421 + ], + [ + -86.62851432479786, + 35.742860087824354 + ], + [ + -86.62851432479786, + 35.74509362569718 + ], + [ + -86.62851449740661, + 35.74509506574047 + ], + [ + -86.62851500859965, + 35.74509645044369 + ], + [ + -86.62851583873208, + 35.74509772659346 + ], + [ + -86.62851695590241, + 35.745098845148064 + ], + [ + -86.62851831717838, + 35.7450997631221 + ], + [ + -86.62851987024693, + 35.74510044523834 + ], + [ + -86.62852155542451, + 35.74510086528344 + ], + [ + -86.6285233079507, + 35.74510100711532 + ], + [ + -86.63123949055856, + 35.74510100711532 + ], + [ + -86.63124124308473, + 35.74510086528344 + ], + [ + -86.63124292826232, + 35.74510044523834 + ], + [ + -86.63124448133088, + 35.7450997631221 + ], + [ + -86.63124584260684, + 35.745098845148064 + ], + [ + -86.63124695977717, + 35.74509772659346 + ], + [ + -86.6312477899096, + 35.74509645044369 + ], + [ + -86.63124830110263, + 35.74509506574047 + ], + [ + -86.6312484737114, + 35.74509362569718 + ], + [ + -86.6312484737114, + 35.742860087824354 + ] + ], + [ + [ + -86.6640582606738, + 35.84178531898385 + ], + [ + -86.66405808806503, + 35.84178388062817 + ], + [ + -86.664057576872, + 35.84178249754766 + ], + [ + -86.66405674673958, + 35.84178122289334 + ], + [ + -86.66405562956925, + 35.84178010564947 + ], + [ + -86.66405426829328, + 35.84177918875111 + ], + [ + -86.66405271522473, + 35.84177850743416 + ], + [ + -86.66405103004713, + 35.841778087881245 + ], + [ + -86.66404927752096, + 35.84177794621557 + ], + [ + -86.66133309491308, + 35.84177794621557 + ], + [ + -86.6613313423869, + 35.841778087881245 + ], + [ + -86.66132965720934, + 35.84177850743416 + ], + [ + -86.66132810414076, + 35.84177918875111 + ], + [ + -86.6613267428648, + 35.84178010564947 + ], + [ + -86.66132562569447, + 35.84178122289334 + ], + [ + -86.66132479556204, + 35.84178249754766 + ], + [ + -86.661324284369, + 35.84178388062817 + ], + [ + -86.66132411176024, + 35.84178531898385 + ], + [ + -86.66132411176024, + 35.84401887455732 + ], + [ + -86.661324284369, + 35.84402031287395 + ], + [ + -86.66132479556204, + 35.84402169591686 + ], + [ + -86.66132562569447, + 35.84402297053648 + ], + [ + -86.6613267428648, + 35.84402408774991 + ], + [ + -86.66132810414076, + 35.844025004623255 + ], + [ + -86.66132965720934, + 35.84402568592161 + ], + [ + -86.6613313423869, + 35.84402610546306 + ], + [ + -86.66133309491308, + 35.844026247124866 + ], + [ + -86.66404927752096, + 35.844026247124866 + ], + [ + -86.66405103004713, + 35.84402610546306 + ], + [ + -86.66405271522473, + 35.84402568592161 + ], + [ + -86.66405426829328, + 35.844025004623255 + ], + [ + -86.66405562956925, + 35.84402408774991 + ], + [ + -86.66405674673958, + 35.84402297053648 + ], + [ + -86.664057576872, + 35.84402169591686 + ], + [ + -86.66405808806503, + 35.84402031287395 + ], + [ + -86.6640582606738, + 35.84401887455732 + ], + [ + -86.6640582606738, + 35.84178531898385 + ] + ], + [ + [ + -86.67226070741438, + 35.7675913956163 + ], + [ + -86.67226053480562, + 35.76758995596529 + ], + [ + -86.67226002361258, + 35.76758857163924 + ], + [ + -86.67225919348014, + 35.767587295837004 + ], + [ + -86.67225807630982, + 35.767586177586985 + ], + [ + -86.67225671503387, + 35.76758525986291 + ], + [ + -86.6722551619653, + 35.76758457793238 + ], + [ + -86.67225347678772, + 35.767584158001654 + ], + [ + -86.67225172426154, + 35.767584016208396 + ], + [ + -86.66953554165369, + 35.767584016208396 + ], + [ + -86.66953378912751, + 35.767584158001654 + ], + [ + -86.66953210394993, + 35.76758457793238 + ], + [ + -86.66953055088136, + 35.76758525986291 + ], + [ + -86.66952918960541, + 35.767586177586985 + ], + [ + -86.66952807243509, + 35.767587295837004 + ], + [ + -86.66952724230264, + 35.76758857163924 + ], + [ + -86.66952673110961, + 35.76758995596529 + ], + [ + -86.66952655850085, + 35.7675913956163 + ], + [ + -86.66952655850085, + 35.76982493791015 + ], + [ + -86.66952673110961, + 35.76982637752218 + ], + [ + -86.66952724230264, + 35.7698277618107 + ], + [ + -86.66952807243509, + 35.7698290375783 + ], + [ + -86.66952918960541, + 35.76983015579792 + ], + [ + -86.66953055088136, + 35.76983107349703 + ], + [ + -86.66953210394993, + 35.769831755409 + ], + [ + -86.66953378912751, + 35.769832175328304 + ], + [ + -86.66953554165369, + 35.76983231711771 + ], + [ + -86.67225172426154, + 35.76983231711771 + ], + [ + -86.67225347678772, + 35.769832175328304 + ], + [ + -86.6722551619653, + 35.769831755409 + ], + [ + -86.67225671503387, + 35.76983107349703 + ], + [ + -86.67225807630982, + 35.76983015579792 + ], + [ + -86.67225919348014, + 35.7698290375783 + ], + [ + -86.67226002361258, + 35.7698277618107 + ], + [ + -86.67226053480562, + 35.76982637752218 + ], + [ + -86.67226070741438, + 35.76982493791015 + ], + [ + -86.67226070741438, + 35.7675913956163 + ] + ] + ] + }, + "links": [ + { + "rel": "source", + "href": "../../f433578c-f879-414d-8101-83142a0a13c3/d43bead8-e3f8-4c51-95d6-e24e750a402b/d43bead8-e3f8-4c51-95d6-e24e750a402b.json", + "type": "image/vnd.stac.geotiff; cloud-optimized=true", + "title": "Source image STAC item for the label item" + }, + { + "rel": "root", + "href": "../../../catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "../collection.json", + "type": "application/json" + } + ], + "assets": { + "cf73ec1a-d790-4b59-b077-e101738571ed": { + "href": "./data.geojson", + "type": "application/geo+json", + "title": "Label Data Feature Collection" + } + }, + "bbox": [ + -86.75155102590682, + 35.68664518347332, + -86.43438975193705, + 35.90922697349481 + ], + "stac_extensions": [ + "https://stac-extensions.github.io/label/v1.0.1/schema.json" + ] +} \ No newline at end of file diff --git a/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/5b922d42-9a77-4f79-a672-86096f7f849e/cf73ec1a-d790-4b59-b077-e101738571ed/data.geojson b/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/5b922d42-9a77-4f79-a672-86096f7f849e/cf73ec1a-d790-4b59-b077-e101738571ed/data.geojson new file mode 100644 index 000000000..ffca688d2 --- /dev/null +++ b/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/5b922d42-9a77-4f79-a672-86096f7f849e/cf73ec1a-d790-4b59-b077-e101738571ed/data.geojson @@ -0,0 +1,53 @@ +{ + "features": [ + { + "id": "010ce53f-44a9-4dad-8efc-5e5d13b96586", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -86.68525900000002, + 35.86298499999999 + ], + [ + -86.685211, + 35.862947999999996 + ], + [ + -86.68519599999999, + 35.86296099999999 + ], + [ + -86.68523600000002, + 35.862989 + ], + [ + -86.68525900000002, + 35.86298499999999 + ] + ] + ] + }, + "properties": { + "projectId": "7e584c31-f5d1-4a02-9428-e83006642375", + "createdAt": "2019-08-06T21:24:54.401Z", + "createdBy": "auth0|59318a9d2fbbca3e16bcfc92", + "modifiedAt": "2019-08-06T21:24:54.401Z", + "owner": "auth0|59318a9d2fbbca3e16bcfc92", + "label": "Passenger Vehicle", + "description": null, + "machineGenerated": null, + "confidence": null, + "quality": null, + "annotationGroup": "xxxxxxxxxxxxx-4315-abfb-3a959fe1d443", + "labeledBy": null, + "verifiedBy": null, + "projectLayerId": "xxxxxxxxxxxxx-4a62-b33e-3a87c2ebdf16", + "taskId": "xxxxxxxxxxxxx-42f1-a481-3d4cf291f242" + }, + "type": "Feature" + } + ], + "type": "FeatureCollection" +} \ No newline at end of file diff --git a/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/5b922d42-9a77-4f79-a672-86096f7f849e/collection.json b/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/5b922d42-9a77-4f79-a672-86096f7f849e/collection.json new file mode 100644 index 000000000..9c955c8ca --- /dev/null +++ b/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/5b922d42-9a77-4f79-a672-86096f7f849e/collection.json @@ -0,0 +1,51 @@ +{ + "type": "Collection", + "id": "5b922d42-9a77-4f79-a672-86096f7f849e", + "stac_version": "1.1.0", + "description": "Label collection in layer 1a8c1632-fa91-4a62-b33e-3a87c2ebdf16", + "links": [ + { + "rel": "item", + "href": "./cf73ec1a-d790-4b59-b077-e101738571ed/cf73ec1a-d790-4b59-b077-e101738571ed.json", + "type": "application/json", + "title": "STAC label item link" + }, + { + "rel": "root", + "href": "../../catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "../collection.json", + "type": "application/json" + } + ], + "stac_extensions": [], + "title": "Label collection", + "keywords": [], + "version": "1", + "providers": [], + "properties": {}, + "extent": { + "spatial": { + "bbox": [ + [ + -86.75155102590682, + 35.68664518347332, + -86.43438975193705, + 35.90922697349481 + ] + ] + }, + "temporal": { + "interval": [ + [ + "2019-08-07T20:37:10Z", + "2019-08-07T20:37:10Z" + ] + ] + } + }, + "license": "other" +} \ No newline at end of file diff --git a/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/collection.json b/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/collection.json new file mode 100644 index 000000000..3ed2b44d3 --- /dev/null +++ b/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/collection.json @@ -0,0 +1,57 @@ +{ + "type": "Collection", + "id": "1a8c1632-fa91-4a62-b33e-3a87c2ebdf16", + "stac_version": "1.1.0", + "description": "Project layer collection", + "links": [ + { + "rel": "child", + "href": "./5b922d42-9a77-4f79-a672-86096f7f849e/collection.json", + "type": "application/json", + "title": "Label Collection" + }, + { + "rel": "child", + "href": "./f433578c-f879-414d-8101-83142a0a13c3/collection.json", + "type": "application/json", + "title": "Scene Collection" + }, + { + "rel": "root", + "href": "../catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "../catalog.json", + "type": "application/json" + } + ], + "stac_extensions": [], + "title": "Layers", + "keywords": [], + "version": "1", + "providers": [], + "properties": {}, + "extent": { + "spatial": { + "bbox": [ + [ + -86.75260925292969, + 35.685734110176064, + -86.43606567382812, + 35.90789475644849 + ] + ] + }, + "temporal": { + "interval": [ + [ + "2019-08-07T20:37:10Z", + "2019-08-07T20:37:10Z" + ] + ] + } + }, + "license": "other" +} \ No newline at end of file diff --git a/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/f433578c-f879-414d-8101-83142a0a13c3/collection.json b/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/f433578c-f879-414d-8101-83142a0a13c3/collection.json new file mode 100644 index 000000000..958ca390d --- /dev/null +++ b/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/f433578c-f879-414d-8101-83142a0a13c3/collection.json @@ -0,0 +1,51 @@ +{ + "type": "Collection", + "id": "f433578c-f879-414d-8101-83142a0a13c3", + "stac_version": "1.1.0", + "description": "Scene collection in layer 1a8c1632-fa91-4a62-b33e-3a87c2ebdf16", + "links": [ + { + "rel": "item", + "href": "./d43bead8-e3f8-4c51-95d6-e24e750a402b/d43bead8-e3f8-4c51-95d6-e24e750a402b.json", + "type": "application/json", + "title": "d43bead8-e3f8-4c51-95d6-e24e750a402b/item.json" + }, + { + "rel": "root", + "href": "../../catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "../collection.json", + "type": "application/json" + } + ], + "stac_extensions": [], + "title": "Scene collection", + "keywords": [], + "version": "1", + "providers": [], + "properties": {}, + "extent": { + "spatial": { + "bbox": [ + [ + -86.75260925292969, + 35.685734110176064, + -86.43606567382812, + 35.90789475644849 + ] + ] + }, + "temporal": { + "interval": [ + [ + "2019-08-07T20:37:10Z", + "2019-08-07T20:37:10Z" + ] + ] + } + }, + "license": "other" +} \ No newline at end of file diff --git a/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/f433578c-f879-414d-8101-83142a0a13c3/d43bead8-e3f8-4c51-95d6-e24e750a402b/d43bead8-e3f8-4c51-95d6-e24e750a402b.json b/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/f433578c-f879-414d-8101-83142a0a13c3/d43bead8-e3f8-4c51-95d6-e24e750a402b/d43bead8-e3f8-4c51-95d6-e24e750a402b.json new file mode 100644 index 000000000..89aef54f4 --- /dev/null +++ b/tests/v1/data-files/catalogs/test-case-2/1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/f433578c-f879-414d-8101-83142a0a13c3/d43bead8-e3f8-4c51-95d6-e24e750a402b/d43bead8-e3f8-4c51-95d6-e24e750a402b.json @@ -0,0 +1,63 @@ +{ + "type": "Feature", + "stac_version": "1.1.0", + "id": "d43bead8-e3f8-4c51-95d6-e24e750a402b", + "properties": { + "datetime": "2019-08-07T20:37:10Z" + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + -86.75260925292969, + 35.685734110176064 + ], + [ + -86.75260925292969, + 35.9078947564485 + ], + [ + -86.43606567382814, + 35.9078947564485 + ], + [ + -86.43606567382814, + 35.685734110176064 + ], + [ + -86.75260925292969, + 35.685734110176064 + ] + ] + ] + ] + }, + "links": [ + { + "rel": "root", + "href": "../../../catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "../collection.json", + "type": "application/json" + } + ], + "assets": { + "d43bead8-e3f8-4c51-95d6-e24e750a402b": { + "href": "s3://somebucket/some/sort/of/COG.tif", + "type": "image/vnd.stac.geotiff; cloud-optimized=true", + "title": "scene" + } + }, + "bbox": [ + -86.75260925292969, + 35.685734110176064, + -86.43606567382814, + 35.9078947564485 + ], + "stac_extensions": [] +} \ No newline at end of file diff --git a/tests/v1/data-files/catalogs/test-case-2/catalog.json b/tests/v1/data-files/catalogs/test-case-2/catalog.json new file mode 100644 index 000000000..35864d36f --- /dev/null +++ b/tests/v1/data-files/catalogs/test-case-2/catalog.json @@ -0,0 +1,21 @@ +{ + "type": "Catalog", + "id": "01749366-9e25-4b42-a4d1-5aae9f8c9eec", + "stac_version": "1.1.0", + "description": "Exported from Raster Foundry 2019-10-04 15:55:13.759", + "links": [ + { + "rel": "root", + "href": "./catalog.json", + "type": "application/json" + }, + { + "rel": "child", + "href": "./1a8c1632-fa91-4a62-b33e-3a87c2ebdf16/collection.json", + "type": "application/json", + "title": "Layer Collection" + } + ], + "stac_extensions": [], + "title": "Test Case 2" +} \ No newline at end of file diff --git a/tests/v1/test_item.py b/tests/v1/test_item.py index 8d1de41d8..fb2b8548a 100644 --- a/tests/v1/test_item.py +++ b/tests/v1/test_item.py @@ -1,17 +1,19 @@ +import os.path from copy import deepcopy from typing import Any import pytest -from pystac import Catalog, Item +from pystac import Catalog, Item, utils -from . import utils +from . import utils as test_utils +from .utils import TestCases def test_to_from_dict(sample_item_dict: dict[str, Any]) -> None: param_dict = deepcopy(sample_item_dict) - utils.assert_to_from_dict(Item, param_dict) + test_utils.assert_to_from_dict(Item, param_dict) item = Item.from_dict(param_dict) assert item.id == "CS3-20160503_132131_05" @@ -26,7 +28,8 @@ def test_to_from_dict(sample_item_dict: dict[str, Any]) -> None: assert param_dict == sample_item_dict # assert that the parameter is preserved regardless of preserve_dict - Item.from_dict(param_dict, preserve_dict=False) + with pytest.warns(FutureWarning): + Item.from_dict(param_dict, preserve_dict=False) assert param_dict == sample_item_dict @@ -35,3 +38,15 @@ def test_from_dict_set_root(sample_item_dict: dict[str, Any]) -> None: with pytest.warns(FutureWarning): item = Item.from_dict(sample_item_dict, root=catalog) assert item.get_root() is catalog + + +def test_set_self_href_does_not_break_asset_hrefs() -> None: + cat = TestCases.case_2() + for item in cat.get_items(recursive=True): + for asset in item.assets.values(): + if utils.is_absolute_href(asset.href): + asset.href = f"./{os.path.basename(asset.href)}" + with pytest.warns(FutureWarning): + item.set_self_href("http://example.com/item.json") + for asset in item.assets.values(): + assert utils.is_absolute_href(asset.href) diff --git a/tests/v1/utils.py b/tests/v1/utils.py index f06b3564b..3547ea701 100644 --- a/tests/v1/utils.py +++ b/tests/v1/utils.py @@ -1,6 +1,4 @@ -import csv import datetime -import os from copy import deepcopy from pathlib import Path from typing import Any @@ -8,7 +6,6 @@ import pytest from dateutil.parser import parse -import pystac from pystac import ( Asset, Catalog, @@ -67,7 +64,7 @@ def _parse_times(a_dict: dict[str, Any]) -> None: a_dict[k] = a_dict[k].replace(microsecond=0) d1 = deepcopy(d) - d2 = stac_object_class.from_dict(d, migrate=False).to_dict() + d2 = stac_object_class.from_dict(d).to_dict() _parse_times(d1) _parse_times(d2) assert d1 == d2 @@ -101,22 +98,6 @@ def _parse_times(a_dict: dict[str, Any]) -> None: } -class ExampleInfo: - def __init__( - self, - path: str, - object_type: pystac.STACObjectType, - stac_version: str, - extensions: list[str], - valid: bool, - ) -> None: - self.path = path - self.object_type = object_type - self.stac_version = stac_version - self.extensions = extensions - self.valid = valid - - class TestCases: bad_catalog_case = "data-files/catalogs/invalid-catalog/catalog.json" @@ -124,37 +105,6 @@ class TestCases: def get_path(rel_path: str) -> str: return str(Path(__file__).parent.joinpath(rel_path)) - @staticmethod - def get_examples_info() -> list[ExampleInfo]: - examples: list[ExampleInfo] = [] - - info_path = TestCases.get_path("data-files/examples/example-info.csv") - with open(TestCases.get_path("data-files/examples/example-info.csv")) as f: - for row in csv.reader(f): - path = os.path.abspath(os.path.join(os.path.dirname(info_path), row[0])) - object_type = row[1] - stac_version = row[2] - extensions: list[str] = [] - if row[3]: - extensions = row[3].split("|") - - valid = True - if len(row) > 4: - # The 5th column will be "INVALID" if the example - # shouldn't pass validation - valid = row[4] != "INVALID" - - examples.append( - ExampleInfo( - path=path, - object_type=pystac.STACObjectType(object_type), - stac_version=stac_version, - extensions=extensions, - valid=valid, - ) - ) - return examples - @staticmethod def all_test_catalogs() -> list[Catalog]: return [