diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 7634c12..1a00a06 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 @@ -27,11 +27,14 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install tox pre-commit + python -m pip install '.[lint]' pre-commit install - # Run tox using the version of Python in `PATH` - - name: Run Tox + - name: Lint + run: pre-commit run --all + + # Run tox using the version of Python in `PATH` + - name: Test run: tox -e py - name: Upload Results diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 902e96e..fda80da 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,11 @@ +- Require `type` property to be set for Catalog and Collections +- Fix validator for Item `datetime` and Common MetaData `start_datetime` and `end_datetime` +- Include `datetime` and `license` to Common MetaData +- Make sure default values for required but unset fields are correctly parsed +- Add support from Python 3.12 +- Lint all files +- Increase test coverage + 3.0.0 (2024-01-25) ------------------ - Support pydantic>2.0 (@huard) diff --git a/pyproject.toml b/pyproject.toml index 174415e..87abbf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,13 +13,14 @@ classifiers=[ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "License :: OSI Approved :: MIT License", ] keywords=["stac", "pydantic", "validation"] authors=[{ name = "Arturo Engineering", email = "engineering@arturo.ai"}] license= { text = "MIT" } requires-python=">=3.8" -dependencies = ["click>=8.1.7", "pydantic>=2.4.1", "geojson-pydantic>=1.0.0", "ciso8601~=2.3"] +dependencies = ["click>=8.1.7", "pydantic>=2.4.1", "geojson-pydantic>=1.0.0", "ciso8601~=2.3","python-dateutil>=2.7.0"] dynamic = ["version", "readme"] [project.scripts] @@ -42,11 +43,12 @@ dev = ["arrow>=1.2.3", lint = ["types-requests>=2.31.0.5", "types-jsonschema>=4.19.0.3", "types-PyYAML>=6.0.12.12", + "types-python-dateutil>=2.7.0", "black>=23.9.1", "isort>=5.12.0", "flake8>=6.1.0", "Flake8-pyproject>=1.2.3", - "mypy>=1.5.1", + "mypy==1.4.1", "pre-commit>=3.4.0", "tox>=4.11.3"] diff --git a/stac_pydantic/api/search.py b/stac_pydantic/api/search.py index a005369..85d8553 100644 --- a/stac_pydantic/api/search.py +++ b/stac_pydantic/api/search.py @@ -1,8 +1,7 @@ from datetime import datetime as dt from typing import Any, Dict, List, Optional, Tuple, Union, cast -from ciso8601 import parse_rfc3339 -from geojson_pydantic.geometries import ( # type: ignore +from geojson_pydantic.geometries import ( GeometryCollection, LineString, MultiLineString, @@ -11,12 +10,12 @@ Point, Polygon, ) -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, TypeAdapter, field_validator, model_validator from stac_pydantic.api.extensions.fields import FieldsExtension from stac_pydantic.api.extensions.query import Operator from stac_pydantic.api.extensions.sort import SortExtension -from stac_pydantic.shared import BBox +from stac_pydantic.shared import BBox, UtcDatetime Intersection = Union[ Point, @@ -28,6 +27,8 @@ GeometryCollection, ] +SearchDatetime = TypeAdapter(Optional[UtcDatetime]) + class Search(BaseModel): """ @@ -43,23 +44,18 @@ class Search(BaseModel): datetime: Optional[str] = None limit: int = 10 + # Private properties to store the parsed datetime values. Not part of the model schema. + _start_date: Optional[dt] = None + _end_date: Optional[dt] = None + + # Properties to return the private values @property def start_date(self) -> Optional[dt]: - values = (self.datetime or "").split("/") - if len(values) == 1: - return None - if values[0] == ".." or values[0] == "": - return None - return parse_rfc3339(values[0]) + return self._start_date @property def end_date(self) -> Optional[dt]: - values = (self.datetime or "").split("/") - if len(values) == 1: - return parse_rfc3339(values[0]) - if values[1] == ".." or values[1] == "": - return None - return parse_rfc3339(values[1]) + return self._end_date # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-validators for more information. @model_validator(mode="before") @@ -102,30 +98,38 @@ def validate_bbox(cls, v: BBox) -> BBox: @field_validator("datetime") @classmethod - def validate_datetime(cls, v: str) -> str: - if "/" in v: - values = v.split("/") - else: - # Single date is interpreted as end date - values = ["..", v] - - dates: List[dt] = [] - for value in values: - if value == ".." or value == "": - continue - - dates.append(parse_rfc3339(value)) - + def validate_datetime(cls, value: str) -> str: + # Split on "/" and replace no value or ".." with None + values = [v if v and v != ".." else None for v in value.split("/")] + # If there are more than 2 dates, it's invalid if len(values) > 2: - raise ValueError("Invalid datetime range, must match format (begin_date, end_date)") - - if not {"..", ""}.intersection(set(values)): - if dates[0] > dates[1]: - raise ValueError( - "Invalid datetime range, must match format (begin_date, end_date)" - ) - - return v + raise ValueError( + "Invalid datetime range. Too many values. Must match format: {begin_date}/{end_date}" + ) + # If there is only one date, insert a None for the start date + if len(values) == 1: + values.insert(0, None) + # Cast because pylance gets confused by the type adapter and annotated type + dates = cast( + List[Optional[dt]], + [ + # Use the type adapter to validate the datetime strings, strict is necessary + # due to pydantic issues #8736 and #8762 + SearchDatetime.validate_strings(v, strict=True) if v else None + for v in values + ], + ) + # If there is a start and end date, check that the start date is before the end date + if dates[0] and dates[1] and dates[0] > dates[1]: + raise ValueError( + "Invalid datetime range. Begin date after end date. " + "Must match format: {begin_date}/{end_date}" + ) + # Store the parsed dates + cls._start_date = dates[0] + cls._end_date = dates[1] + # Return the original string value + return value @property def spatial_filter(self) -> Optional[Intersection]: diff --git a/stac_pydantic/catalog.py b/stac_pydantic/catalog.py index d97bbd7..4760734 100644 --- a/stac_pydantic/catalog.py +++ b/stac_pydantic/catalog.py @@ -1,6 +1,6 @@ -from typing import List, Literal, Optional +from typing import Any, List, Literal, Optional -from pydantic import AnyUrl, ConfigDict, Field +from pydantic import AnyUrl, ConfigDict, Field, model_validator from stac_pydantic.links import Links from stac_pydantic.shared import SEMVER_REGEX, StacBaseModel @@ -14,13 +14,25 @@ class _Catalog(StacBaseModel): id: str = Field(..., alias="id", min_length=1) description: str = Field(..., alias="description", min_length=1) - stac_version: str = Field(STAC_VERSION, pattern=SEMVER_REGEX) + stac_version: str = Field(..., pattern=SEMVER_REGEX) links: Links - stac_extensions: Optional[List[AnyUrl]] = [] + stac_extensions: Optional[List[AnyUrl]] = None title: Optional[str] = None type: str model_config = ConfigDict(use_enum_values=True, extra="allow") + @model_validator(mode="before") + @classmethod + def set_default_links(cls, data: Any) -> Any: + """Make sure default values are properly set, + so that they are always present in the output JSON.""" + if isinstance(data, dict): + if data.get("links") is None: + data["links"] = [] + if data.get("stac_version") is None: + data["stac_version"] = STAC_VERSION + return data + class Catalog(_Catalog): - type: Literal["Catalog"] = "Catalog" + type: Literal["Catalog"] diff --git a/stac_pydantic/collection.py b/stac_pydantic/collection.py index 1704726..4b4cae8 100644 --- a/stac_pydantic/collection.py +++ b/stac_pydantic/collection.py @@ -52,4 +52,4 @@ class Collection(_Catalog): keywords: Optional[List[str]] = None providers: Optional[List[Provider]] = None summaries: Optional[Dict[str, Union[Range, List[Any], Dict[str, Any]]]] = None - type: Literal["Collection"] = "Collection" + type: Literal["Collection"] diff --git a/stac_pydantic/item.py b/stac_pydantic/item.py index 452b51a..8e7a8ca 100644 --- a/stac_pydantic/item.py +++ b/stac_pydantic/item.py @@ -1,25 +1,11 @@ -from datetime import datetime as dt -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional -from ciso8601 import parse_rfc3339 from geojson_pydantic import Feature -from pydantic import ( - AnyUrl, - ConfigDict, - Field, - field_serializer, - model_serializer, - model_validator, -) +from pydantic import AnyUrl, ConfigDict, Field, model_serializer, model_validator +from typing_extensions import Annotated from stac_pydantic.links import Links -from stac_pydantic.shared import ( - DATETIME_RFC339, - SEMVER_REGEX, - Asset, - StacBaseModel, - StacCommonMetadata, -) +from stac_pydantic.shared import SEMVER_REGEX, Asset, StacBaseModel, StacCommonMetadata from stac_pydantic.version import STAC_VERSION @@ -28,60 +14,58 @@ class ItemProperties(StacCommonMetadata): https://github.com/radiantearth/stac-spec/blob/v1.0.0/item-spec/item-spec.md#properties-object """ - datetime: Union[dt, str] = Field(..., alias="datetime") - # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-config for more information. model_config = ConfigDict(extra="allow") @model_validator(mode="before") @classmethod - def validate_datetime(cls, data: Dict[str, Any]) -> Dict[str, Any]: - datetime = data.get("datetime") - start_datetime = data.get("start_datetime") - end_datetime = data.get("end_datetime") - - if not datetime or datetime == "null": - if not start_datetime and not end_datetime: - raise ValueError( - "start_datetime and end_datetime must be specified when datetime is null" - ) - - if isinstance(datetime, str): - data["datetime"] = parse_rfc3339(datetime) - - if isinstance(start_datetime, str): - data["start_datetime"] = parse_rfc3339(start_datetime) - - if isinstance(end_datetime, str): - data["end_datetime"] = parse_rfc3339(end_datetime) + def validate_datetime(cls, data: Any) -> Any: + if isinstance(data, dict): + + datetime = data.get("datetime") + start_datetime = data.get("start_datetime") + end_datetime = data.get("end_datetime") + + if datetime is None or datetime == "null": + if not start_datetime and not end_datetime: + raise ValueError( + "start_datetime and end_datetime must be specified when datetime is null" + ) + # Make sure datetime is properly set to None + # so that it is not present in the output JSON. + data["datetime"] = None return data - @field_serializer("datetime") - def serialize_datetime(self, v: dt, _info: Any) -> str: - return v.strftime(DATETIME_RFC339) - class Item(Feature, StacBaseModel): """ https://github.com/radiantearth/stac-spec/blob/v1.0.0/item-spec/item-spec.md """ - id: str = Field(..., alias="id", min_length=1) - stac_version: str = Field(STAC_VERSION, pattern=SEMVER_REGEX) + id: Annotated[str, Field(min_length=1)] + stac_version: Annotated[str, Field(pattern=SEMVER_REGEX)] properties: ItemProperties assets: Dict[str, Asset] links: Links - stac_extensions: Optional[List[AnyUrl]] = [] + stac_extensions: Optional[List[AnyUrl]] = None collection: Optional[str] = None @model_validator(mode="before") @classmethod - def validate_bbox(cls, values: Dict[str, Any]) -> Dict[str, Any]: - if isinstance(values, dict): - if values.get("geometry") and values.get("bbox") is None: + def validate_defaults(cls, data: Any) -> Any: + """Make sure default values are properly set, + so that they are always present in the output JSON.""" + if isinstance(data, dict): + if data.get("geometry") and data.get("bbox") is None: raise ValueError("bbox is required if geometry is not null") - return values + if data.get("stac_version") is None: + data["stac_version"] = STAC_VERSION + if data.get("assets") is None: + data["assets"] = {} + if data.get("links") is None: + data["links"] = [] + return data # https://github.com/developmentseed/geojson-pydantic/issues/147 @model_serializer(mode="wrap") diff --git a/stac_pydantic/shared.py b/stac_pydantic/shared.py index bde66b2..82f43f7 100644 --- a/stac_pydantic/shared.py +++ b/stac_pydantic/shared.py @@ -1,9 +1,19 @@ -from datetime import datetime +from datetime import datetime as dt +from datetime import timezone from enum import Enum, auto from typing import Any, Dict, List, Optional, Tuple, Union from warnings import warn -from pydantic import BaseModel, ConfigDict, Field +import dateutil.parser +from pydantic import ( + AfterValidator, + BaseModel, + ConfigDict, + Field, + PlainSerializer, + model_validator, +) +from typing_extensions import Annotated from stac_pydantic.utils import AutoValueEnum @@ -15,9 +25,29 @@ SEMVER_REGEX = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" -# https://tools.ietf.org/html/rfc3339#section-5.6 -# Unused, but leaving it here since it's used by dependencies -DATETIME_RFC339 = "%Y-%m-%dT%H:%M:%SZ" + +def datetime_to_str(d: dt) -> str: + if d.tzinfo is None: + d = d.replace(tzinfo=timezone.utc) + + timestamp = d.isoformat(timespec="auto") + zulu = "+00:00" + if timestamp.endswith(zulu): + timestamp = f"{timestamp[: -len(zulu)]}Z" + + return timestamp + + +# Allows for some additional flexibility in the input datetime format. +# If the input value has timezone information, it will be converted to UTC timezone. +# Otherwise URT timezone will be assumed. +UtcDatetime = Annotated[ + Union[str, dt], + # Input value must be in a format which has timezone information + AfterValidator(lambda d: d if isinstance(d, dt) else dateutil.parser.isoparse(d)), + # Use `isoformat` to serialize the value in an RFC3339 compatible format + PlainSerializer(lambda d: datetime_to_str(d)), +] class MimeTypes(str, Enum): @@ -116,18 +146,35 @@ class StacCommonMetadata(StacBaseModel): https://github.com/radiantearth/stac-spec/blob/v1.0.0/item-spec/common-metadata.md#date-and-time-range """ - title: Optional[str] = Field(None, alias="title") - description: Optional[str] = Field(None, alias="description") - start_datetime: Optional[datetime] = Field(None, alias="start_datetime") - end_datetime: Optional[datetime] = Field(None, alias="end_datetime") - created: Optional[datetime] = Field(None, alias="created") - updated: Optional[datetime] = Field(None, alias="updated") - platform: Optional[str] = Field(None, alias="platform") - instruments: Optional[List[str]] = Field(None, alias="instruments") - constellation: Optional[str] = Field(None, alias="constellation") - mission: Optional[str] = Field(None, alias="mission") - providers: Optional[List[Provider]] = Field(None, alias="providers") - gsd: Optional[float] = Field(None, alias="gsd", gt=0) + title: Optional[str] = None + description: Optional[str] = None + datetime: Optional[UtcDatetime] = None + start_datetime: Optional[UtcDatetime] = None + end_datetime: Optional[UtcDatetime] = None + created: Optional[UtcDatetime] = None + updated: Optional[UtcDatetime] = None + platform: Optional[str] = None + instruments: Optional[List[str]] = None + constellation: Optional[str] = None + mission: Optional[str] = None + providers: Optional[List[Provider]] = None + gsd: Optional[Annotated[float, Field(gt=0)]] = None + + @model_validator(mode="before") + @classmethod + def validate_start_end_datetime(cls, data: Any) -> Any: + if isinstance(data, dict): + + start_datetime = data.get("start_datetime") + end_datetime = data.get("end_datetime") + + if not all([start_datetime, end_datetime]) and any( + [start_datetime, end_datetime] + ): + raise ValueError( + "start_datetime and end_datetime must be specified together" + ) + return data class Asset(StacCommonMetadata): diff --git a/tests/api/examples/v1.0.0/example-collection-list.json b/tests/api/examples/v1.0.0/example-collection-list.json index 9594583..797c1a1 100644 --- a/tests/api/examples/v1.0.0/example-collection-list.json +++ b/tests/api/examples/v1.0.0/example-collection-list.json @@ -13,6 +13,7 @@ ], "collections":[ { + "type":"Collection", "id":"aster-l1t", "description":"The [ASTER](https://terra.nasa.gov/about/terra-instruments/aster) instrument, launched on-board NASA's [Terra](https://terra.nasa.gov/) satellite in 1999, provides multispectral images of the Earth at 15m-90m resolution. ASTER images provide information about land surface temperature, color, elevation, and mineral composition.\n\nThis dataset represents ASTER [L1T](https://lpdaac.usgs.gov/products/ast_l1tv003/) data from 2000-2006. L1T images have been terrain-corrected and rotated to a north-up UTM projection. Images are in [cloud-optimized GeoTIFF](https://www.cogeo.org/) format.\n", "stac_version":"1.0.0", @@ -405,6 +406,7 @@ } }, { + "type":"Collection", "id":"landsat-8-c2-l2", "description":"The [Landsat](https://landsat.gsfc.nasa.gov/) program has been imaging the Earth since 1972; it provides a comprehensive, continuous archive of the Earth's surface. [Landsat 8](https://www.usgs.gov/core-science-systems/nli/landsat/landsat-8) is the most recent satellite in the Landsat series. Launched in 2013, Landsat 8 captures data in eleven spectral bands: ten optical/IR bands from the [Operational Land Imager](https://landsat.gsfc.nasa.gov/landsat-8/operational-land-imager) (OLI) instrument, and two thermal bands from the [Thermal Infrared Sensor](https://landsat.gsfc.nasa.gov/landsat-8/thermal-infrared-sensor-tirs) (TIRS) instrument.\n\nThis dataset represents the global archive of Level-2 Landsat 8 data from [Landsat Collection 2](https://www.usgs.gov/core-science-systems/nli/landsat/landsat-collection-2). Because there is some latency before Level-2 data is available, a rolling window of recent Level-1 data is available as well. Images are stored in [cloud-optimized GeoTIFF](https://www.cogeo.org/) format.\n", "stac_version":"1.0.0", @@ -871,6 +873,7 @@ } }, { + "type":"Collection", "id":"sentinel-2-l2a", "description":"The [Sentinel-2](https://sentinel.esa.int/web/sentinel/missions/sentinel-2) program provides global imagery in thirteen spectral bands at 10m-60m resolution and a revisit time of approximately five days. This dataset represents the global Sentinel-2 archive, from 2016 to the present, processed to L2A (bottom-of-atmosphere) using [Sen2Cor](https://step.esa.int/main/snap-supported-plugins/sen2cor/) and converted to [cloud-optimized GeoTIFF](https://www.cogeo.org/) format.", "stac_version":"1.0.0", @@ -1496,6 +1499,7 @@ } }, { + "type":"Collection", "id":"naip", "description":"The [National Agriculture Imagery Program](https://www.fsa.usda.gov/programs-and-services/aerial-photography/imagery-programs/naip-imagery/) (NAIP) provides US-wide, high-resolution aerial imagery, with four spectral bands (R, G, B, IR). NAIP is administered by the [Aerial Field Photography Office](https://www.fsa.usda.gov/programs-and-services/aerial-photography/) (AFPO) within the [US Department of Agriculture](https://www.usda.gov/) (USDA). Data are captured at least once every three years for each state. This dataset represents NAIP data from 2010-present, in [cloud-optimized GeoTIFF](https://www.cogeo.org/) format.\n", "stac_version":"1.0.0", diff --git a/tests/api/extensions/test_fields.py b/tests/api/extensions/test_fields.py index cbc2e74..664057c 100644 --- a/tests/api/extensions/test_fields.py +++ b/tests/api/extensions/test_fields.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from shapely.geometry import Polygon @@ -15,7 +15,7 @@ def test_fields_filter_item(): item = Item( id="test-fields-filter", geometry=Polygon.from_bounds(0, 0, 0, 0), - properties={"datetime": datetime.utcnow(), "foo": "foo", "bar": "bar"}, + properties={"datetime": datetime.now(timezone.utc), "foo": "foo", "bar": "bar"}, assets={}, links=[ {"href": "http://link", "rel": "self"}, diff --git a/tests/api/test_landing_page.py b/tests/api/test_landing_page.py index c9d59d3..08fb765 100644 --- a/tests/api/test_landing_page.py +++ b/tests/api/test_landing_page.py @@ -71,6 +71,7 @@ def test_schema(example_url, schema_url): def test_api_landing_page(): LandingPage( + type="Catalog", id="test-landing-page", description="stac-api landing page", stac_extensions=[ @@ -100,6 +101,7 @@ def test_api_landing_page(): def test_api_landing_page_is_catalog(): landing_page = LandingPage( + type="Catalog", id="test-landing-page", description="stac-api landing page", stac_extensions=[ diff --git a/tests/api/test_search.py b/tests/api/test_search.py index ab44566..b8a76eb 100644 --- a/tests/api/test_search.py +++ b/tests/api/test_search.py @@ -1,12 +1,11 @@ import time -from datetime import datetime, timezone, timedelta +from datetime import datetime, timedelta, timezone import pytest from pydantic import ValidationError from shapely.geometry import Polygon, shape from stac_pydantic.api.search import Search -from stac_pydantic.shared import DATETIME_RFC339 def test_search(): @@ -57,8 +56,8 @@ def test_invalid_spatial_search(): def test_temporal_search_single_tailed(): # Test single tailed - utcnow = datetime.utcnow().replace(microsecond=0, tzinfo=timezone.utc) - utcnow_str = utcnow.strftime(DATETIME_RFC339) + utcnow = datetime.now(timezone.utc).replace(microsecond=0, tzinfo=timezone.utc) + utcnow_str = utcnow.isoformat() search = Search(collections=["collection1"], datetime=utcnow_str) assert search.start_date is None assert search.end_date == utcnow @@ -66,8 +65,8 @@ def test_temporal_search_single_tailed(): def test_temporal_search_two_tailed(): # Test two tailed - utcnow = datetime.utcnow().replace(microsecond=0, tzinfo=timezone.utc) - utcnow_str = utcnow.strftime(DATETIME_RFC339) + utcnow = datetime.now(timezone.utc).replace(microsecond=0, tzinfo=timezone.utc) + utcnow_str = utcnow.isoformat() search = Search(collections=["collection1"], datetime=f"{utcnow_str}/{utcnow_str}") assert search.start_date == search.end_date == utcnow @@ -87,26 +86,34 @@ def test_temporal_search_open(): assert search.end_date is None -def test_invalid_temporal_search(): - # Not RFC339 - utcnow = datetime.utcnow().strftime("%Y-%m-%d") - with pytest.raises(ValidationError): - Search(collections=["collection1"], datetime=utcnow) +# def test_invalid_temporal_search_date(): +# # Just a date, no time +# utcnow = datetime.now(timezone.utc).strftime("%Y-%m-%d") +# with pytest.raises(ValidationError): +# Search(collections=["collection1"], datetime=utcnow) + - t1 = datetime.utcnow() +def test_invalid_temporal_search_too_many(): + # Too many dates + t1 = datetime.now(timezone.utc) t2 = t1 + timedelta(seconds=100) t3 = t2 + timedelta(seconds=100) with pytest.raises(ValidationError): - Search(collections=["collection1"], datetime=f"{t1.strftime(DATETIME_RFC339)}/{t2.strftime(DATETIME_RFC339)}/{t3.strftime(DATETIME_RFC339)}",) + Search( + collections=["collection1"], + datetime=f"{t1.isoformat()}/{t2.isoformat()}/{t3.isoformat()}", + ) + +def test_invalid_temporal_search_date_wrong_order(): # End date is before start date - start = datetime.utcnow() + start = datetime.now(timezone.utc) time.sleep(2) - end = datetime.utcnow() + end = datetime.now(timezone.utc) with pytest.raises(ValidationError): Search( collections=["collection1"], - datetime=f"{end.strftime(DATETIME_RFC339)}/{start.strftime(DATETIME_RFC339)}", + datetime=f"{end.isoformat()}/{start.isoformat()}", ) diff --git a/tests/example_stac/example-collection-list.json b/tests/example_stac/example-collection-list.json index d12357b..6c85829 100644 --- a/tests/example_stac/example-collection-list.json +++ b/tests/example_stac/example-collection-list.json @@ -8,7 +8,8 @@ ], "collections":[ { - "id":"aster-l1t", + "type":"Collection", + "id":"aster-l1t", "description":"The [ASTER](https://terra.nasa.gov/about/terra-instruments/aster) instrument, launched on-board NASA's [Terra](https://terra.nasa.gov/) satellite in 1999, provides multispectral images of the Earth at 15m-90m resolution. ASTER images provide information about land surface temperature, color, elevation, and mineral composition.\n\nThis dataset represents ASTER [L1T](https://lpdaac.usgs.gov/products/ast_l1tv003/) data from 2000-2006. L1T images have been terrain-corrected and rotated to a north-up UTM projection. Images are in [cloud-optimized GeoTIFF](https://www.cogeo.org/) format.\n", "stac_version":"1.0.0", "links":[ @@ -400,6 +401,7 @@ } }, { + "type":"Collection", "id":"landsat-8-c2-l2", "description":"The [Landsat](https://landsat.gsfc.nasa.gov/) program has been imaging the Earth since 1972; it provides a comprehensive, continuous archive of the Earth's surface. [Landsat 8](https://www.usgs.gov/core-science-systems/nli/landsat/landsat-8) is the most recent satellite in the Landsat series. Launched in 2013, Landsat 8 captures data in eleven spectral bands: ten optical/IR bands from the [Operational Land Imager](https://landsat.gsfc.nasa.gov/landsat-8/operational-land-imager) (OLI) instrument, and two thermal bands from the [Thermal Infrared Sensor](https://landsat.gsfc.nasa.gov/landsat-8/thermal-infrared-sensor-tirs) (TIRS) instrument.\n\nThis dataset represents the global archive of Level-2 Landsat 8 data from [Landsat Collection 2](https://www.usgs.gov/core-science-systems/nli/landsat/landsat-collection-2). Because there is some latency before Level-2 data is available, a rolling window of recent Level-1 data is available as well. Images are stored in [cloud-optimized GeoTIFF](https://www.cogeo.org/) format.\n", "stac_version":"1.0.0", @@ -866,6 +868,7 @@ } }, { + "type":"Collection", "id":"sentinel-2-l2a", "description":"The [Sentinel-2](https://sentinel.esa.int/web/sentinel/missions/sentinel-2) program provides global imagery in thirteen spectral bands at 10m-60m resolution and a revisit time of approximately five days. This dataset represents the global Sentinel-2 archive, from 2016 to the present, processed to L2A (bottom-of-atmosphere) using [Sen2Cor](https://step.esa.int/main/snap-supported-plugins/sen2cor/) and converted to [cloud-optimized GeoTIFF](https://www.cogeo.org/) format.", "stac_version":"1.0.0", @@ -1491,6 +1494,7 @@ } }, { + "type":"Collection", "id":"naip", "description":"The [National Agriculture Imagery Program](https://www.fsa.usda.gov/programs-and-services/aerial-photography/imagery-programs/naip-imagery/) (NAIP) provides US-wide, high-resolution aerial imagery, with four spectral bands (R, G, B, IR). NAIP is administered by the [Aerial Field Photography Office](https://www.fsa.usda.gov/programs-and-services/aerial-photography/) (AFPO) within the [US Department of Agriculture](https://www.usda.gov/) (USDA). Data are captured at least once every three years for each state. This dataset represents NAIP data from 2010-present, in [cloud-optimized GeoTIFF](https://www.cogeo.org/) format.\n", "stac_version":"1.0.0", diff --git a/tests/example_stac/example-collection_version-extension.json b/tests/example_stac/example-collection_version-extension.json index bf5ba87..780f826 100644 --- a/tests/example_stac/example-collection_version-extension.json +++ b/tests/example_stac/example-collection_version-extension.json @@ -1,5 +1,6 @@ { - "stac_version": "1.0.0", + "type": "Collection", + "stac_version": "1.0.0", "stac_extensions": ["https://stac-extensions.github.io/version/v1.0.0/schema.json"], "id": "merraclim", "title": "MERRAclim", diff --git a/tests/example_stac/example-landsat8_item-assets-extension.json b/tests/example_stac/example-landsat8_item-assets-extension.json index 31304fa..e423069 100644 --- a/tests/example_stac/example-landsat8_item-assets-extension.json +++ b/tests/example_stac/example-landsat8_item-assets-extension.json @@ -1,4 +1,5 @@ { + "type": "Collection", "id": "landsat-8-l1", "title": "Landsat 8 L1", "description": "Landat 8 imagery radiometrically calibrated and orthorectified using gound points and Digital Elevation Model (DEM) data to correct relief displacement.", diff --git a/tests/example_stac/landsat-collection.json b/tests/example_stac/landsat-collection.json index 91a8018..cebbeaa 100644 --- a/tests/example_stac/landsat-collection.json +++ b/tests/example_stac/landsat-collection.json @@ -1,5 +1,6 @@ { - "stac_version": "1.0.0", + "type": "Collection", + "stac_version": "1.0.0", "stac_extensions": [], "id": "landsat-8-l1", "title": "Landsat 8 L1", diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 0000000..b3186c0 --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,22 @@ +from stac_pydantic.catalog import Catalog + + +def test_catalog(): + # Create a valid Catalog instance + catalog = Catalog( + type="Catalog", + id="my-catalog", + description="My STAC catalog", + ) + + catalog_json = catalog.model_dump(mode="json") + + # Make default all values are set + assert catalog_json["id"] == "my-catalog" + assert catalog_json["description"] == "My STAC catalog" + assert catalog_json["stac_version"] == "1.0.0" + assert catalog_json["links"] == [] + assert catalog_json["type"] == "Catalog" + + assert "stac_extensions" not in catalog_json + assert "title" not in catalog_json diff --git a/tests/test_item.py b/tests/test_item.py new file mode 100644 index 0000000..be4155a --- /dev/null +++ b/tests/test_item.py @@ -0,0 +1,170 @@ +import pytest +from pydantic import ValidationError + +from stac_pydantic.item import Item + + +def test_item(): + + item_data = { + "type": "Feature", + "id": "sample-item", + "stac_version": "1.0.0", + "geometry": {"type": "Point", "coordinates": [125.6, 10.1]}, + "bbox": [125.6, 10.1, 125.6, 10.1], + "properties": {"datetime": "2022-01-01T00:00:00Z"}, + } + item = Item(**item_data) + + item_json = item.model_dump(mode="json") + + # make sure that assets and links are set and parsed correctly + # datetime should be parsed as string and internally handled as datetime + # Collection and stac_extensions should not be parsed if not set + assert item_json["id"] == "sample-item" + assert item_json["stac_version"] == "1.0.0" + assert item_json["properties"]["datetime"] == "2022-01-01T00:00:00Z" + assert item_json["assets"] == {} + assert item_json["links"] == [] + + assert "collection" not in item_json + assert "stac_extensions" not in item_json + + +def test_item_datetime_set_null(): + + item_data = { + "type": "Feature", + "id": "sample-item", + "stac_version": "1.0.0", + "geometry": {"type": "Point", "coordinates": [125.6, 10.1]}, + "bbox": [125.6, 10.1, 125.6, 10.1], + "properties": { + "start_datetime": "2022-01-01T00:00:00Z", + "end_datetime": "2022-12-01T00:00:00Z", + }, + } + item = Item(**item_data) + + item_json = item.model_dump(mode="json") + + # make sure datetime is parsed as null and start_datetime and end_datetime are parsed as strings + assert item_json["properties"]["datetime"] is None + assert item_json["properties"]["start_datetime"] == "2022-01-01T00:00:00Z" + assert item_json["properties"]["end_datetime"] == "2022-12-01T00:00:00Z" + + +def test_item_different_tz(): + + item_data = { + "type": "Feature", + "id": "sample-item", + "stac_version": "1.0.0", + "geometry": {"type": "Point", "coordinates": [125.6, 10.1]}, + "bbox": [125.6, 10.1, 125.6, 10.1], + "properties": {"datetime": "2022-01-01T00:00:00+05:00"}, + } + item = Item(**item_data) + + item_json = item.model_dump(mode="json") + + # make sure that assets and links are set and parsed correctly + # datetime should be parsed as string and internally handled as datetime + # Collection and stac_extensions should not be parsed if not set + assert item_json["id"] == "sample-item" + assert item_json["stac_version"] == "1.0.0" + assert item_json["properties"]["datetime"] == "2022-01-01T00:00:00+05:00" + assert item_json["assets"] == {} + assert item_json["links"] == [] + + assert "collection" not in item_json + assert "stac_extensions" not in item_json + + +@pytest.mark.parametrize( + "datetime", ["2022-01-01T00:00:00", "2022-01-01", "2022-01", "2022"] +) +def test_item_datetime_no_tz(datetime): + + item_data = { + "type": "Feature", + "id": "sample-item", + "stac_version": "1.0.0", + "geometry": {"type": "Point", "coordinates": [125.6, 10.1]}, + "bbox": [125.6, 10.1, 125.6, 10.1], + "properties": {"datetime": datetime}, + } + item = Item(**item_data) + + item_json = item.model_dump(mode="json") + + # The model should fix the date and timezone for us + assert item_json["properties"]["datetime"] == "2022-01-01T00:00:00Z" + + +def test_item_bbox_missing(): + + item_data = { + "type": "Feature", + "id": "sample-item", + "stac_version": "1.0.0", + "geometry": {"type": "Point", "coordinates": [125.6, 10.1]}, + "properties": {"datetime": "2022-01-01T00:00:00Z"}, + } + + with pytest.raises(ValidationError) as e: + Item(**item_data) + assert e.value.errors() == [ + { + "loc": ("bbox",), + "msg": "bbox is required if geometry is not null", + "type": "value_error", + } + ] + + +@pytest.mark.parametrize("property", ["start_datetime", "end_datetime"]) +def test_item_start_end_datetime_missing(property): + + item_data = { + "type": "Feature", + "id": "sample-item", + "stac_version": "1.0.0", + "geometry": {"type": "Point", "coordinates": [125.6, 10.1]}, + "bbox": [125.6, 10.1, 125.6, 10.1], + "properties": { + property: "2022-12-01T00:00:00Z", + }, + } + + with pytest.raises(ValidationError) as e: + Item(**item_data) + assert e.value.errors() == [ + { + "loc": ("properties",), + "msg": "start_datetime and end_datetime must be specified when datetime is null", + "type": "value_error", + } + ] + + +def test_item_datetime_missing(): + + item_data = { + "type": "Feature", + "id": "sample-item", + "stac_version": "1.0.0", + "geometry": {"type": "Point", "coordinates": [125.6, 10.1]}, + "bbox": [125.6, 10.1, 125.6, 10.1], + "properties": {}, + } + + with pytest.raises(ValidationError) as e: + Item(**item_data) + assert e.value.errors() == [ + { + "loc": ("properties",), + "msg": "start_datetime and end_datetime must be specified when datetime is null", + "type": "value_error", + } + ]