Skip to content

Commit 7b1cb44

Browse files
Merge pull request #131 from eseglem/bugfix/stac-pydantic-130-validate-item-properties
Adjust ItemProperties Validation.
2 parents b128135 + 8b7881b commit 7b1cb44

File tree

9 files changed

+207
-134
lines changed

9 files changed

+207
-134
lines changed

Diff for: CHANGELOG.txt

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
- Enforce required `type` key for `Collection` and `Catalog` models
99
- Add queryables link relation type (#123, @constantinius)
1010
- Fix STAC API Query Extension operator names from ne->neq, le->lte, and ge->gte (#120, @philvarner)
11+
- Better **datetime** parsing/validation by using Pydantic native types and remove `ciso8601` requirement (#131, @eseglem)
12+
- move datetime validation in `StacCommonMetadata` model definition (#131, @eseglem)
1113

1214
3.0.0 (2024-01-25)
1315
------------------

Diff for: pyproject.toml

+1-7
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,7 @@ keywords=["stac", "pydantic", "validation"]
2020
authors=[{ name = "Arturo Engineering", email = "[email protected]"}]
2121
license= { text = "MIT" }
2222
requires-python=">=3.8"
23-
dependencies = [
24-
"click>=8.1.7",
25-
"pydantic>=2.4.1",
26-
"geojson-pydantic>=1.0.0",
27-
"ciso8601~=2.3",
28-
]
23+
dependencies = ["click>=8.1.7", "pydantic>=2.4.1", "geojson-pydantic>=1.0.0"]
2924
dynamic = ["version", "readme"]
3025

3126
[project.scripts]
@@ -37,7 +32,6 @@ repository ="https://github.com/stac-utils/stac-pydantic.git"
3732

3833
[project.optional-dependencies]
3934
dev = [
40-
"arrow>=1.2.3",
4135
"pytest>=7.4.2",
4236
"pytest-cov>=4.1.0",
4337
"pytest-icdiff>=0.8",

Diff for: stac_pydantic/api/search.py

+43-36
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
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 GeometryCollection # type: ignore
64
from geojson_pydantic.geometries import (
5+
GeometryCollection,
76
LineString,
87
MultiLineString,
98
MultiPoint,
109
MultiPolygon,
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,32 +98,43 @@ 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))
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("/")]
118104

105+
# If there are more than 2 dates, it's invalid
119106
if len(values) > 2:
120107
raise ValueError(
121-
"Invalid datetime range, must match format (begin_date, end_date)"
108+
"Invalid datetime range. Too many values. Must match format: {begin_date}/{end_date}"
122109
)
123110

124-
if not {"..", ""}.intersection(set(values)):
125-
if dates[0] > dates[1]:
126-
raise ValueError(
127-
"Invalid datetime range, must match format (begin_date, end_date)"
128-
)
111+
# If there is only one date, insert a None for the start date
112+
if len(values) == 1:
113+
values.insert(0, None)
114+
115+
# Cast because pylance gets confused by the type adapter and annotated type
116+
dates = cast(
117+
List[Optional[dt]],
118+
[
119+
# Use the type adapter to validate the datetime strings, strict is necessary
120+
# due to pydantic issues #8736 and #8762
121+
SearchDatetime.validate_strings(v, strict=True) if v else None
122+
for v in values
123+
],
124+
)
125+
126+
# If there is a start and end date, check that the start date is before the end date
127+
if dates[0] and dates[1] and dates[0] > dates[1]:
128+
raise ValueError(
129+
"Invalid datetime range. Begin date after end date. "
130+
"Must match format: {begin_date}/{end_date}"
131+
)
129132

130-
return v
133+
# Store the parsed dates
134+
cls._start_date = dates[0]
135+
cls._end_date = dates[1]
136+
# Return the original string value
137+
return value
131138

132139
@property
133140
def spatial_filter(self) -> Optional[Intersection]:

Diff for: stac_pydantic/item.py

+5-41
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,15 @@
1-
from datetime import datetime as dt
2-
from typing import Any, Dict, List, Optional, Union
1+
from typing import Any, Dict, List, Optional
32

4-
from ciso8601 import parse_rfc3339
53
from geojson_pydantic import Feature
6-
from pydantic import (
7-
AnyUrl,
8-
ConfigDict,
9-
Field,
10-
field_serializer,
11-
model_serializer,
12-
model_validator,
13-
)
4+
from pydantic import AnyUrl, ConfigDict, Field, model_serializer, model_validator
145

156
from stac_pydantic.links import Links
167
from stac_pydantic.shared import (
17-
DATETIME_RFC339,
188
SEMVER_REGEX,
199
Asset,
2010
StacBaseModel,
2111
StacCommonMetadata,
12+
UtcDatetime,
2213
)
2314
from stac_pydantic.version import STAC_VERSION
2415

@@ -28,39 +19,12 @@ class ItemProperties(StacCommonMetadata):
2819
https://github.com/radiantearth/stac-spec/blob/v1.0.0/item-spec/item-spec.md#properties-object
2920
"""
3021

31-
datetime: Union[dt, str] = Field(..., alias="datetime")
22+
# Overide the datetime field to be required
23+
datetime: Optional[UtcDatetime]
3224

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

36-
@model_validator(mode="before")
37-
@classmethod
38-
def validate_datetime(cls, data: Dict[str, Any]) -> Dict[str, Any]:
39-
datetime = data.get("datetime")
40-
start_datetime = data.get("start_datetime")
41-
end_datetime = data.get("end_datetime")
42-
43-
if not datetime or datetime == "null":
44-
if not start_datetime and not end_datetime:
45-
raise ValueError(
46-
"start_datetime and end_datetime must be specified when datetime is null"
47-
)
48-
49-
if isinstance(datetime, str):
50-
data["datetime"] = parse_rfc3339(datetime)
51-
52-
if isinstance(start_datetime, str):
53-
data["start_datetime"] = parse_rfc3339(start_datetime)
54-
55-
if isinstance(end_datetime, str):
56-
data["end_datetime"] = parse_rfc3339(end_datetime)
57-
58-
return data
59-
60-
@field_serializer("datetime")
61-
def serialize_datetime(self, v: dt, _info: Any) -> str:
62-
return v.strftime(DATETIME_RFC339)
63-
6428

6529
class Item(Feature, StacBaseModel):
6630
"""

Diff for: stac_pydantic/shared.py

+68-20
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1-
from datetime import datetime
1+
from datetime import timezone
22
from enum import Enum, auto
33
from typing import 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+
model_validator,
13+
)
14+
from typing_extensions import Annotated, Self
715

816
from stac_pydantic.utils import AutoValueEnum
917

@@ -15,9 +23,14 @@
1523

1624
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-]+)*))?$"
1725

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"
26+
# Allows for some additional flexibility in the input datetime format. As long as
27+
# the input value has timezone information, it will be converted to UTC timezone.
28+
UtcDatetime = Annotated[
29+
# Input value must be in a format which has timezone information
30+
AwareDatetime,
31+
# Convert the input value to UTC timezone
32+
AfterValidator(lambda d: d.astimezone(timezone.utc)),
33+
]
2134

