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.
@@ -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