Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +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))
- Add validation for collection's spatial intervals
- Add validation for collection's time intervals

## 3.2.0 (2025-03-20)

Expand Down
59 changes: 57 additions & 2 deletions stac_pydantic/collection.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union

from pydantic import AfterValidator, Field, conlist
from typing_extensions import Annotated
Expand All @@ -11,6 +11,7 @@
Provider,
StacBaseModel,
UtcDatetime,
validate_bbox,
)

if TYPE_CHECKING:
Expand All @@ -21,6 +22,60 @@
TInterval = conlist(StartEndTime, min_length=1)


def _normalize_bounds(
xmin: NumType, ymin: NumType, xmax: NumType, ymax: NumType
) -> Tuple[NumType, NumType, NumType, NumType]:
"""Return BBox in correct minx, miny, maxx, maxy order."""
return (
min(xmin, xmax),
min(ymin, ymax),
max(xmin, xmax),
max(ymin, ymax),
)


def validate_bbox_interval(v: List[BBox]) -> List[BBox]:
ivalues = iter(v)

# The first time interval always describes the overall spatial extent of the data.
overall_bbox = next(ivalues, None)
if not overall_bbox:
return v

assert validate_bbox(overall_bbox)

if len(overall_bbox) == 4:
xmin, ymin, xmax, ymax = overall_bbox
else:
xmin, ymin, _, xmax, ymax, _ = overall_bbox

# if bbox is crossing the Antimeridian limit we move xmax to the west
if xmin > xmax:
xmax = 180 - (xmax % 360)
Copy link
Member Author

Choose a reason for hiding this comment

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

Screenshot 2025-05-22 at 1 06 16 PM


xmin, ymin, xmax, ymax = _normalize_bounds(xmin, ymin, xmax, ymax)
for bbox in ivalues:
_ = validate_bbox(bbox)

if len(bbox) == 4:
xminb, yminb, xmaxb, ymaxb = bbox
else:
xminb, yminb, _, xmaxb, ymaxb, _ = bbox

if xminb > xmaxb:
xmaxb = 180 - (xmaxb % 360)

xminb, yminb, xmaxb, ymaxb = _normalize_bounds(xminb, yminb, xmaxb, ymaxb)
if not (
(xminb >= xmin) and (xmaxb <= xmax) and (yminb >= ymin) and (ymaxb <= ymax)
):
raise ValueError(
f"`BBOX` {bbox} not fully contained in `Overall BBOX` {overall_bbox}"
)

return v


def validate_time_interval(v: TInterval) -> TInterval: # noqa: C901
ivalues = iter(v)

Expand Down Expand Up @@ -61,7 +116,7 @@ class SpatialExtent(StacBaseModel):
https://github.com/radiantearth/stac-spec/blob/v1.0.0/collection-spec/collection-spec.md#spatial-extent-object
"""

bbox: List[BBox]
bbox: Annotated[List[BBox], AfterValidator(validate_bbox_interval)]


class TimeInterval(StacBaseModel):
Expand Down
43 changes: 42 additions & 1 deletion tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from shapely.geometry import shape

from stac_pydantic import Collection, Item, ItemProperties
from stac_pydantic.collection import TimeInterval
from stac_pydantic.collection import SpatialExtent, TimeInterval
from stac_pydantic.extensions import _fetch_and_cache_schema, validate_extensions
from stac_pydantic.links import Link, Links
from stac_pydantic.shared import MimeTypes, StacCommonMetadata
Expand Down Expand Up @@ -407,3 +407,44 @@ def test_time_intervals_invalid(interval) -> None:
def test_time_intervals_valid(interval) -> None:
"""Check Time Interval model."""
assert TimeInterval(interval=interval)


@pytest.mark.parametrize(
"bboxes",
[
# invalid Y order
[[0, 1, 1, 0]],
# sub-sequent crossing Y
[[0, 0, 2, 2], [0.5, 0.5, 2.0, 2.5]],
# sub-sequent crossing X
[[0, 0, 2, 2], [0.5, 0.5, 2.5, 2.0]],
# sub-sequent crossing Antimeridian limit
[[0, 0, 2, 2], [1, 0, -179, 1]],
# both crossing Antimeridian limit but sub-sequent cross west bbox
[[2, 0, -178, 2], [1, 0, -176, 1]],
],
)
def test_spatial_intervals_invalid(bboxes) -> None:
"""Check invalid Spatial Interval model."""
with pytest.raises(ValidationError):
SpatialExtent(bbox=bboxes)


@pytest.mark.parametrize(
"bboxes",
[
[[0, 0, 1, 1]],
# Same on both side
[[0, 0, 2, 2], [0, 0, 2, 2]],
[[0, 0, 2, 2], [0.5, 0.5, 1.5, 1.5]],
# crossing Antimeridian limit
[[2, 0, -178, 2]],
# overall crossing Antimeridian, sub-sequent bbox not crossing
[[2, 0, -178, 2], [0, 0, 1, 1]],
# overall and sub-sequent crossing Antimeridian
[[2, 0, -178, 2], [1, 0, -179, 1]],
],
)
def test_spatial_intervals_valid(bboxes) -> None:
"""Check Spatial Interval model."""
assert SpatialExtent(bbox=bboxes)