2235

2336
class MimeTypes(str, Enum):
@@ -106,41 +119,76 @@ class Provider(StacBaseModel):
106119
https://github.com/radiantearth/stac-spec/blob/v1.0.0/collection-spec/collection-spec.md#provider-object
107120
"""
108121

109-
name: str = Field(..., alias="name", min_length=1)
122+
name: str = Field(..., min_length=1)
110123
description: Optional[str] = None
111124
roles: Optional[List[str]] = None
112125
url: Optional[str] = None
113126

114127

115128
class StacCommonMetadata(StacBaseModel):
116129
"""
117-
https://github.com/radiantearth/stac-spec/blob/v1.0.0/item-spec/common-metadata.md#date-and-time-range
130+
https://github.com/radiantearth/stac-spec/blob/v1.0.0/item-spec/common-metadata.md
118131
"""
119132

120-
title: Optional[str] = Field(None, alias="title")
121-
description: Optional[str] = Field(None, alias="description")
122-
start_datetime: Optional[datetime] = Field(None, alias="start_datetime")
123-
end_datetime: Optional[datetime] = Field(None, alias="end_datetime")
124-
created: Optional[datetime] = Field(None, alias="created")
125-
updated: Optional[datetime] = Field(None, alias="updated")
126-
platform: Optional[str] = Field(None, alias="platform")
127-
instruments: Optional[List[str]] = Field(None, alias="instruments")
128-
constellation: Optional[str] = Field(None, alias="constellation")
129-
mission: Optional[str] = Field(None, alias="mission")
130-
providers: Optional[List[Provider]] = Field(None, alias="providers")
131-
gsd: Optional[float] = Field(None, alias="gsd", gt=0)
133+
# Basic
134+
title: Optional[str] = None
135+
description: Optional[str] = None
136+
# Date and Time
137+
datetime: Optional[UtcDatetime] = None
138+
created: Optional[UtcDatetime] = None
139+
updated: Optional[UtcDatetime] = None
140+
# Date and Time Range
141+
start_datetime: Optional[UtcDatetime] = None
142+
end_datetime: Optional[UtcDatetime] = None
143+
# Provider
144+
providers: Optional[List[Provider]] = None
145+
# Instrument
146+
platform: Optional[str] = None
147+
instruments: Optional[List[str]] = None
148+
constellation: Optional[str] = None
149+
mission: Optional[str] = None
150+
gsd: Optional[float] = Field(None, gt=0)
151+
152+
@model_validator(mode="after")
153+
def validate_datetime_or_start_end(self) -> Self:
154+
# When datetime is null, start_datetime and end_datetime must be specified
155+
if not self.datetime and (not self.start_datetime or not self.end_datetime):
156+
raise ValueError(
157+
"start_datetime and end_datetime must be specified when datetime is null"
158+
)
159+
160+
return self
161+
162+
@model_validator(mode="after")
163+
def validate_start_end(self) -> Self:
164+
# Using one of start_datetime or end_datetime requires the use of the other
165+
if (self.start_datetime and not self.end_datetime) or (
166+
not self.start_datetime and self.end_datetime
167+
):
168+
raise ValueError(
169+
"use of start_datetime or end_datetime requires the use of the other"
170+
)
171+
return self
132172

133173

134174
class Asset(StacCommonMetadata):
135175
"""
136176
https://github.com/radiantearth/stac-spec/blob/v1.0.0/item-spec/item-spec.md#asset-object
137177
"""
138178

139-
href: str = Field(..., alias="href", min_length=1)
179+
href: str = Field(..., min_length=1)
140180
type: Optional[str] = None
141181
title: Optional[str] = None
142182
description: Optional[str] = None
143183
roles: Optional[List[str]] = None
184+
144185
model_config = ConfigDict(
145186
populate_by_name=True, use_enum_values=True, extra="allow"
146187
)
188+
189+
@model_validator(mode="after")
190+
def validate_datetime_or_start_end(self) -> Self:
191+
# Overriding the parent method to avoid requiring datetime or start/end_datetime
192+
# Additional fields MAY be added on the Asset object, but are not required.
193+
# https://github.com/radiantearth/stac-spec/blob/v1.0.0/item-spec/item-spec.md#additional-fields-for-assets
194+
return self

Diff for: tests/api/extensions/test_fields.py

+2-2
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"},

0 commit comments

Comments
 (0)