Skip to content

Commit dd54306

Browse files
committed
Update datetime strategy.
1 parent b026dc7 commit dd54306

File tree

6 files changed

+95
-69
lines changed

6 files changed

+95
-69
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ keywords=["stac", "pydantic", "validation"]
1919
authors=[{ name = "Arturo Engineering", email = "[email protected]"}]
2020
license= { text = "MIT" }
2121
requires-python=">=3.8"
22-
dependencies = ["click>=8.1.7", "pydantic>=2.4.1", "geojson-pydantic>=1.0.0", "ciso8601~=2.3"]
22+
dependencies = ["click>=8.1.7", "pydantic>=2.4.1", "geojson-pydantic>=1.0.0"]
2323
dynamic = ["version", "readme"]
2424

2525
[project.scripts]

stac_pydantic/api/search.py

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

4-
from ciso8601 import parse_rfc3339
5-
from geojson_pydantic.geometries import ( # type: ignore
4+
from geojson_pydantic.geometries import (
65
GeometryCollection,
76
LineString,
87
MultiLineString,
@@ -11,12 +10,12 @@
1110
Point,
1211
Polygon,
1312
)
14-
from pydantic import BaseModel, Field, field_validator, model_validator
13+
from pydantic import BaseModel, Field, TypeAdapter, field_validator, model_validator
1514

1615
from stac_pydantic.api.extensions.fields import FieldsExtension
1716
from stac_pydantic.api.extensions.query import Operator
1817
from stac_pydantic.api.extensions.sort import SortExtension
19-
from stac_pydantic.shared import BBox
18+
from stac_pydantic.shared import BBox, UtcDatetime
2019

2120
Intersection = Union[
2221
Point,
@@ -28,6 +27,8 @@
2827
GeometryCollection,
2928
]
3029

30+
SearchDatetime = TypeAdapter(Optional[UtcDatetime])
31+
3132

3233
class Search(BaseModel):
3334
"""
@@ -43,23 +44,18 @@ class Search(BaseModel):
4344
datetime: Optional[str] = None
4445
limit: int = 10
4546

47+
# Private properties to store the parsed datetime values. Not part of the model schema.
48+
_start_date: Optional[dt] = None
49+
_end_date: Optional[dt] = None
50+
51+
# Properties to return the private values
4652
@property
4753
def start_date(self) -> Optional[dt]:
48-
values = (self.datetime or "").split("/")
49-
if len(values) == 1:
50-
return None
51-
if values[0] == ".." or values[0] == "":
52-
return None
53-
return parse_rfc3339(values[0])
54+
return self._start_date
5455

5556
@property
5657
def end_date(self) -> Optional[dt]:
57-
values = (self.datetime or "").split("/")
58-
if len(values) == 1:
59-
return parse_rfc3339(values[0])
60-
if values[1] == ".." or values[1] == "":
61-
return None
62-
return parse_rfc3339(values[1])
58+
return self._end_date
6359

6460
# Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-validators for more information.
6561
@model_validator(mode="before")
@@ -102,30 +98,38 @@ def validate_bbox(cls, v: BBox) -> BBox:
10298

10399
@field_validator("datetime")
104100
@classmethod
105-
def validate_datetime(cls, v: str) -> str:
106-
if "/" in v:
107-
values = v.split("/")
108-
else:
109-
# Single date is interpreted as end date
110-
values = ["..", v]
111-
112-
dates: List[dt] = []
113-
for value in values:
114-
if value == ".." or value == "":
115-
continue
116-
117-
dates.append(parse_rfc3339(value))
118-
101+
def validate_datetime(cls, value: str) -> str:
102+
# Split on "/" and replace no value or ".." with None
103+
values = [v if v and v != ".." else None for v in value.split("/")]
104+
# If there are more than 2 dates, it's invalid
119105
if len(values) > 2:
120-
raise ValueError("Invalid datetime range, must match format (begin_date, end_date)")
121-
122-
if not {"..", ""}.intersection(set(values)):
123-
if dates[0] > dates[1]:
124-
raise ValueError(
125-
"Invalid datetime range, must match format (begin_date, end_date)"
126-
)
127-
128-
return v
106+
raise ValueError(
107+
"Invalid datetime range. Too many values. Must match format: {begin_date}/{end_date}"
108+
)
109+
# If there is only one date, insert a None for the start date
110+
if len(values) == 1:
111+
values.insert(0, None)
112+
# Cast because pylance gets confused by the type adapter and annotated type
113+
dates = cast(
114+
List[Optional[dt]],
115+
[
116+
# Use the type adapter to validate the datetime strings, strict is necessary
117+
# due to pydantic issues #8736 and #8762
118+
SearchDatetime.validate_strings(v, strict=True) if v else None
119+
for v in values
120+
],
121+
)
122+
# If there is a start and end date, check that the start date is before the end date
123+
if dates[0] and dates[1] and dates[0] > dates[1]:
124+
raise ValueError(
125+
"Invalid datetime range. Begin date after end date. "
126+
"Must match format: {begin_date}/{end_date}"
127+
)
128+
# Store the parsed dates
129+
cls._start_date = dates[0]
130+
cls._end_date = dates[1]
131+
# Return the original string value
132+
return value
129133

130134
@property
131135
def spatial_filter(self) -> Optional[Intersection]:

stac_pydantic/item.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from datetime import datetime as dt
21
from typing import Any, Dict, List, Optional
32

43
from geojson_pydantic import Feature
@@ -9,13 +8,15 @@
98
model_serializer,
109
model_validator,
1110
)
11+
from typing_extensions import Self
1212

1313
from stac_pydantic.links import Links
1414
from stac_pydantic.shared import (
1515
SEMVER_REGEX,
1616
Asset,
1717
StacBaseModel,
1818
StacCommonMetadata,
19+
UtcDatetime,
1920
)
2021
from stac_pydantic.version import STAC_VERSION
2122

@@ -25,13 +26,13 @@ class ItemProperties(StacCommonMetadata):
2526
https://github.com/radiantearth/stac-spec/blob/v1.0.0/item-spec/item-spec.md#properties-object
2627
"""
2728

28-
datetime: Optional[dt] = Field(..., alias="datetime")
29+
datetime: Optional[UtcDatetime]
2930

3031
# Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-config for more information.
3132
model_config = ConfigDict(extra="allow")
3233

3334
@model_validator(mode="after")
34-
def validate_datetime(self) -> "ItemProperties":
35+
def validate_datetime(self) -> Self:
3536
if not self.datetime and (not self.start_datetime or not self.end_datetime):
3637
raise ValueError(
3738
"start_datetime and end_datetime must be specified when datetime is null"

stac_pydantic/shared.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1-
from datetime import datetime
1+
from datetime import timezone
22
from enum import Enum, auto
3-
from typing import Any, Dict, List, Optional, Tuple, Union
3+
from typing import Annotated, Any, Dict, List, Optional, Tuple, Union
44
from warnings import warn
55

6-
from pydantic import BaseModel, ConfigDict, Field
6+
from pydantic import (
7+
AfterValidator,
8+
AwareDatetime,
9+
BaseModel,
10+
ConfigDict,
11+
Field,
12+
PlainSerializer,
13+
)
714

815
from stac_pydantic.utils import AutoValueEnum
916

@@ -15,9 +22,16 @@
1522

1623
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-]+)*))?$"
1724

