stac-valid 4.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stac_valid-4.2.0.dist-info/METADATA +1218 -0
- stac_valid-4.2.0.dist-info/RECORD +12 -0
- stac_valid-4.2.0.dist-info/WHEEL +5 -0
- stac_valid-4.2.0.dist-info/entry_points.txt +3 -0
- stac_valid-4.2.0.dist-info/licenses/LICENSE +202 -0
- stac_valid-4.2.0.dist-info/top_level.txt +1 -0
- stac_validator/__init__.py +0 -0
- stac_validator/batch_validator.py +520 -0
- stac_validator/fast_validator.py +312 -0
- stac_validator/stac_validator.py +574 -0
- stac_validator/utilities.py +547 -0
- stac_validator/validate.py +1107 -0
|
@@ -0,0 +1,1107 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from json.decoder import JSONDecodeError
|
|
4
|
+
from typing import Any, Dict, List, Optional, Union
|
|
5
|
+
from urllib.error import HTTPError, URLError
|
|
6
|
+
|
|
7
|
+
import click # type: ignore
|
|
8
|
+
import jsonschema # type: ignore
|
|
9
|
+
from jsonschema.exceptions import best_match
|
|
10
|
+
from requests import exceptions # type: ignore
|
|
11
|
+
|
|
12
|
+
from .utilities import (
|
|
13
|
+
extract_relevant_oneof_error,
|
|
14
|
+
fetch_and_parse_file,
|
|
15
|
+
fetch_and_parse_schema,
|
|
16
|
+
get_stac_type,
|
|
17
|
+
is_valid_url,
|
|
18
|
+
link_request,
|
|
19
|
+
load_schema_config,
|
|
20
|
+
set_schema_addr,
|
|
21
|
+
validate_stac_version_field,
|
|
22
|
+
validate_with_ref_resolver,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StacValidate:
|
|
27
|
+
"""
|
|
28
|
+
Class that validates STAC objects.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
stac_file (str): The path or URL to the STAC object to be validated.
|
|
32
|
+
collections (bool): Validate response from a /collections endpoint.
|
|
33
|
+
item_collection (bool): Whether the STAC object to be validated is an item collection.
|
|
34
|
+
pages (int): The maximum number of pages to validate if `item_collection` is True.
|
|
35
|
+
recursive (bool): Whether to recursively validate related STAC objects.
|
|
36
|
+
max_depth (int): The maximum depth to traverse when recursively validating related STAC objects.
|
|
37
|
+
core (bool): Whether to only validate the core STAC object (without extensions).
|
|
38
|
+
links (bool): Whether to additionally validate links (only works in default mode).
|
|
39
|
+
assets (bool): Whether to additionally validate assets (only works in default mode).
|
|
40
|
+
assets_open_urls (bool): Whether to open assets URLs when validating assets.
|
|
41
|
+
headers (dict): HTTP headers to include in the requests.
|
|
42
|
+
extensions (bool): Whether to only validate STAC object extensions.
|
|
43
|
+
custom (str): The local filepath or remote URL of a custom JSON schema to validate the STAC object.
|
|
44
|
+
trace_recursion (bool): Whether to enable verbose output in recursive mode.
|
|
45
|
+
log (str): The local filepath to save the output of the recursive validation to.
|
|
46
|
+
pydantic (bool): Whether to validate using Pydantic models.
|
|
47
|
+
schema_config (str): The local filepath or remote URL of a custom JSON schema config to validate the STAC object.
|
|
48
|
+
schema_map (Optional[Dict[str, str]]): A dictionary mapping schema paths to their replacements.
|
|
49
|
+
verbose (bool): Whether to enable verbose output.
|
|
50
|
+
|
|
51
|
+
Methods:
|
|
52
|
+
run(): Validates the STAC object and returns whether it is valid.
|
|
53
|
+
validate_item_collection(): Validates an item collection.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
stac_file: Optional[str] = None,
|
|
59
|
+
collections: bool = False,
|
|
60
|
+
item_collection: bool = False,
|
|
61
|
+
pages: Optional[int] = None,
|
|
62
|
+
recursive: bool = False,
|
|
63
|
+
max_depth: Optional[int] = None,
|
|
64
|
+
core: bool = False,
|
|
65
|
+
links: bool = False,
|
|
66
|
+
assets: bool = False,
|
|
67
|
+
assets_open_urls: bool = True,
|
|
68
|
+
headers: dict = {},
|
|
69
|
+
extensions: bool = False,
|
|
70
|
+
custom: str = "",
|
|
71
|
+
schema_config: Optional[str] = None,
|
|
72
|
+
schema_map: Optional[Dict[str, str]] = None,
|
|
73
|
+
trace_recursion: bool = False,
|
|
74
|
+
log: str = "",
|
|
75
|
+
pydantic: bool = False,
|
|
76
|
+
verbose: bool = False,
|
|
77
|
+
show_progress: bool = False,
|
|
78
|
+
):
|
|
79
|
+
self.stac_file = stac_file
|
|
80
|
+
self.collections = collections
|
|
81
|
+
self.item_collection = item_collection
|
|
82
|
+
self.pages = pages
|
|
83
|
+
self.message: List = []
|
|
84
|
+
self._schema = custom
|
|
85
|
+
self.schema_map = schema_map
|
|
86
|
+
self.schema_config = schema_config
|
|
87
|
+
self.links = links
|
|
88
|
+
self.assets = assets
|
|
89
|
+
self.assets_open_urls = assets_open_urls
|
|
90
|
+
self.headers: Dict = headers
|
|
91
|
+
self.recursive = recursive
|
|
92
|
+
self.max_depth = max_depth
|
|
93
|
+
self.extensions = extensions
|
|
94
|
+
self.core = core
|
|
95
|
+
self.stac_content: Dict = {}
|
|
96
|
+
self.version = ""
|
|
97
|
+
self.depth: int = 0
|
|
98
|
+
self.skip_val = False
|
|
99
|
+
self.trace_recursion = trace_recursion
|
|
100
|
+
self.valid = False
|
|
101
|
+
self.log = log
|
|
102
|
+
self.pydantic = pydantic
|
|
103
|
+
self.verbose = verbose
|
|
104
|
+
self.show_progress = show_progress
|
|
105
|
+
|
|
106
|
+
self._original_schema_paths = {}
|
|
107
|
+
cli_schema_map = schema_map or {}
|
|
108
|
+
|
|
109
|
+
# Load config mappings if provided
|
|
110
|
+
config_mappings = {}
|
|
111
|
+
if schema_config:
|
|
112
|
+
try:
|
|
113
|
+
config_mappings = load_schema_config(schema_config)
|
|
114
|
+
config_dir = os.path.dirname(os.path.abspath(schema_config))
|
|
115
|
+
# Handle config mappings
|
|
116
|
+
for url, path in list(config_mappings.items()):
|
|
117
|
+
self._original_schema_paths[url] = path
|
|
118
|
+
# Resolve relative to config file dir for loading
|
|
119
|
+
if not os.path.isabs(path) and not is_valid_url(path):
|
|
120
|
+
config_mappings[url] = os.path.join(config_dir, path)
|
|
121
|
+
abs_path = os.path.abspath(config_mappings[url])
|
|
122
|
+
self._original_schema_paths[abs_path] = (
|
|
123
|
+
path # <-- This line is critical
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
except Exception as e:
|
|
127
|
+
click.secho(
|
|
128
|
+
f"Error loading schema config file: {e}", fg="red", err=True
|
|
129
|
+
)
|
|
130
|
+
config_mappings = {}
|
|
131
|
+
|
|
132
|
+
# Handle CLI mappings
|
|
133
|
+
for url, path in list(cli_schema_map.items()):
|
|
134
|
+
self._original_schema_paths[url] = path
|
|
135
|
+
# Resolve relative to CWD for loading
|
|
136
|
+
if not os.path.isabs(path) and not is_valid_url(path):
|
|
137
|
+
cli_schema_map[url] = os.path.abspath(path)
|
|
138
|
+
abs_path = os.path.abspath(cli_schema_map[url])
|
|
139
|
+
self._original_schema_paths[abs_path] = path # <-- This line is critical
|
|
140
|
+
|
|
141
|
+
# Merge config with CLI (CLI wins)
|
|
142
|
+
self.schema_map = {**config_mappings, **cli_schema_map}
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def schema(self) -> str:
|
|
146
|
+
return self._schema
|
|
147
|
+
|
|
148
|
+
@schema.setter
|
|
149
|
+
def schema(self, schema_path: str):
|
|
150
|
+
if self.schema_map:
|
|
151
|
+
if schema_path in self.schema_map:
|
|
152
|
+
schema_path = self.schema_map[schema_path]
|
|
153
|
+
self._schema = schema_path
|
|
154
|
+
|
|
155
|
+
def _format_jsonschema_error_verbose(
|
|
156
|
+
self, error: jsonschema.exceptions.ValidationError
|
|
157
|
+
) -> Dict[str, Any]:
|
|
158
|
+
"""Format a JSON Schema validation error into a detailed dictionary.
|
|
159
|
+
|
|
160
|
+
This method transforms a jsonschema ValidationError into a structured dictionary
|
|
161
|
+
containing comprehensive information about the validation failure, including
|
|
162
|
+
error details, schema information, and relevant paths.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
error: The jsonschema ValidationError to be formatted.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Dict[str, Any]: A dictionary containing detailed information about the
|
|
169
|
+
validation error, including:
|
|
170
|
+
- error_type: Type of validation error
|
|
171
|
+
- detail: Error message
|
|
172
|
+
- validator: The validator that failed
|
|
173
|
+
- path_in_document: Path to the error in the validated document
|
|
174
|
+
- path_in_schema: Path to the error in the schema
|
|
175
|
+
- schema: The relevant schema section that caused the validation failure
|
|
176
|
+
- validator_value: The value that failed validation
|
|
177
|
+
- instance: The actual value that caused the validation failure
|
|
178
|
+
"""
|
|
179
|
+
verbose_details: Dict[str, Any] = {}
|
|
180
|
+
|
|
181
|
+
verbose_details["validator"] = error.validator
|
|
182
|
+
if error.validator_value is not None:
|
|
183
|
+
val_value_repr = repr(error.validator_value)
|
|
184
|
+
if len(val_value_repr) > 800:
|
|
185
|
+
val_value_repr = val_value_repr[:800] + "... (truncated)"
|
|
186
|
+
verbose_details["validator_value"] = val_value_repr
|
|
187
|
+
else:
|
|
188
|
+
verbose_details["validator_value"] = None
|
|
189
|
+
|
|
190
|
+
# if error.instance is not None:
|
|
191
|
+
# instance_repr = repr(error.instance)
|
|
192
|
+
# if len(instance_repr) > 200: # Max length for instance snippet
|
|
193
|
+
# instance_repr = instance_repr[:200] + "... (truncated)"
|
|
194
|
+
# verbose_details["instance_snippet"] = instance_repr
|
|
195
|
+
# else:
|
|
196
|
+
# verbose_details["instance_snippet"] = None
|
|
197
|
+
|
|
198
|
+
if (
|
|
199
|
+
error.schema is not None
|
|
200
|
+
and error.validator is not None
|
|
201
|
+
and isinstance(error.schema, dict)
|
|
202
|
+
and isinstance(error.validator, str)
|
|
203
|
+
):
|
|
204
|
+
verbose_details["schema"] = error.schema.get(error.validator)
|
|
205
|
+
else:
|
|
206
|
+
verbose_details["schema"] = None
|
|
207
|
+
|
|
208
|
+
verbose_details["path_in_document"] = list(error.absolute_path)
|
|
209
|
+
verbose_details["path_in_schema"] = list(error.absolute_schema_path)
|
|
210
|
+
|
|
211
|
+
# if error.context:
|
|
212
|
+
# context_errors_list = []
|
|
213
|
+
# for context_error in sorted(error.context, key=jsonschema.exceptions.relevance):
|
|
214
|
+
# # Recursively format context errors, ensuring best_match is handled if nested
|
|
215
|
+
# best_context_error = best_match([context_error]) if context_error.context else context_error
|
|
216
|
+
# context_errors_list.append(self._format_jsonschema_error_verbose(best_context_error))
|
|
217
|
+
# verbose_details["context_errors"] = context_errors_list
|
|
218
|
+
|
|
219
|
+
return verbose_details
|
|
220
|
+
|
|
221
|
+
def _create_verbose_err_msg(self, error_input: Any) -> Union[Dict[str, Any], str]:
|
|
222
|
+
"""Create a verbose error message from various error input types.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
error_input: The error input to process. Can be a jsonschema ValidationError,
|
|
226
|
+
dictionary, Exception, or any other type that can be converted to string.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Union[Dict[str, Any], str]: A detailed error message. For jsonschema ValidationError,
|
|
230
|
+
returns a dictionary with detailed validation information. For other Exception types,
|
|
231
|
+
returns a dictionary with error type and detail. For dictionaries, returns the
|
|
232
|
+
dictionary as-is. For all other types, returns the string representation.
|
|
233
|
+
"""
|
|
234
|
+
if isinstance(error_input, jsonschema.exceptions.ValidationError):
|
|
235
|
+
return self._format_jsonschema_error_verbose(error_input)
|
|
236
|
+
elif isinstance(error_input, dict):
|
|
237
|
+
return error_input
|
|
238
|
+
elif isinstance(error_input, Exception):
|
|
239
|
+
return {
|
|
240
|
+
"error_type": type(error_input).__name__,
|
|
241
|
+
"detail": str(error_input),
|
|
242
|
+
}
|
|
243
|
+
elif error_input is None:
|
|
244
|
+
return {}
|
|
245
|
+
return str(error_input) # Fallback to string representation
|
|
246
|
+
|
|
247
|
+
def create_err_msg(
|
|
248
|
+
self,
|
|
249
|
+
err_type: str,
|
|
250
|
+
err_msg: str,
|
|
251
|
+
error_obj: Optional[Exception] = None,
|
|
252
|
+
schema_uri: str = "",
|
|
253
|
+
version: Optional[str] = None,
|
|
254
|
+
) -> Dict[str, Union[str, bool, List[str], Dict[str, Any]]]:
|
|
255
|
+
"""
|
|
256
|
+
Create a standardized error message dictionary and mark validation as failed.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
err_type (str): The type of error.
|
|
260
|
+
err_msg (str): The error message.
|
|
261
|
+
error_obj (Optional[Exception]): The raw exception object for verbose details.
|
|
262
|
+
schema_uri (str, optional): The URI of the schema that failed validation.
|
|
263
|
+
version (Optional[str]): Override version to use in the error message.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
dict: Dictionary containing error information.
|
|
267
|
+
"""
|
|
268
|
+
self.valid = False
|
|
269
|
+
|
|
270
|
+
# Ensure all values are of the correct type
|
|
271
|
+
if not isinstance(err_type, str):
|
|
272
|
+
err_type = str(err_type)
|
|
273
|
+
if not isinstance(err_msg, str):
|
|
274
|
+
err_msg = str(err_msg)
|
|
275
|
+
|
|
276
|
+
# Use provided version or fall back to self.version
|
|
277
|
+
version_to_use = (
|
|
278
|
+
version
|
|
279
|
+
if version is not None
|
|
280
|
+
else (str(self.version) if hasattr(self, "version") else "")
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Initialize the message with common fields
|
|
284
|
+
message: Dict[str, Any] = {
|
|
285
|
+
"version": version_to_use,
|
|
286
|
+
"path": str(self.stac_file) if hasattr(self, "stac_file") else "",
|
|
287
|
+
"schema": (
|
|
288
|
+
[self._original_schema_paths.get(self.schema, self.schema)]
|
|
289
|
+
if hasattr(self, "schema")
|
|
290
|
+
else [""]
|
|
291
|
+
),
|
|
292
|
+
"valid_stac": False,
|
|
293
|
+
"error_type": err_type,
|
|
294
|
+
"error_message": err_msg,
|
|
295
|
+
"failed_schema": schema_uri if schema_uri else "",
|
|
296
|
+
"recommendation": "For more accurate error information, rerun with --verbose.",
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
# Add asset_type and validation_method if available
|
|
300
|
+
if hasattr(self, "stac_content"):
|
|
301
|
+
try:
|
|
302
|
+
stac_type = get_stac_type(self.stac_content)
|
|
303
|
+
if stac_type:
|
|
304
|
+
message["asset_type"] = stac_type.upper()
|
|
305
|
+
message["validation_method"] = (
|
|
306
|
+
"recursive"
|
|
307
|
+
if hasattr(self, "recursive") and self.recursive
|
|
308
|
+
else "default"
|
|
309
|
+
)
|
|
310
|
+
except Exception: # noqa: BLE001
|
|
311
|
+
pass
|
|
312
|
+
|
|
313
|
+
# Ensure schema is properly typed
|
|
314
|
+
schema_value: str = ""
|
|
315
|
+
if self.schema is not None:
|
|
316
|
+
schema_value = str(self.schema)
|
|
317
|
+
schema_field: List[str] = [schema_value] if schema_value else []
|
|
318
|
+
|
|
319
|
+
# Initialize the error message with common fields
|
|
320
|
+
error_message: Dict[str, Union[str, bool, List[str], Dict[str, Any]]] = {
|
|
321
|
+
"version": version_to_use,
|
|
322
|
+
"path": str(self.stac_file) if self.stac_file is not None else "",
|
|
323
|
+
"schema": schema_field, # All schemas that were checked
|
|
324
|
+
"valid_stac": False,
|
|
325
|
+
"error_type": err_type,
|
|
326
|
+
"error_message": err_msg,
|
|
327
|
+
"failed_schema": "", # Will be populated if we can determine which schema failed
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
# Try to extract the failed schema from the error message if it's a validation error
|
|
331
|
+
if error_obj and hasattr(error_obj, "schema"):
|
|
332
|
+
if isinstance(error_obj.schema, dict) and "$id" in error_obj.schema:
|
|
333
|
+
error_message["failed_schema"] = error_obj.schema["$id"]
|
|
334
|
+
elif hasattr(error_obj, "schema_url"):
|
|
335
|
+
error_message["failed_schema"] = error_obj.schema_url
|
|
336
|
+
# If we can't find a schema ID, try to get it from the schema map
|
|
337
|
+
elif schema_field and len(schema_field) == 1:
|
|
338
|
+
error_message["failed_schema"] = schema_field[0]
|
|
339
|
+
|
|
340
|
+
if self.verbose and error_obj is not None:
|
|
341
|
+
verbose_err = self._create_verbose_err_msg(error_obj)
|
|
342
|
+
if isinstance(verbose_err, dict):
|
|
343
|
+
error_message["error_verbose"] = verbose_err
|
|
344
|
+
else:
|
|
345
|
+
error_message["error_verbose"] = {"detail": str(verbose_err)}
|
|
346
|
+
# Add recommendation to check the schema if the error is not clear
|
|
347
|
+
if error_message.get("failed_schema"):
|
|
348
|
+
error_message["recommendation"] = (
|
|
349
|
+
"If the error is unclear, please check the schema documentation at: "
|
|
350
|
+
f"{error_message['failed_schema']}"
|
|
351
|
+
)
|
|
352
|
+
else:
|
|
353
|
+
error_message["recommendation"] = (
|
|
354
|
+
"For more accurate error information, rerun with --verbose."
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
return error_message
|
|
358
|
+
|
|
359
|
+
def create_links_message(self) -> Dict:
|
|
360
|
+
"""
|
|
361
|
+
Create an initial links validation message structure.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
dict: An empty validation structure for link checking.
|
|
365
|
+
"""
|
|
366
|
+
format_valid: List = []
|
|
367
|
+
format_invalid: List = []
|
|
368
|
+
request_valid: List = []
|
|
369
|
+
request_invalid: List = []
|
|
370
|
+
return {
|
|
371
|
+
"format_valid": format_valid,
|
|
372
|
+
"format_invalid": format_invalid,
|
|
373
|
+
"request_valid": request_valid,
|
|
374
|
+
"request_invalid": request_invalid,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
def create_message(self, stac_type: str, val_type: str) -> Dict:
|
|
378
|
+
"""
|
|
379
|
+
Create a standardized validation message dictionary.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
stac_type (str): The STAC object type.
|
|
383
|
+
val_type (str): The type of validation (e.g., "default", "core").
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
dict: Dictionary containing general validation information.
|
|
387
|
+
"""
|
|
388
|
+
return {
|
|
389
|
+
"version": self.version,
|
|
390
|
+
"path": self.stac_file,
|
|
391
|
+
"schema": [self._original_schema_paths.get(self.schema, self.schema)],
|
|
392
|
+
"valid_stac": False,
|
|
393
|
+
"asset_type": stac_type.upper(),
|
|
394
|
+
"validation_method": val_type,
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
def assets_validator(self) -> Dict:
|
|
398
|
+
"""
|
|
399
|
+
Validate the 'assets' field in STAC content if present.
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
dict: A dictionary containing the asset validation results.
|
|
403
|
+
"""
|
|
404
|
+
initial_message = self.create_links_message()
|
|
405
|
+
assets = self.stac_content.get("assets")
|
|
406
|
+
if assets:
|
|
407
|
+
for asset in assets.values():
|
|
408
|
+
link_request(
|
|
409
|
+
asset, initial_message, self.assets_open_urls, self.headers
|
|
410
|
+
)
|
|
411
|
+
return initial_message
|
|
412
|
+
|
|
413
|
+
def links_validator(self) -> Dict:
|
|
414
|
+
"""
|
|
415
|
+
Validate the 'links' field in STAC content.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
dict: A dictionary containing the link validation results.
|
|
419
|
+
"""
|
|
420
|
+
initial_message = self.create_links_message()
|
|
421
|
+
root_url = ""
|
|
422
|
+
|
|
423
|
+
# Try to locate a self/alternate link that is a valid URL for root reference
|
|
424
|
+
for link in self.stac_content["links"]:
|
|
425
|
+
if link["rel"] in ["self", "alternate"] and is_valid_url(link["href"]):
|
|
426
|
+
root_url = (
|
|
427
|
+
link["href"].split("/")[0] + "//" + link["href"].split("/")[2]
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
# Validate each link, making it absolute if necessary
|
|
431
|
+
for link in self.stac_content["links"]:
|
|
432
|
+
if not is_valid_url(link["href"]):
|
|
433
|
+
link["href"] = root_url + link["href"][1:]
|
|
434
|
+
link_request(link, initial_message, True, self.headers)
|
|
435
|
+
|
|
436
|
+
return initial_message
|
|
437
|
+
|
|
438
|
+
def custom_validator(self) -> None:
|
|
439
|
+
"""
|
|
440
|
+
Validate a STAC JSON file against a custom or dynamically resolved JSON schema.
|
|
441
|
+
|
|
442
|
+
1. If `self.schema` is a valid URL, fetch and validate.
|
|
443
|
+
2. If it is a local file path, use it.
|
|
444
|
+
3. Otherwise, assume it is a relative path and resolve relative to the STAC file.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
None
|
|
448
|
+
"""
|
|
449
|
+
if is_valid_url(self.schema):
|
|
450
|
+
validate_with_ref_resolver(
|
|
451
|
+
self.schema, self.stac_content, schema_map=self.schema_map
|
|
452
|
+
)
|
|
453
|
+
elif os.path.exists(self.schema):
|
|
454
|
+
validate_with_ref_resolver(
|
|
455
|
+
self.schema, self.stac_content, schema_map=self.schema_map
|
|
456
|
+
)
|
|
457
|
+
else:
|
|
458
|
+
file_directory = os.path.dirname(os.path.abspath(str(self.stac_file)))
|
|
459
|
+
schema = os.path.join(file_directory, self.schema)
|
|
460
|
+
schema = os.path.abspath(os.path.realpath(schema))
|
|
461
|
+
validate_with_ref_resolver(
|
|
462
|
+
schema, self.stac_content, schema_map=self.schema_map
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
def core_validator(self, stac_type: str) -> None:
|
|
466
|
+
"""
|
|
467
|
+
Validate the STAC content against the core schema determined by stac_type and version.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
stac_type (str): The type of the STAC object (e.g., "item", "collection").
|
|
471
|
+
"""
|
|
472
|
+
stac_type = stac_type.lower()
|
|
473
|
+
self.schema = set_schema_addr(self.version, stac_type)
|
|
474
|
+
validate_with_ref_resolver(
|
|
475
|
+
self.schema, self.stac_content, schema_map=self.schema_map
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def extensions_validator(self, stac_type: str) -> Dict:
|
|
479
|
+
"""
|
|
480
|
+
Validate STAC extensions for an ITEM or validate the core schema for a COLLECTION.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
stac_type (str): The STAC object type ("ITEM" or "COLLECTION").
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
dict: A dictionary containing extension (or core) validation results.
|
|
487
|
+
"""
|
|
488
|
+
message = self.create_message(stac_type, "extensions")
|
|
489
|
+
message["schema"] = []
|
|
490
|
+
valid = True
|
|
491
|
+
extension = None
|
|
492
|
+
|
|
493
|
+
try:
|
|
494
|
+
if (
|
|
495
|
+
"stac_extensions" in self.stac_content
|
|
496
|
+
and len(self.stac_content["stac_extensions"]) > 0
|
|
497
|
+
):
|
|
498
|
+
# Handle legacy "proj" to "projection" mapping
|
|
499
|
+
if "proj" in self.stac_content["stac_extensions"]:
|
|
500
|
+
index = self.stac_content["stac_extensions"].index("proj")
|
|
501
|
+
self.stac_content["stac_extensions"][index] = "projection"
|
|
502
|
+
|
|
503
|
+
schemas = self.stac_content["stac_extensions"]
|
|
504
|
+
for extension in schemas:
|
|
505
|
+
if not (is_valid_url(extension) or extension.endswith(".json")):
|
|
506
|
+
if self.version == "1.0.0-beta.2":
|
|
507
|
+
self.stac_content["stac_version"] = "1.0.0-beta.1"
|
|
508
|
+
self.version = self.stac_content["stac_version"]
|
|
509
|
+
extension = (
|
|
510
|
+
f"https://cdn.staclint.com/v{self.version}/extension/"
|
|
511
|
+
f"{extension}.json"
|
|
512
|
+
)
|
|
513
|
+
self.schema = extension
|
|
514
|
+
self.custom_validator()
|
|
515
|
+
display_path = self._original_schema_paths.get(extension, extension)
|
|
516
|
+
message["schema"].append(display_path)
|
|
517
|
+
else:
|
|
518
|
+
self.core_validator(stac_type)
|
|
519
|
+
display_path = self._original_schema_paths.get(self.schema, self.schema)
|
|
520
|
+
message["schema"] = [display_path]
|
|
521
|
+
|
|
522
|
+
except jsonschema.exceptions.ValidationError as e:
|
|
523
|
+
verbose_error = e
|
|
524
|
+
e = extract_relevant_oneof_error(e, self.stac_content)
|
|
525
|
+
if self.recursive:
|
|
526
|
+
raise
|
|
527
|
+
if e.context:
|
|
528
|
+
e = best_match(e.context) # type: ignore
|
|
529
|
+
valid = False
|
|
530
|
+
# Get the current schema (extension) that caused the validation error
|
|
531
|
+
failed_schema = (
|
|
532
|
+
self._original_schema_paths.get(extension, extension)
|
|
533
|
+
if extension
|
|
534
|
+
else "unknown schema"
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# Build the error message with path information
|
|
538
|
+
err_msg = e.message
|
|
539
|
+
path_info = (
|
|
540
|
+
f"Error is in {' -> '.join(map(str, e.absolute_path))} "
|
|
541
|
+
if e.absolute_path
|
|
542
|
+
else ""
|
|
543
|
+
)
|
|
544
|
+
if path_info:
|
|
545
|
+
err_msg = f"{err_msg}. {path_info}"
|
|
546
|
+
|
|
547
|
+
# Create the error message with the original error object
|
|
548
|
+
message = self.create_err_msg(
|
|
549
|
+
err_type="JSONSchemaValidationError",
|
|
550
|
+
err_msg=err_msg,
|
|
551
|
+
error_obj=verbose_error,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
# Set the failed_schema in the message if we have it
|
|
555
|
+
if failed_schema:
|
|
556
|
+
message["failed_schema"] = failed_schema
|
|
557
|
+
return message
|
|
558
|
+
|
|
559
|
+
except Exception as e:
|
|
560
|
+
if self.recursive:
|
|
561
|
+
raise
|
|
562
|
+
valid = False
|
|
563
|
+
# Include the current schema in the error message for other types of exceptions
|
|
564
|
+
current_schema = (
|
|
565
|
+
self._original_schema_paths.get(extension, extension)
|
|
566
|
+
if extension
|
|
567
|
+
else "unknown schema"
|
|
568
|
+
)
|
|
569
|
+
err_msg = f"{e} [Schema: {current_schema}]. Error in Extensions."
|
|
570
|
+
return self.create_err_msg(
|
|
571
|
+
err_type="Exception", err_msg=err_msg, error_obj=e
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
self.valid = valid
|
|
575
|
+
message["valid_stac"] = valid
|
|
576
|
+
return message
|
|
577
|
+
|
|
578
|
+
def default_validator(self, stac_type: str) -> Dict:
|
|
579
|
+
"""
|
|
580
|
+
Validate a STAC Catalog or Item against the core schema and its extensions.
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
stac_type (str): The type of STAC object. Must be "catalog" or "item".
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
dict: A dictionary with results of the default validation.
|
|
587
|
+
"""
|
|
588
|
+
message = self.create_message(stac_type, "default")
|
|
589
|
+
message["schema"] = []
|
|
590
|
+
|
|
591
|
+
# Validate core
|
|
592
|
+
self.core_validator(stac_type)
|
|
593
|
+
core_schema = self.schema
|
|
594
|
+
display_path = self._original_schema_paths.get(core_schema, core_schema)
|
|
595
|
+
if display_path not in message["schema"]:
|
|
596
|
+
message["schema"].append(display_path)
|
|
597
|
+
stac_upper = stac_type.upper()
|
|
598
|
+
|
|
599
|
+
# Validate extensions if ITEM
|
|
600
|
+
if stac_upper == "ITEM" or stac_upper == "COLLECTION":
|
|
601
|
+
message = self.extensions_validator(stac_upper)
|
|
602
|
+
message["validation_method"] = "default"
|
|
603
|
+
display_path = self._original_schema_paths.get(core_schema, core_schema)
|
|
604
|
+
if display_path not in message["schema"]:
|
|
605
|
+
message["schema"].append(display_path)
|
|
606
|
+
|
|
607
|
+
# Optionally validate links
|
|
608
|
+
if self.links:
|
|
609
|
+
message["links_validated"] = self.links_validator()
|
|
610
|
+
|
|
611
|
+
# Optionally validate assets
|
|
612
|
+
if self.assets:
|
|
613
|
+
message["assets_validated"] = self.assets_validator()
|
|
614
|
+
|
|
615
|
+
return message
|
|
616
|
+
|
|
617
|
+
def recursive_validator(self, stac_type: str) -> bool:
|
|
618
|
+
"""
|
|
619
|
+
Recursively validate a STAC JSON document and its children/items.
|
|
620
|
+
|
|
621
|
+
Follows "child" and "item" links, calling the appropriate validator on each.
|
|
622
|
+
Uses pydantic_validator if self.pydantic is True, otherwise uses default_validator.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
stac_type (str): The STAC object type to validate.
|
|
626
|
+
|
|
627
|
+
Returns:
|
|
628
|
+
bool: True if all validations are successful, False otherwise.
|
|
629
|
+
"""
|
|
630
|
+
valid = False
|
|
631
|
+
if not self.skip_val:
|
|
632
|
+
self.schema = set_schema_addr(self.version, stac_type.lower())
|
|
633
|
+
message = self.create_message(stac_type, "recursive")
|
|
634
|
+
message["valid_stac"] = False
|
|
635
|
+
# Add validator_engine field to track validation method
|
|
636
|
+
message["validator_engine"] = "pydantic" if self.pydantic else "jsonschema"
|
|
637
|
+
|
|
638
|
+
try:
|
|
639
|
+
if self.pydantic:
|
|
640
|
+
# Set pydantic model info in schema field
|
|
641
|
+
model_name = f"stac-pydantic {stac_type.capitalize()} model"
|
|
642
|
+
message["schema"] = [model_name]
|
|
643
|
+
# Run pydantic validation
|
|
644
|
+
msg = self.pydantic_validator(stac_type)
|
|
645
|
+
else:
|
|
646
|
+
msg = self.default_validator(stac_type)
|
|
647
|
+
message["schema"] = msg["schema"]
|
|
648
|
+
|
|
649
|
+
except jsonschema.exceptions.ValidationError as e:
|
|
650
|
+
if e.context:
|
|
651
|
+
e = best_match(e.context) # type: ignore
|
|
652
|
+
if e.absolute_path:
|
|
653
|
+
err_msg = (
|
|
654
|
+
f"{e.message}. Error is in "
|
|
655
|
+
f"{' -> '.join([str(i) for i in e.absolute_path])} "
|
|
656
|
+
)
|
|
657
|
+
else:
|
|
658
|
+
err_msg = f"{e.message}"
|
|
659
|
+
message.update(
|
|
660
|
+
self.create_err_msg(
|
|
661
|
+
err_type="JSONSchemaValidationError",
|
|
662
|
+
err_msg=err_msg,
|
|
663
|
+
error_obj=e,
|
|
664
|
+
schema_uri=(
|
|
665
|
+
e.schema.get("$id", "")
|
|
666
|
+
if hasattr(e, "schema") and isinstance(e.schema, dict)
|
|
667
|
+
else ""
|
|
668
|
+
),
|
|
669
|
+
)
|
|
670
|
+
)
|
|
671
|
+
self.message.append(message)
|
|
672
|
+
if self.trace_recursion:
|
|
673
|
+
click.echo(json.dumps(message, indent=4))
|
|
674
|
+
return valid
|
|
675
|
+
except Exception as e:
|
|
676
|
+
if self.pydantic and "pydantic" in str(e.__class__.__module__):
|
|
677
|
+
message.update(
|
|
678
|
+
self.create_err_msg(
|
|
679
|
+
err_type="PydanticValidationError",
|
|
680
|
+
err_msg=str(e),
|
|
681
|
+
error_obj=e,
|
|
682
|
+
)
|
|
683
|
+
)
|
|
684
|
+
self.message.append(message)
|
|
685
|
+
if self.trace_recursion:
|
|
686
|
+
click.echo(json.dumps(message, indent=4))
|
|
687
|
+
return valid
|
|
688
|
+
raise
|
|
689
|
+
|
|
690
|
+
valid = True
|
|
691
|
+
message["valid_stac"] = valid
|
|
692
|
+
self.message.append(message)
|
|
693
|
+
if self.trace_recursion:
|
|
694
|
+
click.echo(json.dumps(message, indent=4))
|
|
695
|
+
|
|
696
|
+
self.depth += 1
|
|
697
|
+
if self.max_depth and self.depth >= self.max_depth:
|
|
698
|
+
self.skip_val = True
|
|
699
|
+
|
|
700
|
+
base_url = self.stac_file
|
|
701
|
+
|
|
702
|
+
child_validity = []
|
|
703
|
+
for link in self.stac_content["links"]:
|
|
704
|
+
if link["rel"] in ("child", "item"):
|
|
705
|
+
address = link["href"]
|
|
706
|
+
if not is_valid_url(address):
|
|
707
|
+
path_parts = str(base_url).split("/")
|
|
708
|
+
path_parts.pop(-1)
|
|
709
|
+
root = path_parts[0]
|
|
710
|
+
for i in range(1, len(path_parts)):
|
|
711
|
+
root = f"{root}/{path_parts[i]}"
|
|
712
|
+
self.stac_file = f"{root}/{address}"
|
|
713
|
+
else:
|
|
714
|
+
self.stac_file = address
|
|
715
|
+
|
|
716
|
+
self.stac_content = fetch_and_parse_file(
|
|
717
|
+
str(self.stac_file), self.headers
|
|
718
|
+
)
|
|
719
|
+
self.stac_content["stac_version"] = self.version
|
|
720
|
+
stac_type = get_stac_type(self.stac_content).lower()
|
|
721
|
+
|
|
722
|
+
if link["rel"] == "child":
|
|
723
|
+
if not self.skip_val:
|
|
724
|
+
valid_child = self.recursive_validator(stac_type)
|
|
725
|
+
child_validity.append(valid_child)
|
|
726
|
+
|
|
727
|
+
if link["rel"] == "item":
|
|
728
|
+
self.schema = set_schema_addr(self.version, stac_type.lower())
|
|
729
|
+
message = self.create_message(stac_type, "recursive")
|
|
730
|
+
message["validator_engine"] = (
|
|
731
|
+
"pydantic" if self.pydantic else "jsonschema"
|
|
732
|
+
)
|
|
733
|
+
try:
|
|
734
|
+
if self.pydantic:
|
|
735
|
+
# Set pydantic model info in schema field for child items
|
|
736
|
+
model_name = f"stac-pydantic {stac_type.capitalize()} model"
|
|
737
|
+
message["schema"] = [model_name]
|
|
738
|
+
# Run pydantic validation
|
|
739
|
+
msg = self.pydantic_validator(stac_type)
|
|
740
|
+
elif self.version == "0.7.0":
|
|
741
|
+
schema = fetch_and_parse_schema(self.schema)
|
|
742
|
+
# Prevent unknown url type issue
|
|
743
|
+
schema["allOf"] = [{}]
|
|
744
|
+
jsonschema.validate(self.stac_content, schema)
|
|
745
|
+
else:
|
|
746
|
+
msg = self.default_validator(stac_type)
|
|
747
|
+
message["schema"] = msg["schema"]
|
|
748
|
+
message["valid_stac"] = True
|
|
749
|
+
except Exception as e:
|
|
750
|
+
if self.pydantic and "pydantic" in str(e.__class__.__module__):
|
|
751
|
+
message.update(
|
|
752
|
+
self.create_err_msg(
|
|
753
|
+
err_type="PydanticValidationError",
|
|
754
|
+
err_msg=str(e),
|
|
755
|
+
error_obj=e,
|
|
756
|
+
)
|
|
757
|
+
)
|
|
758
|
+
message["valid_stac"] = False
|
|
759
|
+
else:
|
|
760
|
+
raise
|
|
761
|
+
|
|
762
|
+
if self.log or (not self.max_depth or self.max_depth < 5):
|
|
763
|
+
self.message.append(message)
|
|
764
|
+
if all(child_validity):
|
|
765
|
+
valid = True
|
|
766
|
+
else:
|
|
767
|
+
valid = False
|
|
768
|
+
return valid
|
|
769
|
+
|
|
770
|
+
def validate_dict(self, stac_content: Dict) -> bool:
|
|
771
|
+
"""
|
|
772
|
+
Validate the contents of a dictionary representing a STAC object.
|
|
773
|
+
|
|
774
|
+
Args:
|
|
775
|
+
stac_content (dict): The dictionary representation of the STAC object.
|
|
776
|
+
|
|
777
|
+
Returns:
|
|
778
|
+
bool: True if validation succeeded, False otherwise.
|
|
779
|
+
"""
|
|
780
|
+
self.stac_content = stac_content
|
|
781
|
+
return self.run()
|
|
782
|
+
|
|
783
|
+
def validate_item_collection_dict(self, item_collection: Dict) -> None:
|
|
784
|
+
"""
|
|
785
|
+
Validate the contents of a STAC Item Collection.
|
|
786
|
+
|
|
787
|
+
Args:
|
|
788
|
+
item_collection (dict): The dictionary representation of the item collection.
|
|
789
|
+
"""
|
|
790
|
+
# Store the original stac_file to restore it later
|
|
791
|
+
original_stac_file = self.stac_file
|
|
792
|
+
|
|
793
|
+
# Use tqdm for progress bar if available and show_progress is True
|
|
794
|
+
if self.show_progress:
|
|
795
|
+
try:
|
|
796
|
+
from tqdm import tqdm
|
|
797
|
+
|
|
798
|
+
items_iter = tqdm(
|
|
799
|
+
item_collection["features"], desc="Validating Items", unit="item"
|
|
800
|
+
)
|
|
801
|
+
except ImportError:
|
|
802
|
+
items_iter = item_collection["features"]
|
|
803
|
+
else:
|
|
804
|
+
items_iter = item_collection["features"]
|
|
805
|
+
|
|
806
|
+
for item in items_iter:
|
|
807
|
+
# Update the path to include the item ID for better traceability
|
|
808
|
+
if isinstance(original_stac_file, str) and "id" in item:
|
|
809
|
+
# Remove any query string from the URL before appending the item ID
|
|
810
|
+
base_url = original_stac_file.split("?")[0]
|
|
811
|
+
self.stac_file = f"{base_url}/{item['id']}"
|
|
812
|
+
|
|
813
|
+
self.schema = ""
|
|
814
|
+
self.validate_dict(item)
|
|
815
|
+
|
|
816
|
+
# Restore the original stac_file
|
|
817
|
+
self.stac_file = original_stac_file
|
|
818
|
+
|
|
819
|
+
def validate_collections(self) -> None:
|
|
820
|
+
"""
|
|
821
|
+
Validate STAC Collections from a /collections endpoint.
|
|
822
|
+
|
|
823
|
+
Raises:
|
|
824
|
+
URLError, JSONDecodeError, ValueError, TypeError, FileNotFoundError,
|
|
825
|
+
ConnectionError, exceptions.SSLError, OSError, KeyError, HTTPError,
|
|
826
|
+
jsonschema.exceptions.ValidationError, Exception: Various errors
|
|
827
|
+
during fetching or parsing.
|
|
828
|
+
"""
|
|
829
|
+
collections = fetch_and_parse_file(str(self.stac_file), self.headers)
|
|
830
|
+
for collection in collections["collections"]:
|
|
831
|
+
self.schema = ""
|
|
832
|
+
self.validate_dict(collection)
|
|
833
|
+
|
|
834
|
+
def validate_item_collection(self) -> None:
|
|
835
|
+
"""
|
|
836
|
+
Validate a STAC Item Collection with optional pagination.
|
|
837
|
+
|
|
838
|
+
Raises:
|
|
839
|
+
URLError, JSONDecodeError, ValueError, TypeError, FileNotFoundError,
|
|
840
|
+
ConnectionError, exceptions.SSLError, OSError, KeyError, HTTPError,
|
|
841
|
+
jsonschema.exceptions.ValidationError, Exception: Various errors
|
|
842
|
+
during fetching or parsing.
|
|
843
|
+
"""
|
|
844
|
+
page = 1
|
|
845
|
+
print(f"processing page {page}")
|
|
846
|
+
item_collection = fetch_and_parse_file(str(self.stac_file), self.headers)
|
|
847
|
+
self.validate_item_collection_dict(item_collection)
|
|
848
|
+
|
|
849
|
+
try:
|
|
850
|
+
if self.pages is not None:
|
|
851
|
+
for _ in range(self.pages - 1):
|
|
852
|
+
if "links" in item_collection:
|
|
853
|
+
for link in item_collection["links"]:
|
|
854
|
+
if link["rel"] == "next":
|
|
855
|
+
page += 1
|
|
856
|
+
print(f"processing page {page}")
|
|
857
|
+
next_link = link["href"]
|
|
858
|
+
self.stac_file = next_link
|
|
859
|
+
item_collection = fetch_and_parse_file(
|
|
860
|
+
str(self.stac_file), self.headers
|
|
861
|
+
)
|
|
862
|
+
self.validate_item_collection_dict(item_collection)
|
|
863
|
+
break
|
|
864
|
+
except Exception as e:
|
|
865
|
+
message = {
|
|
866
|
+
"pagination_error": (
|
|
867
|
+
f"Validating the item collection failed on page {page}: {str(e)}"
|
|
868
|
+
)
|
|
869
|
+
}
|
|
870
|
+
self.message.append(message)
|
|
871
|
+
|
|
872
|
+
def pydantic_validator(self, stac_type: str) -> Dict:
|
|
873
|
+
"""
|
|
874
|
+
Validate STAC content using Pydantic models.
|
|
875
|
+
|
|
876
|
+
Args:
|
|
877
|
+
stac_type (str): The STAC object type (e.g., "ITEM", "COLLECTION", "CATALOG").
|
|
878
|
+
|
|
879
|
+
Returns:
|
|
880
|
+
dict: A dictionary containing validation results.
|
|
881
|
+
"""
|
|
882
|
+
message = self.create_message(stac_type, "pydantic")
|
|
883
|
+
message["schema"] = [""]
|
|
884
|
+
|
|
885
|
+
try:
|
|
886
|
+
# Import dependencies
|
|
887
|
+
from pydantic import ValidationError # type: ignore
|
|
888
|
+
from stac_pydantic import Catalog, Collection, Item # type: ignore
|
|
889
|
+
from stac_pydantic.extensions import validate_extensions # type: ignore
|
|
890
|
+
|
|
891
|
+
# Validate based on STAC type
|
|
892
|
+
if stac_type == "ITEM":
|
|
893
|
+
item_model = Item.model_validate(self.stac_content)
|
|
894
|
+
message["schema"] = ["stac-pydantic Item model"]
|
|
895
|
+
self._validate_extensions(item_model, message, validate_extensions)
|
|
896
|
+
|
|
897
|
+
elif stac_type == "COLLECTION":
|
|
898
|
+
collection_model = Collection.model_validate(self.stac_content)
|
|
899
|
+
message["schema"] = [
|
|
900
|
+
"stac-pydantic Collection model"
|
|
901
|
+
] # Fix applied here
|
|
902
|
+
self._validate_extensions(
|
|
903
|
+
collection_model, message, validate_extensions
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
elif stac_type == "CATALOG":
|
|
907
|
+
Catalog.model_validate(self.stac_content)
|
|
908
|
+
message["schema"] = ["stac-pydantic Catalog model"]
|
|
909
|
+
|
|
910
|
+
else:
|
|
911
|
+
raise ValueError(
|
|
912
|
+
f"Unsupported STAC type for Pydantic validation: {stac_type}"
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
self.valid = True
|
|
916
|
+
message["model_validation"] = "passed"
|
|
917
|
+
|
|
918
|
+
except ValidationError as e:
|
|
919
|
+
self.valid = False
|
|
920
|
+
error_details = [
|
|
921
|
+
f"{' -> '.join(map(str, error.get('loc', [])))}: {error.get('msg', '')}"
|
|
922
|
+
for error in e.errors()
|
|
923
|
+
]
|
|
924
|
+
error_message = f"Pydantic validation failed for {stac_type}: {'; '.join(error_details)}"
|
|
925
|
+
message.update(
|
|
926
|
+
self.create_err_msg(
|
|
927
|
+
err_type="PydanticValidationError",
|
|
928
|
+
err_msg=error_message,
|
|
929
|
+
error_obj=e,
|
|
930
|
+
)
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
except Exception as e:
|
|
934
|
+
self.valid = False
|
|
935
|
+
message.update(
|
|
936
|
+
self.create_err_msg(
|
|
937
|
+
err_type="PydanticValidationError", err_msg=str(e), error_obj=e
|
|
938
|
+
)
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
return message
|
|
942
|
+
|
|
943
|
+
def _validate_extensions(self, model, message: Dict, validate_extensions) -> None:
|
|
944
|
+
"""
|
|
945
|
+
Validate extensions for a given Pydantic model.
|
|
946
|
+
|
|
947
|
+
Args:
|
|
948
|
+
model: The Pydantic model instance.
|
|
949
|
+
message (dict): The validation message dictionary to update.
|
|
950
|
+
validate_extensions: The function to validate extensions.
|
|
951
|
+
"""
|
|
952
|
+
if (
|
|
953
|
+
"stac_extensions" in self.stac_content
|
|
954
|
+
and self.stac_content["stac_extensions"]
|
|
955
|
+
):
|
|
956
|
+
extension_schemas = []
|
|
957
|
+
validate_extensions(model, reraise_exception=True)
|
|
958
|
+
for ext in self.stac_content["stac_extensions"]:
|
|
959
|
+
extension_schemas.append(ext)
|
|
960
|
+
message["extension_schemas"] = extension_schemas
|
|
961
|
+
|
|
962
|
+
def run(self) -> bool:
|
|
963
|
+
"""
|
|
964
|
+
Run the STAC validation process based on the input parameters.
|
|
965
|
+
|
|
966
|
+
Returns:
|
|
967
|
+
bool: True if the STAC is valid, False otherwise.
|
|
968
|
+
|
|
969
|
+
Raises:
|
|
970
|
+
URLError, JSONDecodeError, ValueError, TypeError, FileNotFoundError,
|
|
971
|
+
ConnectionError, exceptions.SSLError, OSError, KeyError, HTTPError,
|
|
972
|
+
jsonschema.exceptions.ValidationError, Exception: Various errors
|
|
973
|
+
during fetching or parsing.
|
|
974
|
+
"""
|
|
975
|
+
message = {}
|
|
976
|
+
try:
|
|
977
|
+
# Fetch STAC content if not provided via item_collection/collections
|
|
978
|
+
if (
|
|
979
|
+
self.stac_file is not None
|
|
980
|
+
and not self.item_collection
|
|
981
|
+
and not self.collections
|
|
982
|
+
):
|
|
983
|
+
self.stac_content = fetch_and_parse_file(self.stac_file, self.headers)
|
|
984
|
+
|
|
985
|
+
stac_type = get_stac_type(self.stac_content).upper()
|
|
986
|
+
version = self.stac_content.get("stac_version", "")
|
|
987
|
+
|
|
988
|
+
# Validate stac_version field comprehensively
|
|
989
|
+
version_valid, version_error_type, version_error_msg = (
|
|
990
|
+
validate_stac_version_field(self.stac_content)
|
|
991
|
+
)
|
|
992
|
+
if not version_valid:
|
|
993
|
+
message.update(
|
|
994
|
+
self.create_err_msg(
|
|
995
|
+
err_type=version_error_type,
|
|
996
|
+
err_msg=version_error_msg,
|
|
997
|
+
schema_uri="",
|
|
998
|
+
version=version, # Pass the version we extracted
|
|
999
|
+
)
|
|
1000
|
+
)
|
|
1001
|
+
self.message.append(message)
|
|
1002
|
+
return self.valid
|
|
1003
|
+
|
|
1004
|
+
self.version = version
|
|
1005
|
+
|
|
1006
|
+
if self.core:
|
|
1007
|
+
message = self.create_message(stac_type, "core")
|
|
1008
|
+
self.core_validator(stac_type)
|
|
1009
|
+
display_path = self._original_schema_paths.get(self.schema, self.schema)
|
|
1010
|
+
message["schema"] = [display_path]
|
|
1011
|
+
self.valid = True
|
|
1012
|
+
|
|
1013
|
+
elif self.schema:
|
|
1014
|
+
message = self.create_message(stac_type, "custom")
|
|
1015
|
+
display_path = self._original_schema_paths.get(self.schema, self.schema)
|
|
1016
|
+
message["schema"] = [display_path]
|
|
1017
|
+
self.custom_validator()
|
|
1018
|
+
self.valid = True
|
|
1019
|
+
|
|
1020
|
+
elif self.recursive:
|
|
1021
|
+
self.valid = self.recursive_validator(stac_type)
|
|
1022
|
+
|
|
1023
|
+
elif self.extensions:
|
|
1024
|
+
message = self.extensions_validator(stac_type)
|
|
1025
|
+
|
|
1026
|
+
elif self.pydantic:
|
|
1027
|
+
message = self.pydantic_validator(stac_type)
|
|
1028
|
+
|
|
1029
|
+
else:
|
|
1030
|
+
self.valid = True
|
|
1031
|
+
message = self.default_validator(stac_type)
|
|
1032
|
+
|
|
1033
|
+
except jsonschema.exceptions.ValidationError as e:
|
|
1034
|
+
# Improve error message for collection field validation
|
|
1035
|
+
err_msg = e.message
|
|
1036
|
+
if (
|
|
1037
|
+
e.validator == "not"
|
|
1038
|
+
and list(e.absolute_path) == ["collection"]
|
|
1039
|
+
and "collection" in self.stac_content
|
|
1040
|
+
):
|
|
1041
|
+
# Check if item has a collection link
|
|
1042
|
+
has_collection_link = False
|
|
1043
|
+
for link in self.stac_content.get("links", []):
|
|
1044
|
+
if link.get("rel") == "collection":
|
|
1045
|
+
has_collection_link = True
|
|
1046
|
+
break
|
|
1047
|
+
|
|
1048
|
+
if not has_collection_link:
|
|
1049
|
+
err_msg = (
|
|
1050
|
+
"Item has a 'collection' field but is missing a link with rel='collection'. "
|
|
1051
|
+
"According to STAC v1.0.0 spec, items with a collection field must have a "
|
|
1052
|
+
"corresponding link. Either remove the 'collection' field or add a link with "
|
|
1053
|
+
"rel='collection' pointing to the collection."
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
if e.absolute_path:
|
|
1057
|
+
err_msg = (
|
|
1058
|
+
f"{err_msg}. Error is in "
|
|
1059
|
+
f"{' -> '.join([str(i) for i in e.absolute_path])} "
|
|
1060
|
+
)
|
|
1061
|
+
message.update(
|
|
1062
|
+
self.create_err_msg(
|
|
1063
|
+
err_type="JSONSchemaValidationError", err_msg=err_msg, error_obj=e
|
|
1064
|
+
)
|
|
1065
|
+
)
|
|
1066
|
+
|
|
1067
|
+
except (
|
|
1068
|
+
URLError,
|
|
1069
|
+
JSONDecodeError,
|
|
1070
|
+
ValueError,
|
|
1071
|
+
TypeError,
|
|
1072
|
+
FileNotFoundError,
|
|
1073
|
+
ConnectionError,
|
|
1074
|
+
exceptions.SSLError,
|
|
1075
|
+
OSError,
|
|
1076
|
+
KeyError,
|
|
1077
|
+
HTTPError,
|
|
1078
|
+
) as e:
|
|
1079
|
+
message.update(
|
|
1080
|
+
self.create_err_msg(
|
|
1081
|
+
err_type=type(e).__name__, err_msg=str(e), error_obj=e
|
|
1082
|
+
)
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
except Exception as e:
|
|
1086
|
+
message.update(
|
|
1087
|
+
self.create_err_msg(err_type="Exception", err_msg=str(e), error_obj=e)
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
if message:
|
|
1091
|
+
message["valid_stac"] = self.valid
|
|
1092
|
+
self.message.append(message)
|
|
1093
|
+
|
|
1094
|
+
# Write out log if path is provided
|
|
1095
|
+
if self.log:
|
|
1096
|
+
with open(self.log, "w") as f:
|
|
1097
|
+
f.write(json.dumps(self.message, indent=4))
|
|
1098
|
+
|
|
1099
|
+
# filter message to only show errors if valid is False unless trace_recursion mode is on
|
|
1100
|
+
if self.recursive and not self.valid and not self.trace_recursion:
|
|
1101
|
+
filtered_messages = []
|
|
1102
|
+
for message in self.message:
|
|
1103
|
+
if not message["valid_stac"]:
|
|
1104
|
+
filtered_messages.append(message)
|
|
1105
|
+
self.message = filtered_messages
|
|
1106
|
+
|
|
1107
|
+
return self.valid
|