Skip to content

add datetime validation for collection time intervals #177

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
May 22, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@

## Unreleased

- Add datetime validation for collection's time intervals (Must follow [`RFC 3339, section 5.6.`](https://datatracker.ietf.org/doc/html/rfc3339#section-5.6))

## 3.2.0 (2025-03-20)

- Move `validate_bbox` and `validate_datetime` field validation functions outside the Search class (to enable re-utilization)
Expand Down
26 changes: 26 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Contributing

Issues and pull requests are more than welcome.

**dev install**

```bash
git clone https://github.com/stac-utils/stac-pydantic.git
cd stac-pydantic
python -m pip install -e ".[dev]"
```

You can then run the tests with the following command:

```sh
python -m pytest --cov stac_pydantic --cov-report term-missing
```


**pre-commit**

This repo is set to use `pre-commit` to run *ruff*, *pydocstring* and mypy when committing new code.

```bash
pre-commit install
```
83 changes: 8 additions & 75 deletions stac_pydantic/api/search.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from datetime import datetime as dt
from typing import Any, Dict, List, Optional, Tuple, Union, cast
from typing import Any, Dict, List, Optional, Union

from geojson_pydantic.geometries import (
GeometryCollection,
Expand All @@ -10,13 +10,18 @@
Point,
Polygon,
)
from pydantic import AfterValidator, BaseModel, Field, TypeAdapter, model_validator
from pydantic import AfterValidator, BaseModel, Field, model_validator
from typing_extensions import Annotated

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, UtcDatetime
from stac_pydantic.shared import (
BBox,
str_to_datetimes,
validate_bbox,
validate_datetime,
)

Intersection = Union[
Point,
Expand All @@ -28,78 +33,6 @@
GeometryCollection,
]

SearchDatetime = TypeAdapter(Optional[UtcDatetime])


def validate_bbox(v: Optional[BBox]) -> Optional[BBox]:
"""Validate BBOX value."""
if v:
# Validate order
if len(v) == 4:
xmin, ymin, xmax, ymax = cast(Tuple[int, int, int, int], v)

elif len(v) == 6:
xmin, ymin, min_elev, xmax, ymax, max_elev = cast(
Tuple[int, int, int, int, int, int], v
)
if max_elev < min_elev:
raise ValueError(
"Maximum elevation must greater than minimum elevation"
)
else:
raise ValueError("Bounding box must have 4 or 6 coordinates")

# Validate against WGS84
if xmin < -180 or ymin < -90 or xmax > 180 or ymax > 90:
raise ValueError("Bounding box must be within (-180, -90, 180, 90)")

if ymax < ymin:
raise ValueError("Maximum latitude must be greater than minimum latitude")

return v


def str_to_datetimes(value: str) -> List[Optional[dt]]:
# Split on "/" and replace no value or ".." with None
values = [v if v and v != ".." else None for v in value.split("/")]

# 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
],
)
return dates


def validate_datetime(v: Optional[str]) -> Optional[str]:
"""Validate Datetime value."""
if v is not None:
dates = str_to_datetimes(v)

# If there are more than 2 dates, it's invalid
if len(dates) > 2:
raise ValueError(
"Invalid datetime range. Too many values. Must match format: {begin_date}/{end_date}"
)

# If there is only one date, duplicate to use for both start and end dates
if len(dates) == 1:
dates = [dates[0], dates[0]]

# 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}"
)

return v

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved ☝️ to shared


