Skip to content

Commit 962e814

Browse files
Merge pull request #174 from stac-utils/feature/validator-reutilization
move Search field validators outside model
2 parents b37aaf7 + fa0c125 commit 962e814

File tree

3 files changed

+130
-88
lines changed

3 files changed

+130
-88
lines changed

Diff for: CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11

22
## Unreleased
33

4+
## 3.2.0 (TBD)
5+
6+
- Move `validate_bbox` and `validate_datetime` field validation functions outside the Search class (to enable re-utilization)
7+
- Remove `Search()._start_date` and ``Search()._end_date` private attributes
8+
- Add `api.search.str_to_datetime(value: str) -> List[Optional[datetime.datetime]]` function
9+
410
## 3.1.5 (2025-02-28)
511

612
- Fix `Search` model to make sure `_start_date` and `_end_date` privateAttr are cleared on model initialization (#72, @sbrunato and @vincentsarago)

Diff for: stac_pydantic/api/search.py

+82-88
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,8 @@
1010
Point,
1111
Polygon,
1212
)
13-
from pydantic import (
14-
BaseModel,
15-
Field,
16-
PrivateAttr,
17-
TypeAdapter,
18-
ValidationInfo,
19-
field_validator,
20-
model_validator,
21-
)
13+
from pydantic import AfterValidator, BaseModel, Field, TypeAdapter, model_validator
14+
from typing_extensions import Annotated
2215

2316
from stac_pydantic.api.extensions.fields import FieldsExtension
2417
from stac_pydantic.api.extensions.query import Operator
@@ -38,96 +31,65 @@
3831
SearchDatetime = TypeAdapter(Optional[UtcDatetime])
3932

4033

41-
class Search(BaseModel):
42-
"""
43-
The base class for STAC API searches.
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)
4440

45-
https://github.com/radiantearth/stac-api-spec/blob/v1.0.0/item-search/README.md#query-parameter-table
46-
"""
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")
4751

48-
collections: Optional[List[str]] = None
49-
ids: Optional[List[str]] = None
50-
bbox: Optional[BBox] = None
51-
intersects: Optional[Intersection] = None
52-
datetime: Optional[str] = None
53-
limit: Optional[int] = 10
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)")
5455

55-
# Private properties to store the parsed datetime values. Not part of the model schema.
56-
_start_date: Optional[dt] = PrivateAttr(default=None)
57-
_end_date: Optional[dt] = PrivateAttr(default=None)
56+
if ymax < ymin:
57+
raise ValueError("Maximum latitude must be greater than minimum latitude")
5858

59-
# Properties to return the private values
60-
@property
61-
def start_date(self) -> Optional[dt]:
62-
return self._start_date
59+
return v
6360

64-
@property
65-
def end_date(self) -> Optional[dt]:
66-
return self._end_date
6761

68-
# Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-validators for more information.
69-
@model_validator(mode="before")
70-
def validate_spatial(cls, values: Dict[str, Any]) -> Dict[str, Any]:
71-
if values.get("intersects") and values.get("bbox") is not None:
72-
raise ValueError("intersects and bbox parameters are mutually exclusive")
73-
return values
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("/")]
7465

75-
@field_validator("bbox")
76-
@classmethod
77-
def validate_bbox(cls, v: BBox) -> BBox:
78-
if v:
79-
# Validate order
80-
if len(v) == 4:
81-
xmin, ymin, xmax, ymax = cast(Tuple[int, int, int, int], v)
82-
else:
83-
xmin, ymin, min_elev, xmax, ymax, max_elev = cast(
84-
Tuple[int, int, int, int, int, int], v
85-
)
86-
if max_elev < min_elev:
87-
raise ValueError(
88-
"Maximum elevation must greater than minimum elevation"
89-
)
90-
# Validate against WGS84
91-
if xmin < -180 or ymin < -90 or xmax > 180 or ymax > 90:
92-
raise ValueError("Bounding box must be within (-180, -90, 180, 90)")
93-
94-
if ymax < ymin:
95-
raise ValueError(
96-
"Maximum longitude must be greater than minimum longitude"
97-
)
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
9877

99-
return v
10078

101-
@field_validator("datetime", mode="after")
102-
@classmethod
103-
def validate_datetime(
104-
cls, value: Optional[str], info: ValidationInfo
105-
) -> Optional[str]:
106-
# Split on "/" and replace no value or ".." with None
107-
if value is None:
108-
return value
109-
values = [v if v and v != ".." else None for v in value.split("/")]
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)
11083

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

11790
# If there is only one date, duplicate to use for both start and end dates
118-
if len(values) == 1:
119-
values = [values[0], values[0]]
120-
121-
# Cast because pylance gets confused by the type adapter and annotated type
122-
dates = cast(
123-
List[Optional[dt]],
124-
[
125-
# Use the type adapter to validate the datetime strings, strict is necessary
126-
# due to pydantic issues #8736 and #8762
127-
SearchDatetime.validate_strings(v, strict=True) if v else None
128-
for v in values
129-
],
130-
)
91+
if len(dates) == 1:
92+
dates = [dates[0], dates[0]]
13193

13294
# If there is a start and end date, check that the start date is before the end date
13395
if dates[0] and dates[1] and dates[0] > dates[1]:
@@ -136,12 +98,44 @@ def validate_datetime(
13698
"Must match format: {begin_date}/{end_date}"
13799
)
138100

139-
# Store the parsed dates
140-
info.data["_start_date"] = dates[0]
141-
info.data["_end_date"] = dates[1]
101+
return v
102+
103+
104+
class Search(BaseModel):
105+
"""
106+
The base class for STAC API searches.
107+
108+
https://github.com/radiantearth/stac-api-spec/blob/v1.0.0/item-search/README.md#query-parameter-table
109+
"""
110+
111+
collections: Optional[List[str]] = None
112+
ids: Optional[List[str]] = None
113+
bbox: Annotated[Optional[BBox], AfterValidator(validate_bbox)] = None
114+
intersects: Optional[Intersection] = None
115+
datetime: Annotated[Optional[str], AfterValidator(validate_datetime)] = None
116+
limit: Optional[int] = 10
117+
118+
@property
119+
def start_date(self) -> Optional[dt]:
120+
start_date: Optional[dt] = None
121+
if self.datetime:
122+
start_date = str_to_datetimes(self.datetime)[0]
123+
return start_date
124+
125+
@property
126+
def end_date(self) -> Optional[dt]:
127+
end_date: Optional[dt] = None
128+
if self.datetime:
129+
dates = str_to_datetimes(self.datetime)
130+
end_date = dates[0] if len(dates) == 1 else dates[1]
131+
return end_date
142132

143-
# Return the original string value
144-
return value
133+
# Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-validators for more information.
134+
@model_validator(mode="before")
135+
def validate_spatial(cls, values: Dict[str, Any]) -> Dict[str, Any]:
136+
if values.get("intersects") and values.get("bbox") is not None:
137+
raise ValueError("intersects and bbox parameters are mutually exclusive")
138+
return values
145139

146140
@property
147141
def spatial_filter(self) -> Optional[Intersection]:

Diff for: tests/api/test_search.py

+42
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ def test_search_geometry_bbox():
146146
@pytest.mark.parametrize(
147147
"bbox",
148148
[
149+
(100.0, 1.0), # 1D Coordinates
149150
(100.0, 1.0, 105.0, 0.0), # ymin greater than ymax
150151
(100.0, 0.0, 5.0, 105.0, 1.0, 4.0), # min elev greater than max elev
151152
(-200.0, 0.0, 105.0, 1.0), # xmin is invalid WGS84
@@ -165,3 +166,44 @@ def test_search_invalid_bbox(bbox):
165166

166167
def test_search_none_datetime() -> None:
167168
Search(datetime=None)
169+
170+
171+
@pytest.mark.parametrize(
172+
"dt,start,end",
173+
[
174+
# unique datetime, start/end == datetime
175+
["1985-04-12T23:20:50.52Z", False, False],
176+
# start datetime is None
177+
["../1985-04-12T23:20:50.52Z", True, False],
178+
["/1985-04-12T23:20:50.52Z", True, False],
179+
# end datetime is None
180+
["1985-04-12T23:20:50.52Z/..", False, True],
181+
["1985-04-12T23:20:50.52Z/", False, True],
182+
# Both start/end datetime are available
183+
["1985-04-12T23:20:50.52Z/1986-04-12T23:20:50.52Z", False, False],
184+
["1985-04-12T23:20:50.52+01:00/1986-04-12T23:20:50.52+01:00", False, False],
185+
["1985-04-12T23:20:50.52-01:00/1986-04-12T23:20:50.52-01:00", False, False],
186+
],
187+
)
188+
def test_search_datetime(dt, start, end):
189+
s = Search(datetime=dt)
190+
assert s.datetime is not None
191+
assert (s.start_date is None) == start
192+
assert (s.end_date is None) == end
193+
194+
195+
@pytest.mark.parametrize(
196+
"dt",
197+
[
198+
"/"
199+
"../"
200+
"/.."
201+
"../.."
202+
"/1984-04-12T23:20:50.52Z/1985-04-12T23:20:50.52Z", # extra start /
203+
"1984-04-12T23:20:50.52Z/1985-04-12T23:20:50.52Z/", # extra end /
204+
"1986-04-12T23:20:50.52Z/1985-04-12T23:20:50.52Z", # start > end
205+
],
206+
)
207+
def test_search_invalid_datetime(dt):
208+
with pytest.raises(ValidationError):
209+
Search(datetime=dt)

0 commit comments

Comments
 (0)