18-
# https://tools.ietf.org/html/rfc3339#section-5.6
19-
# Unused, but leaving it here since it's used by dependencies
20-
DATETIME_RFC339 = "%Y-%m-%dT%H:%M:%SZ"
25+
# Allows for some additional flexibility in the input datetime format. As long as
26+
# the input value has timezone information, it will be converted to UTC timezone.
27+
UtcDatetime = Annotated[
28+
# Input value must be in a format which has timezone information
29+
AwareDatetime,
30+
# Convert the input value to UTC timezone
31+
AfterValidator(lambda d: d.astimezone(timezone.utc)),
32+
# Use `isoformat` to serialize the value in an RFC3339 compatible format
33+
PlainSerializer(lambda d: d.isoformat()),
34+
]
2135

2236

2337
class MimeTypes(str, Enum):
@@ -118,10 +132,10 @@ class StacCommonMetadata(StacBaseModel):
118132

119133
title: Optional[str] = Field(None, alias="title")
120134
description: Optional[str] = Field(None, alias="description")
121-
start_datetime: Optional[datetime] = Field(None, alias="start_datetime")
122-
end_datetime: Optional[datetime] = Field(None, alias="end_datetime")
123-
created: Optional[datetime] = Field(None, alias="created")
124-
updated: Optional[datetime] = Field(None, alias="updated")
135+
start_datetime: Optional[UtcDatetime] = Field(None, alias="start_datetime")
136+
end_datetime: Optional[UtcDatetime] = Field(None, alias="end_datetime")
137+
created: Optional[UtcDatetime] = Field(None, alias="created")
138+
updated: Optional[UtcDatetime] = Field(None, alias="updated")
125139
platform: Optional[str] = Field(None, alias="platform")
126140
instruments: Optional[List[str]] = Field(None, alias="instruments")
127141
constellation: Optional[str] = Field(None, alias="constellation")