class Search(BaseModel):
"""
Expand Down
46 changes: 41 additions & 5 deletions stac_pydantic/collection.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,61 @@
from typing import Any, Dict, List, Literal, Optional, Union
from typing import Any, Dict, List, Literal, Optional, Tuple, Union

from pydantic import Field
from pydantic import AfterValidator, Field
from typing_extensions import Annotated

from stac_pydantic.catalog import _Catalog
from stac_pydantic.shared import Asset, NumType, Provider, StacBaseModel
from stac_pydantic.shared import (
Asset,
BBox,
NumType,
Provider,
StacBaseModel,
UtcDatetime,
)

TInterval = Tuple[Union[UtcDatetime, None], Union[UtcDatetime, None]]


def validate_time_interval(v: List[TInterval]) -> List[TInterval]:
ivalues = iter(v)

# The first time interval always describes the overall temporal extent of the data.
start, end = next(ivalues)
if start and end:
assert start < end, f"`Start` time {start} older than `End` time {end}"

# All subsequent time intervals can be used to provide a more precise
# description of the extent and identify clusters of data.
for s, e in ivalues:
if start and s:
if start > s:
raise ValueError(
f"`Overall Start` time {start} older than `Start` time {s}"
)

if end and e:
if e > end:
raise ValueError(
f"`End` time {e} older than `Overall Start` time {end}"
)

return v


class SpatialExtent(StacBaseModel):
"""
https://github.com/radiantearth/stac-spec/blob/v1.0.0/collection-spec/collection-spec.md#spatial-extent-object
"""

bbox: List[List[NumType]]
bbox: List[BBox]


class TimeInterval(StacBaseModel):
"""
https://github.com/radiantearth/stac-spec/blob/v1.0.0/collection-spec/collection-spec.md#temporal-extent-object
"""

interval: List[List[Union[str, None]]]
interval: Annotated[List[TInterval], AfterValidator(validate_time_interval)]


class Extent(StacBaseModel):
Expand Down
76 changes: 75 additions & 1 deletion stac_pydantic/shared.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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 typing import Any, Dict, List, Optional, Tuple, Union, cast
from warnings import warn

from pydantic import (
Expand All @@ -9,6 +10,7 @@
BaseModel,
ConfigDict,
Field,
TypeAdapter,
model_validator,
)
from typing_extensions import Annotated, Self
Expand All @@ -32,6 +34,8 @@
AfterValidator(lambda d: d.astimezone(timezone.utc)),
]

SearchDatetime = TypeAdapter(Optional[UtcDatetime])


class MimeTypes(str, Enum):
"""
Expand Down Expand Up @@ -196,3 +200,73 @@ class Asset(StacBaseModel):
model_config = ConfigDict(
populate_by_name=True, use_enum_values=True, extra="allow"
)


def str_to_datetimes(value: str) -> List[Optional[dt]]:
# Split on "/" and replace no value or ".." with None
values = [v if v and v != ".." else None for v in value.split("/")]

# 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
],
)
return dates


def validate_datetime(v: Optional[str]) -> Optional[str]:
"""Validate Datetime value."""
if v is not None:
dates = str_to_datetimes(v)

# If there are more than 2 dates, it's invalid
if len(dates) > 2:
raise ValueError(
"Invalid datetime range. Too many values. Must match format: {begin_date}/{end_date}"
)

# If there is only one date, duplicate to use for both start and end dates
if len(dates) == 1:
dates = [dates[0], dates[0]]

# 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}"
)

return v


def validate_bbox(v: Optional[BBox]) -> Optional[BBox]:
"""Validate BBOX value."""
if v:
# Validate order
if len(v) == 4:
xmin, ymin, xmax, ymax = cast(Tuple[int, int, int, int], v)

elif len(v) == 6:
xmin, ymin, min_elev, xmax, ymax, max_elev = cast(
Tuple[int, int, int, int, int, int], v
)
if max_elev < min_elev:
raise ValueError(
"Maximum elevation must greater than minimum elevation"
)
else:
raise ValueError("Bounding box must have 4 or 6 coordinates")

# Validate against WGS84
if xmin < -180 or ymin < -90 or xmax > 180 or ymax > 90:
raise ValueError("Bounding box must be within (-180, -90, 180, 90)")

if ymax < ymin:
raise ValueError("Maximum latitude must be greater than minimum latitude")

return v
4 changes: 2 additions & 2 deletions tests/api/examples/v1.0.0/example-collection-list.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@
"temporal":{
"interval":[
[
"2000-03-04T12:00:00.000000Z",
"2006-12-31T12:00:00.000000Z"
"2000-03-04T12:00:00Z",
"2006-12-31T12:00:00Z"
]
]
}
Expand Down
2 changes: 1 addition & 1 deletion tests/api/test_collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@

def test_collection_list():
test_collection_list = request(EXAMPLE_COLLECTION_LIST, PATH)
valid_collection_list = Collections(**test_collection_list).model_dump()
valid_collection_list = Collections(**test_collection_list).model_dump(mode="json")
dict_match(test_collection_list, valid_collection_list)
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def dict_match(d1: dict, d2: dict):
# to compare the values as datetime objects.
elif "datetime" in diff[1]:
dates = [
UtcDatetimeAdapter.validate_strings(date)
UtcDatetimeAdapter.validate_strings(date, strict=True)
if isinstance(date, str)
else date
for date in diff[2]
Expand Down
Loading