Skip to content

Commit f5c9033

Browse files
Merge pull request #177 from stac-utils/feature/validate-time-intervals
add datetime validation for collection time intervals
2 parents 0b5a8a2 + ac134e8 commit f5c9033

File tree

10 files changed

+236
-89
lines changed

10 files changed

+236
-89
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ repos:
2929
- id: ruff-format
3030

3131
- repo: https://github.com/pre-commit/mirrors-mypy
32-
rev: v1.9.0
32+
rev: v1.15.0
3333
hooks:
3434
- id: mypy
3535
language_version: python

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11

22
## Unreleased
33

4+
- 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))
5+
46
## 3.2.0 (2025-03-20)
57

68
- Move `validate_bbox` and `validate_datetime` field validation functions outside the Search class (to enable re-utilization)

CONTRIBUTING.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Contributing
2+
3+
Issues and pull requests are more than welcome.
4+
5+
**dev install**
6+
7+
```bash
8+
git clone https://github.com/stac-utils/stac-pydantic.git
9+
cd stac-pydantic
10+
python -m pip install -e ".[dev]"
11+
```
12+
13+
You can then run the tests with the following command:
14+
15+
```sh
16+
python -m pytest --cov stac_pydantic --cov-report term-missing
17+
```
18+
19+
20+
**pre-commit**
21+
22+
This repo is set to use `pre-commit` to run *ruff*, *pydocstring* and mypy when committing new code.
23+
24+
```bash
25+
pre-commit install
26+
```

stac_pydantic/api/search.py

Lines changed: 8 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from datetime import datetime as dt
2-
from typing import Any, Dict, List, Optional, Tuple, Union, cast
2+
from typing import Any, Dict, List, Optional, Union
33

44
from geojson_pydantic.geometries import (
55
GeometryCollection,
@@ -10,13 +10,18 @@
1010
Point,
1111
Polygon,
1212
)
13-
from pydantic import AfterValidator, BaseModel, Field, TypeAdapter, model_validator
13+
from pydantic import AfterValidator, BaseModel, Field, model_validator
1414
from typing_extensions import Annotated
1515

1616
from stac_pydantic.api.extensions.fields import FieldsExtension
1717
from stac_pydantic.api.extensions.query import Operator
1818
from stac_pydantic.api.extensions.sort import SortExtension
19-
from stac_pydantic.shared import BBox, UtcDatetime
19+
from stac_pydantic.shared import (
20+
BBox,
21+
str_to_datetimes,
22+
validate_bbox,
23+
validate_datetime,
24+
)
2025

2126
Intersection = Union[
2227
Point,
@@ -28,78 +33,6 @@
2833
GeometryCollection,
2934
]
3035

31-
SearchDatetime = TypeAdapter(Optional[UtcDatetime])
32-
33-
34-
def validate_bbox(v: Optional[BBox]) -> Optional[BBox]:
35-
"""Validate BBOX value."""
36-
if v:
37-
# Validate order
38-
if len(v) == 4:
39-
xmin, ymin, xmax, ymax = cast(Tuple[int, int, int, int], v)
40-
41-
elif len(v) == 6:
42-
xmin, ymin, min_elev, xmax, ymax, max_elev = cast(
43-
Tuple[int, int, int, int, int, int], v
44-
)
45-
if max_elev < min_elev:
46-
raise ValueError(
47-
"Maximum elevation must greater than minimum elevation"
48-
)
49-
else:
50-
raise ValueError("Bounding box must have 4 or 6 coordinates")
51-
52-
# Validate against WGS84
53-
if xmin < -180 or ymin < -90 or xmax > 180 or ymax > 90:
54-
raise ValueError("Bounding box must be within (-180, -90, 180, 90)")
55-
56-
if ymax < ymin:
57-
raise ValueError("Maximum latitude must be greater than minimum latitude")
58-
59-
return v
60-
61-
62-
def str_to_datetimes(value: str) -> List[Optional[dt]]:
63-
# Split on "/" and replace no value or ".." with None
64-
values = [v if v and v != ".." else None for v in value.split("/")]
65-
66-
# Cast because pylance gets confused by the type adapter and annotated type
67-
dates = cast(
68-
List[Optional[dt]],
69-
[
70-
# Use the type adapter to validate the datetime strings, strict is necessary
71-
# due to pydantic issues #8736 and #8762
72-
SearchDatetime.validate_strings(v, strict=True) if v else None
73-
for v in values
74-
],
75-
)
76-
return dates
77-
78-
79-
def validate_datetime(v: Optional[str]) -> Optional[str]:
80-
"""Validate Datetime value."""
81-
if v is not None:
82-
dates = str_to_datetimes(v)
83-
84-
# If there are more than 2 dates, it's invalid
85-
if len(dates) > 2:
86-
raise ValueError(
87-
"Invalid datetime range. Too many values. Must match format: {begin_date}/{end_date}"
88-
)
89-
90-
# If there is only one date, duplicate to use for both start and end dates
91-
if len(dates) == 1:
92-
dates = [dates[0], dates[0]]
93-
94-
# If there is a start and end date, check that the start date is before the end date
95-
if dates[0] and dates[1] and dates[0] > dates[1]:
96-
raise ValueError(
97-
"Invalid datetime range. Begin date after end date. "
98-
"Must match format: {begin_date}/{end_date}"
99-
)
100-
101-
return v
102-
10336

