Skip to content

Commit 2a6c6bb

Browse files
authored
Add stac-pydantic validation (#249)
- Added `--pydantic` option for validating STAC objects using stac-pydantic models, providing enhanced type checking and validation - Added optional dependency for stac-pydantic that can be installed with `pip install stac-validator[pydantic]`
1 parent b102665 commit 2a6c6bb

File tree

9 files changed

+214
-8
lines changed

9 files changed

+214
-8
lines changed

.github/workflows/test-runner.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
pytest --mypy stac_validator
3333
3434
- name: Run pre-commit
35-
if: matrix.python-version == 3.10
35+
if: matrix.python-version == 3.12
3636
run: |
3737
pre-commit install
3838
pre-commit autoupdate

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ The format is (loosely) based on [Keep a Changelog](http://keepachangelog.com/)
1010

1111
- Added a comprehensive Table of Contents to README.md [#247](https://github.com/stac-utils/stac-validator/pull/247)
1212
- Added Sponsors and Supporters section to README.md with organizational logos and acknowledgments [#247](https://github.com/stac-utils/stac-validator/pull/247)
13+
- Added `--pydantic` option for validating STAC objects using stac-pydantic models, providing enhanced type checking and validation [#249](https://github.com/stac-utils/stac-validator/pull/249)
14+
- Added optional dependency for stac-pydantic that can be installed with `pip install stac-validator[pydantic]` [#249](https://github.com/stac-utils/stac-validator/pull/249)
1315

1416
### Changed
1517

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
- [Item Collection Validation](#--item-collection)
3232
- [Using Headers](#--header)
3333
- [Schema Mapping](#--schema-map)
34+
- [Pydantic Validation](#--pydantic)
3435
- [Deployment](#deployment)
3536
- [Docker](#docker)
3637
- [AWS (CDK)](#aws-cdk)
@@ -174,6 +175,8 @@ Options:
174175
--no_output Do not print output to console.
175176
--log_file TEXT Save full recursive output to log file
176177
(local filepath).
178+
--pydantic Validate using stac-pydantic models for enhanced
179+
type checking and validation.
177180
--help Show this message and exit.
178181
```
179182
@@ -458,6 +461,43 @@ $ stac-validator tests/test_data/v100/extended-item-local.json --custom tests/te
458461
]
459462
```
460463
464+
### --pydantic
465+
466+
The `--pydantic` option provides enhanced validation using stac-pydantic models, which offer stronger type checking and more detailed error messages. To use this feature, you need to install the optional dependency:
467+
468+
```bash
469+
$ pip install stac-validator[pydantic]
470+
```
471+
472+
Then you can validate your STAC objects using Pydantic models:
473+
474+
```bash
475+
$ stac-validator https://raw.githubusercontent.com/radiantearth/stac-spec/master/examples/extended-item.json --pydantic
476+
```
477+
478+
```bash
479+
[
480+
{
481+
"version": "1.0.0",
482+
"path": "https://raw.githubusercontent.com/radiantearth/stac-spec/master/examples/extended-item.json",
483+
"schema": [
484+
"stac-pydantic Item model"
485+
],
486+
"valid_stac": true,
487+
"asset_type": "ITEM",
488+
"validation_method": "pydantic",
489+
"extension_schemas": [
490+
"https://stac-extensions.github.io/eo/v1.0.0/schema.json",
491+
"https://stac-extensions.github.io/projection/v1.0.0/schema.json",
492+
"https://stac-extensions.github.io/scientific/v1.0.0/schema.json",
493+
"https://stac-extensions.github.io/view/v1.0.0/schema.json",
494+
"https://stac-extensions.github.io/remote-data/v1.0.0/schema.json"
495+
],
496+
"model_validation": "passed"
497+
}
498+
]
499+
```
500+
461501
## Sponsors and Supporters
462502
463503
The following organizations have contributed time and/or funding to support the development of this project:

setup.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"requests>=2.32.3",
3030
"jsonschema>=4.23.0",
3131
"click>=8.1.8",
32+
"stac-pydantic>=3.3.0",
3233
"referencing>=0.35.1",
3334
],
3435
extras_require={
@@ -37,6 +38,9 @@
3738
"requests-mock",
3839
"types-setuptools",
3940
],
41+
"pydantic": [
42+
"stac-pydantic>=3.3.0",
43+
],
4044
},
4145
packages=["stac_validator"],
4246
entry_points={

stac_validator/stac_validator.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ def collections_summary(message: List[Dict[str, Any]]) -> None:
142142
default="",
143143
help="Save full recursive output to log file (local filepath).",
144144
)
145+
@click.option(
146+
"--pydantic",
147+
is_flag=True,
148+
help="Validate using stac-pydantic models for enhanced type checking and validation.",
149+
)
145150
def main(
146151
stac_file: str,
147152
collections: bool,
@@ -160,6 +165,7 @@ def main(
160165
verbose: bool,
161166
no_output: bool,
162167
log_file: str,
168+
pydantic: bool,
163169
) -> None:
164170
"""Main function for the `stac-validator` command line tool. Validates a STAC file
165171
against the STAC specification and prints the validation results to the console as JSON.
@@ -182,6 +188,7 @@ def main(
182188
verbose (bool): Whether to enable verbose output for recursive mode.
183189
no_output (bool): Whether to print output to console.
184190
log_file (str): Path to a log file to save full recursive output.
191+
pydantic (bool): Whether to validate using stac-pydantic models for enhanced type checking and validation.
185192
186193
Returns:
187194
None
@@ -212,6 +219,7 @@ def main(
212219
schema_map=schema_map_dict,
213220
verbose=verbose,
214221
log=log_file,
222+
pydantic=pydantic,
215223
)
216224
if not item_collection and not collections:
217225
valid = stac.run()

stac_validator/validate.py

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class StacValidate:
4040
custom (str): The local filepath or remote URL of a custom JSON schema to validate the STAC object.
4141
verbose (bool): Whether to enable verbose output in recursive mode.
4242
log (str): The local filepath to save the output of the recursive validation to.
43+
pydantic (bool): Whether to validate using Pydantic models.
4344
4445
Methods:
4546
run(): Validates the STAC object and returns whether it is valid.
@@ -64,6 +65,7 @@ def __init__(
6465
schema_map: Optional[Dict[str, str]] = None,
6566
verbose: bool = False,
6667
log: str = "",
68+
pydantic: bool = False,
6769
):
6870
self.stac_file = stac_file
6971
self.collections = collections
@@ -87,6 +89,7 @@ def __init__(
8789
self.verbose = verbose
8890
self.valid = False
8991
self.log = log
92+
self.pydantic = pydantic
9093

9194
@property
9295
def schema(self) -> str:
@@ -372,7 +375,7 @@ def recursive_validator(self, stac_type: str) -> bool:
372375
if e.absolute_path:
373376
err_msg = (
374377
f"{e.message}. Error is in "
375-
f"{' -> '.join([str(i) for i in e.absolute_path])}"
378+
f"{' -> '.join([str(i) for i in e.absolute_path])} "
376379
)
377380
else:
378381
err_msg = f"{e.message}"
@@ -474,8 +477,9 @@ def validate_collections(self) -> None:
474477
475478
Raises:
476479
URLError, JSONDecodeError, ValueError, TypeError, FileNotFoundError,
477-
ConnectionError, exceptions.SSLError, OSError: Various errors related
478-
to fetching or parsing.
480+
ConnectionError, exceptions.SSLError, OSError, KeyError, HTTPError,
481+
jsonschema.exceptions.ValidationError, Exception: Various errors
482+
during fetching or parsing.
479483
"""
480484
collections = fetch_and_parse_file(str(self.stac_file), self.headers)
481485
for collection in collections["collections"]:
@@ -488,8 +492,9 @@ def validate_item_collection(self) -> None:
488492
489493
Raises:
490494
URLError, JSONDecodeError, ValueError, TypeError, FileNotFoundError,
491-
ConnectionError, exceptions.SSLError, OSError: Various errors related
492-
to fetching or parsing.
495+
ConnectionError, exceptions.SSLError, OSError, KeyError, HTTPError,
496+
jsonschema.exceptions.ValidationError, Exception: Various errors
497+
during fetching or parsing.
493498
"""
494499
page = 1
495500
print(f"processing page {page}")
@@ -519,6 +524,88 @@ def validate_item_collection(self) -> None:
519524
}
520525
self.message.append(message)
521526

527+
def pydantic_validator(self, stac_type: str) -> Dict:
528+
"""
529+
Validate STAC content using Pydantic models.
530+
531+
Args:
532+
stac_type (str): The STAC object type (e.g., "ITEM", "COLLECTION", "CATALOG").
533+
534+
Returns:
535+
dict: A dictionary containing validation results.
536+
"""
537+
message = self.create_message(stac_type, "pydantic")
538+
message["schema"] = [""]
539+
540+
try:
541+
# Import dependencies
542+
from pydantic import ValidationError # type: ignore
543+
from stac_pydantic import Catalog, Collection, Item # type: ignore
544+
from stac_pydantic.extensions import validate_extensions # type: ignore
545+
546+
# Validate based on STAC type
547+
if stac_type == "ITEM":
548+
item_model = Item.model_validate(self.stac_content)
549+
message["schema"] = ["stac-pydantic Item model"]
550+
self._validate_extensions(item_model, message, validate_extensions)
551+
552+
elif stac_type == "COLLECTION":
553+
collection_model = Collection.model_validate(self.stac_content)
554+
message["schema"] = [
555+
"stac-pydantic Collection model"
556+
] # Fix applied here
557+
self._validate_extensions(
558+
collection_model, message, validate_extensions
559+
)
560+
561+
elif stac_type == "CATALOG":
562+
Catalog.model_validate(self.stac_content)
563+
message["schema"] = ["stac-pydantic Catalog model"]
564+
565+
else:
566+
raise ValueError(
567+
f"Unsupported STAC type for Pydantic validation: {stac_type}"
568+
)
569+
570+
self.valid = True
571+
message["model_validation"] = "passed"
572+
573+
except ValidationError as e:
574+
self.valid = False
575+
error_details = [
576+
f"{' -> '.join(map(str, error.get('loc', [])))}: {error.get('msg', '')}"
577+
for error in e.errors()
578+
]
579+
error_message = f"Pydantic validation failed for {stac_type}: {'; '.join(error_details)}"
580+
message.update(
581+
self.create_err_msg("PydanticValidationError", error_message)
582+
)
583+
584+
except Exception as e:
585+
self.valid = False
586+
message.update(self.create_err_msg("PydanticValidationError", str(e)))
587+
588+
return message
589+
590+
def _validate_extensions(self, model, message: Dict, validate_extensions) -> None:
591+
"""
592+
Validate extensions for a given Pydantic model.
593+
594+
Args:
595+
model: The Pydantic model instance.
596+
message (dict): The validation message dictionary to update.
597+
validate_extensions: The function to validate extensions.
598+
"""
599+
if (
600+
"stac_extensions" in self.stac_content
601+
and self.stac_content["stac_extensions"]
602+
):
603+
extension_schemas = []
604+
validate_extensions(model, reraise_exception=True)
605+
for ext in self.stac_content["stac_extensions"]:
606+
extension_schemas.append(ext)
607+
message["extension_schemas"] = extension_schemas
608+
522609
def run(self) -> bool:
523610
"""
524611
Run the STAC validation process based on the input parameters.
@@ -563,6 +650,9 @@ def run(self) -> bool:
563650
elif self.extensions:
564651
message = self.extensions_validator(stac_type)
565652

653+
elif self.pydantic:
654+
message = self.pydantic_validator(stac_type)
655+
566656
else:
567657
self.valid = True
568658
message = self.default_validator(stac_type)

tests/test_pydantic.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""
2+
Description: Test the validator for Pydantic model validation
3+
4+
"""
5+
6+
from stac_validator import stac_validator
7+
8+
9+
def test_pydantic_item_local_v110():
10+
stac_file = "tests/test_data/v110/simple-item.json"
11+
stac = stac_validator.StacValidate(stac_file, pydantic=True)
12+
stac.run()
13+
assert stac.message[0]["version"] == "1.1.0"
14+
assert stac.message[0]["path"] == "tests/test_data/v110/simple-item.json"
15+
assert stac.message[0]["schema"] == ["stac-pydantic Item model"]
16+
assert stac.message[0]["valid_stac"] is True
17+
assert stac.message[0]["asset_type"] == "ITEM"
18+
assert stac.message[0]["validation_method"] == "pydantic"
19+
assert stac.message[0]["model_validation"] == "passed"
20+
21+
22+
def test_pydantic_collection_local_v110():
23+
stac_file = "tests/test_data/v110/collection.json"
24+
stac = stac_validator.StacValidate(stac_file, pydantic=True)
25+
stac.run()
26+
assert stac.message[0]["version"] == "1.1.0"
27+
assert stac.message[0]["path"] == "tests/test_data/v110/collection.json"
28+
assert stac.message[0]["schema"] == ["stac-pydantic Collection model"]
29+
assert stac.message[0]["valid_stac"] is True
30+
assert stac.message[0]["asset_type"] == "COLLECTION"
31+
assert stac.message[0]["validation_method"] == "pydantic"
32+
assert "extension_schemas" in stac.message[0]
33+
assert stac.message[0]["model_validation"] == "passed"
34+
35+
36+
# Find a catalog file in v100 directory since v110 doesn't have one
37+
def test_pydantic_catalog_local_v100():
38+
stac_file = "tests/test_data/v100/catalog.json"
39+
stac = stac_validator.StacValidate(stac_file, pydantic=True)
40+
stac.run()
41+
assert stac.message[0]["version"] == "1.0.0"
42+
assert stac.message[0]["path"] == "tests/test_data/v100/catalog.json"
43+
assert stac.message[0]["schema"] == ["stac-pydantic Catalog model"]
44+
assert stac.message[0]["valid_stac"] is True
45+
assert stac.message[0]["asset_type"] == "CATALOG"
46+
assert stac.message[0]["validation_method"] == "pydantic"
47+
assert stac.message[0]["model_validation"] == "passed"
48+
49+
50+
def test_pydantic_invalid_item():
51+
# Test with a file that should fail Pydantic validation
52+
stac_file = "tests/test_data/bad_data/bad_item_v090.json"
53+
stac = stac_validator.StacValidate(stac_file, pydantic=True)
54+
stac.run()
55+
assert stac.message[0]["version"] == "0.9.0"
56+
assert stac.message[0]["path"] == "tests/test_data/bad_data/bad_item_v090.json"
57+
assert stac.message[0]["valid_stac"] is False
58+
assert stac.message[0]["asset_type"] == "ITEM"
59+
assert stac.message[0]["validation_method"] == "pydantic"
60+
assert "error_type" in stac.message[0]
61+
assert "error_message" in stac.message[0]

tests/test_recursion.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,6 @@ def test_recursion_with_missing_collection_link():
398398
"valid_stac": False,
399399
"validation_method": "recursive",
400400
"error_type": "JSONSchemaValidationError",
401-
"error_message": "'simple-collection' should not be valid under {}. Error is in collection",
401+
"error_message": "'simple-collection' should not be valid under {}. Error is in collection ",
402402
},
403403
]

tox.ini

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ envlist = py38,py39,py310,py311,py312,py313
55
deps =
66
pytest
77
requests-mock
8-
commands = pytest
8+
commands = pytest
9+
extras = pydantic

0 commit comments

Comments
 (0)