tests/api/extensions/test_fields.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime
1+
from datetime import datetime, timezone
22

33
from shapely.geometry import Polygon
44

@@ -15,7 +15,7 @@ def test_fields_filter_item():
1515
item = Item(
1616
id="test-fields-filter",
1717
geometry=Polygon.from_bounds(0, 0, 0, 0),
18-
properties={"datetime": datetime.utcnow(), "foo": "foo", "bar": "bar"},
18+
properties={"datetime": datetime.now(timezone.utc), "foo": "foo", "bar": "bar"},
1919
assets={},
2020
links=[
2121
{"href": "http://link", "rel": "self"},

tests/api/test_search.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import time
2-
from datetime import datetime, timezone, timedelta
2+
from datetime import datetime, timedelta, timezone
33

44
import pytest
55
from pydantic import ValidationError
66
from shapely.geometry import Polygon, shape
77

88
from stac_pydantic.api.search import Search
9-
from stac_pydantic.shared import DATETIME_RFC339
109

1110

1211
def test_search():
@@ -57,17 +56,17 @@ def test_invalid_spatial_search():
5756

5857
def test_temporal_search_single_tailed():
5958
# Test single tailed
60-
utcnow = datetime.utcnow().replace(microsecond=0, tzinfo=timezone.utc)
61-
utcnow_str = utcnow.strftime(DATETIME_RFC339)
59+
utcnow = datetime.now(timezone.utc).replace(microsecond=0, tzinfo=timezone.utc)
60+
utcnow_str = utcnow.isoformat()
6261
search = Search(collections=["collection1"], datetime=utcnow_str)
6362
assert search.start_date is None
6463
assert search.end_date == utcnow
6564

6665

6766
def test_temporal_search_two_tailed():
6867
# Test two tailed
69-
utcnow = datetime.utcnow().replace(microsecond=0, tzinfo=timezone.utc)
70-
utcnow_str = utcnow.strftime(DATETIME_RFC339)
68+
utcnow = datetime.now(timezone.utc).replace(microsecond=0, tzinfo=timezone.utc)
69+
utcnow_str = utcnow.isoformat()
7170
search = Search(collections=["collection1"], datetime=f"{utcnow_str}/{utcnow_str}")
7271
assert search.start_date == search.end_date == utcnow
7372

@@ -87,26 +86,34 @@ def test_temporal_search_open():
8786
assert search.end_date is None
8887

8988

90-
def test_invalid_temporal_search():
91-
# Not RFC339
92-
utcnow = datetime.utcnow().strftime("%Y-%m-%d")
89+
def test_invalid_temporal_search_date():
90+
# Just a date, no time
91+
utcnow = datetime.now(timezone.utc).strftime("%Y-%m-%d")
9392
with pytest.raises(ValidationError):
9493
Search(collections=["collection1"], datetime=utcnow)
9594

96-
t1 = datetime.utcnow()
95+
96+
def test_invalid_temporal_search_too_many():
97+
# Too many dates
98+
t1 = datetime.now(timezone.utc)
9799
t2 = t1 + timedelta(seconds=100)
98100
t3 = t2 + timedelta(seconds=100)
99101
with pytest.raises(ValidationError):
100-
Search(collections=["collection1"], datetime=f"{t1.strftime(DATETIME_RFC339)}/{t2.strftime(DATETIME_RFC339)}/{t3.strftime(DATETIME_RFC339)}",)
102+
Search(
103+
collections=["collection1"],
104+
datetime=f"{t1.isoformat()}/{t2.isoformat()}/{t3.isoformat()}",
105+
)
106+
101107

108+
def test_invalid_temporal_search_date_wrong_order():
102109
# End date is before start date
103-
start = datetime.utcnow()
110+
start = datetime.now(timezone.utc)
104111
time.sleep(2)
105-
end = datetime.utcnow()
112+
end = datetime.now(timezone.utc)
106113
with pytest.raises(ValidationError):
107114
Search(
108115
collections=["collection1"],
109-
datetime=f"{end.strftime(DATETIME_RFC339)}/{start.strftime(DATETIME_RFC339)}",
116+
datetime=f"{end.isoformat()}/{start.isoformat()}",
110117
)
111118

112119

0 commit comments

Comments
 (0)