10437
class Search(BaseModel):
10538
"""

stac_pydantic/collection.py

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,75 @@
1-
from typing import Any, Dict, List, Literal, Optional, Union
1+
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union
22

3-
from pydantic import Field
3+
from pydantic import AfterValidator, Field, conlist
4+
from typing_extensions import Annotated
45

56
from stac_pydantic.catalog import _Catalog
6-
from stac_pydantic.shared import Asset, NumType, Provider, StacBaseModel
7+
from stac_pydantic.shared import (
8+
Asset,
9+
BBox,
10+
NumType,
11+
Provider,
12+
StacBaseModel,
13+
UtcDatetime,
14+
)
15+
16+
if TYPE_CHECKING:
17+
StartEndTime = List[Union[UtcDatetime, None]]
18+
TInterval = List[StartEndTime]
19+
else:
20+
StartEndTime = conlist(Union[UtcDatetime, None], min_length=2, max_length=2)
21+
TInterval = conlist(StartEndTime, min_length=1)
22+
23+
24+
def validate_time_interval(v: TInterval) -> TInterval: # noqa: C901
25+
ivalues = iter(v)
26+
27+
# The first time interval always describes the overall temporal extent of the data.
28+
overall_interval = next(ivalues, None)
29+
if not overall_interval:
30+
return v
31+
32+
start, end = overall_interval
33+
if start and end:
34+
if start > end:
35+
raise ValueError(f"`Start` time {start} older than `End` time {end}")
36+
37+
# All subsequent time intervals can be used to provide a more precise
38+
# description of the extent and identify clusters of data.
39+
for s, e in ivalues:
40+
if s and e:
41+
if s > e:
42+
raise ValueError(f"`Start` time {s} older than `End` time {e}")
43+
44+
if start and s:
45+
if start > s:
46+
raise ValueError(
47+
f"`Overall Start` time {start} older than `Start` time {s}"
48+
)
49+
50+
if end and e:
51+
if e > end:
52+
raise ValueError(
53+
f"`End` time {e} older than `Overall Start` time {end}"
54+
)
55+
56+
return v
757

858

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

14-
bbox: List[List[NumType]]
64+
bbox: List[BBox]
1565

1666

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

22-
interval: List[List[Union[str, None]]]
72+
interval: Annotated[TInterval, AfterValidator(validate_time_interval)]
2373

2474

2575
class Extent(StacBaseModel):

stac_pydantic/shared.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
from datetime import datetime as dt
12
from datetime import timezone
23
from enum import Enum, auto
3-
from typing import Any, Dict, List, Optional, Tuple, Union
4+
from typing import Any, Dict, List, Optional, Tuple, Union, cast
45
from warnings import warn
56

67
from pydantic import (
@@ -9,6 +10,7 @@
910
BaseModel,
1011
ConfigDict,
1112
Field,
13+
TypeAdapter,
1214
model_validator,
1315
)
1416
from typing_extensions import Annotated, Self
@@ -32,6 +34,8 @@
3234
AfterValidator(lambda d: d.astimezone(timezone.utc)),
3335
]
3436

37+
SearchDatetime = TypeAdapter(Optional[UtcDatetime])
38+
3539

3640
class MimeTypes(str, Enum):
3741
"""
@@ -196,3 +200,73 @@ class Asset(StacBaseModel):
196200
model_config = ConfigDict(
197201
populate_by_name=True, use_enum_values=True, extra="allow"
198202
)
203+
204+
205+
def str_to_datetimes(value: str) -> List[Optional[dt]]:
206+
# Split on "/" and replace no value or ".." with None
207+
values = [v if v and v != ".." else None for v in value.split("/")]
208+
209+
# Cast because pylance gets confused by the type adapter and annotated type
210+
dates = cast(
211+
List[Optional[dt]],
212+
[
213+
# Use the type adapter to validate the datetime strings, strict is necessary
214+
# due to pydantic issues #8736 and #8762
215+
SearchDatetime.validate_strings(v, strict=True) if v else None
216+
for v in values
217+
],
218+
)
219+
return dates
220+
221+
222+
def validate_datetime(v: Optional[str]) -> Optional[str]:
223+
"""Validate Datetime value."""
224+
if v is not None:
225+
dates = str_to_datetimes(v)
226+
227+
# If there are more than 2 dates, it's invalid
228+
if len(dates) > 2:
229+
raise ValueError(
230+
"Invalid datetime range. Too many values. Must match format: {begin_date}/{end_date}"
231+
)
232+
233+
# If there is only one date, duplicate to use for both start and end dates
234+
if len(dates) == 1:
235+
dates = [dates[0], dates[0]]
236+
237+
# If there is a start and end date, check that the start date is before the end date
238+
if dates[0] and dates[1] and dates[0] > dates[1]:
239+
raise ValueError(
240+
"Invalid datetime range. Begin date after end date. "
241+
"Must match format: {begin_date}/{end_date}"
242+
)
243+
244+
return v
245+
246+
247+
def validate_bbox(v: Optional[BBox]) -> Optional[BBox]:
248+
"""Validate BBOX value."""
249+
if v:
250+
# Validate order
251+
if len(v) == 4:
252+
xmin, ymin, xmax, ymax = cast(Tuple[int, int, int, int], v)
253+
254+
elif len(v) == 6:
255+
xmin, ymin, min_elev, xmax, ymax, max_elev = cast(
256+
Tuple[int, int, int, int, int, int], v
257+
)
258+
if max_elev < min_elev:
259+
raise ValueError(
260+
"Maximum elevation must greater than minimum elevation"
261+
)
262+
else:
263+
raise ValueError("Bounding box must have 4 or 6 coordinates")
264+
265+
# Validate against WGS84
266+
if xmin < -180 or ymin < -90 or xmax > 180 or ymax > 90:
267+
raise ValueError("Bounding box must be within (-180, -90, 180, 90)")
268+
269+
if ymax < ymin:
270+
raise ValueError("Maximum latitude must be greater than minimum latitude")
271+
272+
return v

tests/api/examples/v1.0.0/example-collection-list.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@
6363
"temporal":{
6464
"interval":[
6565
[
66-
"2000-03-04T12:00:00.000000Z",
67-
"2006-12-31T12:00:00.000000Z"
66+
"2000-03-04T12:00:00Z",
67+
"2006-12-31T12:00:00Z"
6868
]
6969
]
7070
}

tests/api/test_collections.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@
99

1010
def test_collection_list():
1111
test_collection_list = request(EXAMPLE_COLLECTION_LIST, PATH)
12-
valid_collection_list = Collections(**test_collection_list).model_dump()
12+
valid_collection_list = Collections(**test_collection_list).model_dump(mode="json")
1313
dict_match(test_collection_list, valid_collection_list)

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def dict_match(d1: dict, d2: dict):
4848
# to compare the values as datetime objects.
4949
elif "datetime" in diff[1]:
5050
dates = [
51-
UtcDatetimeAdapter.validate_strings(date)
51+
UtcDatetimeAdapter.validate_strings(date, strict=True)
5252
if isinstance(date, str)
5353
else date
5454
for date in diff[2]

0 commit comments

Comments
 (0)