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,574 @@
1
+ import json
2
+ import sys
3
+ import time
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+
6
+ import click # type: ignore
7
+
8
+ from .batch_validator import validate_concurrently
9
+ from .fast_validator import FastValidator
10
+ from .utilities import set_schema_cache_size
11
+ from .validate import StacValidate
12
+
13
+
14
+ def _print_summary(
15
+ title: str, valid_count: int, total_count: int, obj_type: str = "STAC objects"
16
+ ) -> None:
17
+ """Helper function to print a consistent summary line.
18
+
19
+ Args:
20
+ title (str): Title of the summary section
21
+ valid_count (int): Number of valid items
22
+ total_count (int): Total number of items
23
+ obj_type (str): Type of objects being counted (e.g., 'items', 'collections')
24
+ """
25
+ click.secho()
26
+ click.secho(f"{title}:", bold=True)
27
+ if total_count > 0:
28
+ percentage = (valid_count / total_count) * 100
29
+ click.secho(
30
+ f" {obj_type.capitalize()} passed: {valid_count}/{total_count} ({percentage:.1f}%)"
31
+ )
32
+ else:
33
+ click.secho(f" No {obj_type} found to validate")
34
+
35
+
36
+ def format_duration(seconds: float) -> str:
37
+ """Format duration in seconds to a human-readable string.
38
+
39
+ Args:
40
+ seconds (float): Duration in seconds
41
+
42
+ Returns:
43
+ str: Formatted duration string (e.g., '1m 23.45s' or '456.78ms')
44
+ """
45
+ if seconds < 1.0:
46
+ return f"{seconds * 1000:.2f}ms"
47
+ minutes, seconds = divmod(seconds, 60)
48
+ if minutes > 0:
49
+ return f"{int(minutes)}m {seconds:.2f}s"
50
+ return f"{seconds:.2f}s"
51
+
52
+
53
+ def print_update_message(version: str) -> None:
54
+ """Prints an update message for `stac-validator` based on the version of the
55
+ STAC file being validated.
56
+
57
+ Args:
58
+ version (str): The version of the STAC file being validated.
59
+
60
+ Returns:
61
+ None
62
+ """
63
+ click.secho()
64
+ if version != "1.1.0":
65
+ click.secho(
66
+ f"Please upgrade from version {version} to version 1.1.0!", fg="red"
67
+ )
68
+ else:
69
+ click.secho("Thanks for using STAC version 1.1.0!", fg="green")
70
+ click.secho()
71
+
72
+
73
+ def item_collection_summary(message: List[Dict[str, Any]]) -> None:
74
+ """Prints a summary of the validation results for an item collection response.
75
+
76
+ Args:
77
+ message (List[Dict[str, Any]]): The validation results for the item collection.
78
+
79
+ Returns:
80
+ None
81
+ """
82
+ valid_count = sum(1 for item in message if item.get("valid_stac") is True)
83
+ _print_summary("-- Item Collection Summary", valid_count, len(message), "items")
84
+
85
+
86
+ def collections_summary(message: List[Dict[str, Any]]) -> None:
87
+ """Prints a summary of the validation results for a collections response.
88
+
89
+ Args:
90
+ message (List[Dict[str, Any]]): The validation results for the collections.
91
+
92
+ Returns:
93
+ None
94
+ """
95
+ valid_count = sum(1 for coll in message if coll.get("valid_stac") is True)
96
+ _print_summary("-- Collections Summary", valid_count, len(message), "collections")
97
+
98
+
99
+ def recursive_validation_summary(message: List[Dict[str, Any]]) -> None:
100
+ """Prints a summary of the recursive validation results.
101
+
102
+ Args:
103
+ message (List[Dict[str, Any]]): The validation results from recursive validation.
104
+
105
+ Returns:
106
+ None
107
+ """
108
+ # Count valid and total objects by type
109
+ type_counts = {}
110
+ total_valid = 0
111
+
112
+ for item in message:
113
+ if not isinstance(item, dict):
114
+ continue
115
+
116
+ obj_type = item.get("asset_type", "unknown").lower()
117
+ is_valid = item.get("valid_stac", False) is True
118
+
119
+ if obj_type not in type_counts:
120
+ type_counts[obj_type] = {"valid": 0, "total": 0}
121
+
122
+ type_counts[obj_type]["total"] += 1
123
+ if is_valid:
124
+ type_counts[obj_type]["valid"] += 1
125
+ total_valid += 1
126
+
127
+ # Print overall summary
128
+ _print_summary("-- Recursive Validation Summary", total_valid, len(message))
129
+
130
+ # Print breakdown by type if there are multiple types
131
+ if len(type_counts) > 1:
132
+ click.secho("\n Breakdown by type:")
133
+ for obj_type, counts in sorted(type_counts.items()):
134
+ percentage = (
135
+ (counts["valid"] / counts["total"]) * 100 if counts["total"] > 0 else 0
136
+ )
137
+ click.secho(
138
+ f" {obj_type.capitalize()}: {counts['valid']}/{counts['total']} ({percentage:.1f}%)"
139
+ )
140
+
141
+
142
+ @click.command()
143
+ @click.argument("stac_file")
144
+ @click.option(
145
+ "--core", is_flag=True, help="Validate core stac object only without extensions."
146
+ )
147
+ @click.option("--extensions", is_flag=True, help="Validate extensions only.")
148
+ @click.option(
149
+ "--links",
150
+ is_flag=True,
151
+ help="Additionally validate links. Only works with default mode.",
152
+ )
153
+ @click.option(
154
+ "--assets",
155
+ is_flag=True,
156
+ help="Additionally validate assets. Only works with default mode.",
157
+ )
158
+ @click.option(
159
+ "--custom",
160
+ "-c",
161
+ default="",
162
+ help="Validate against a custom schema (local filepath or remote schema).",
163
+ )
164
+ @click.option(
165
+ "--schema-config",
166
+ "-sc",
167
+ default="",
168
+ help="Validate against a custom schema config (local filepath or remote schema config).",
169
+ )
170
+ @click.option(
171
+ "--schema-map",
172
+ "-s",
173
+ type=(str, str),
174
+ multiple=True,
175
+ help="Schema path to replaced by (local) schema path during validation. Can be used multiple times.",
176
+ )
177
+ @click.option(
178
+ "--recursive",
179
+ "-r",
180
+ is_flag=True,
181
+ help="Recursively validate all related stac objects.",
182
+ )
183
+ @click.option(
184
+ "--max-depth",
185
+ "-m",
186
+ type=int,
187
+ help="Maximum depth to traverse when recursing. Omit this argument to get full recursion. Ignored if `recursive == False`.",
188
+ )
189
+ @click.option(
190
+ "--collections",
191
+ is_flag=True,
192
+ help="Validate /collections response.",
193
+ )
194
+ @click.option(
195
+ "--item-collection",
196
+ "--feature-collection",
197
+ is_flag=True,
198
+ help="Validate item collection response. Can be combined with --pages. Defaults to one page.",
199
+ )
200
+ @click.option(
201
+ "--no-assets-urls",
202
+ is_flag=True,
203
+ help="Disables the opening of href links when validating assets (enabled by default).",
204
+ )
205
+ @click.option(
206
+ "--header",
207
+ type=(str, str),
208
+ multiple=True,
209
+ help="HTTP header to include in the requests. Can be used multiple times.",
210
+ )
211
+ @click.option(
212
+ "--pages",
213
+ "-p",
214
+ type=int,
215
+ help="Maximum number of pages to validate via --item-collection. Defaults to one page.",
216
+ )
217
+ @click.option(
218
+ "-t",
219
+ "--trace-recursion",
220
+ is_flag=True,
221
+ help="Enables verbose output for recursive mode.",
222
+ )
223
+ @click.option("--no_output", is_flag=True, help="Do not print output to console.")
224
+ @click.option(
225
+ "--log_file",
226
+ default="",
227
+ help="Save full recursive output to log file (local filepath).",
228
+ )
229
+ @click.option(
230
+ "--pydantic",
231
+ is_flag=True,
232
+ help="Validate using stac-pydantic models for enhanced type checking and validation.",
233
+ )
234
+ @click.option(
235
+ "--verbose",
236
+ is_flag=True,
237
+ help="Enable verbose output. This will output additional information during validation.",
238
+ )
239
+ @click.option(
240
+ "--schema-cache-size",
241
+ type=int,
242
+ default=None,
243
+ help="Max number of schema entries to cache in memory. Defaults to 16.",
244
+ )
245
+ def main(
246
+ stac_file: str,
247
+ collections: bool,
248
+ item_collection: bool,
249
+ no_assets_urls: bool,
250
+ header: list,
251
+ pages: int,
252
+ recursive: bool,
253
+ max_depth: int,
254
+ core: bool,
255
+ extensions: bool,
256
+ links: bool,
257
+ assets: bool,
258
+ custom: str,
259
+ schema_config: str,
260
+ schema_map: List[Tuple],
261
+ trace_recursion: bool,
262
+ no_output: bool,
263
+ log_file: str,
264
+ pydantic: bool,
265
+ verbose: bool = False,
266
+ schema_cache_size: Optional[int] = None,
267
+ ):
268
+ """Validate a STAC file against the STAC specification.
269
+
270
+ Prints validation results to the console as JSON.
271
+ Exits with status code 0 if valid, 1 if invalid.
272
+ """
273
+ start_time = time.time()
274
+ valid = True
275
+
276
+ if schema_map == ():
277
+ schema_map_dict: Optional[Dict[str, str]] = None
278
+ else:
279
+ schema_map_dict = dict(schema_map)
280
+
281
+ if schema_cache_size is not None:
282
+ if schema_cache_size < 0:
283
+ raise click.BadParameter(
284
+ "must be greater than or equal to 0",
285
+ param_hint="--schema-cache-size",
286
+ )
287
+ set_schema_cache_size(schema_cache_size)
288
+
289
+ stac = StacValidate(
290
+ stac_file=stac_file,
291
+ collections=collections,
292
+ item_collection=item_collection,
293
+ pages=pages,
294
+ recursive=recursive,
295
+ max_depth=max_depth,
296
+ core=core,
297
+ links=links,
298
+ assets=assets,
299
+ assets_open_urls=not no_assets_urls,
300
+ headers=dict(header),
301
+ extensions=extensions,
302
+ custom=custom,
303
+ schema_config=schema_config,
304
+ schema_map=schema_map_dict,
305
+ trace_recursion=trace_recursion,
306
+ log=log_file,
307
+ pydantic=pydantic,
308
+ verbose=verbose,
309
+ show_progress=True,
310
+ )
311
+
312
+ try:
313
+ if not item_collection and not collections:
314
+ valid = stac.run()
315
+ elif collections:
316
+ stac.validate_collections()
317
+ else:
318
+ stac.validate_item_collection()
319
+
320
+ message = stac.message
321
+ if "version" in message[0]:
322
+ print_update_message(message[0]["version"])
323
+
324
+ if no_output is False:
325
+ click.echo(json.dumps(message, indent=4))
326
+
327
+ # Print appropriate summary based on validation mode
328
+ if item_collection:
329
+ item_collection_summary(message)
330
+ elif collections:
331
+ collections_summary(message)
332
+ elif recursive:
333
+ recursive_validation_summary(message)
334
+
335
+ finally:
336
+ # Always print the duration, even if validation fails
337
+ duration = time.time() - start_time
338
+ click.secho(
339
+ f"\nValidation completed in {format_duration(duration)}", fg="green"
340
+ )
341
+ click.secho()
342
+ sys.exit(0 if valid else 1)
343
+
344
+
345
+ @click.command()
346
+ @click.argument("files", nargs=-1, required=True, type=click.Path(exists=True))
347
+ @click.option(
348
+ "--cores",
349
+ type=int,
350
+ default=None,
351
+ help="Number of CPU cores to use for parallel validation. Defaults to all available cores.",
352
+ )
353
+ @click.option(
354
+ "--no-progress",
355
+ is_flag=True,
356
+ help="Disable progress bar during validation.",
357
+ )
358
+ @click.option(
359
+ "--no-output",
360
+ is_flag=True,
361
+ help="Do not print output to console.",
362
+ )
363
+ @click.option(
364
+ "--item-collection",
365
+ "--feature-collection",
366
+ is_flag=True,
367
+ help="Treat files as ItemCollections and validate each item individually.",
368
+ )
369
+ @click.option(
370
+ "--verbose",
371
+ is_flag=True,
372
+ help="Show full JSON output for all items. By default, only invalid items are shown.",
373
+ )
374
+ @click.option(
375
+ "--schema-cache-size",
376
+ type=int,
377
+ default=16,
378
+ help="Max number of schema entries to cache per worker process. Defaults to 16.",
379
+ )
380
+ @click.option(
381
+ "--batch-size",
382
+ type=click.IntRange(min=1),
383
+ default=2000,
384
+ help="Batch size for chunked processing. Larger batches use more memory but may be faster. Defaults to 2000.",
385
+ )
386
+ def batch(
387
+ files: Tuple[str, ...],
388
+ cores: Optional[int],
389
+ no_progress: bool,
390
+ no_output: bool,
391
+ item_collection: bool,
392
+ verbose: bool,
393
+ schema_cache_size: int,
394
+ batch_size: int,
395
+ ):
396
+ """Validate multiple STAC files concurrently using all available CPU cores.
397
+
398
+ This command uses multiprocessing to validate STAC files in parallel,
399
+ bypassing Python's Global Interpreter Lock (GIL) for maximum performance.
400
+ Each CPU core gets its own schema cache, which is warmed up on the first
401
+ file and reused for subsequent files.
402
+
403
+ Examples:
404
+
405
+ # Validate all JSON files in a directory
406
+ stac-validator batch *.json
407
+
408
+ # Validate specific files
409
+ stac-validator batch file1.json file2.json file3.json
410
+
411
+ # Use only 4 cores
412
+ stac-validator batch *.json --cores 4
413
+
414
+ # Disable progress bar
415
+ stac-validator batch *.json --no-progress
416
+ """
417
+ if not files:
418
+ click.secho("Error: No files provided", fg="red")
419
+ sys.exit(1)
420
+
421
+ start_time = time.time()
422
+
423
+ try:
424
+ # Configure schema cache size BEFORE spawning workers
425
+ # This ensures all worker processes inherit the configured cache size
426
+ if schema_cache_size != 16:
427
+ set_schema_cache_size(schema_cache_size)
428
+
429
+ # Get optimal worker count
430
+ from .batch_validator import get_optimal_worker_count
431
+
432
+ optimal_workers = get_optimal_worker_count(cores)
433
+
434
+ # Validate concurrently
435
+ results = validate_concurrently(
436
+ list(files),
437
+ max_workers=cores,
438
+ show_progress=not no_progress,
439
+ feature_collection=item_collection,
440
+ batch_size=batch_size,
441
+ )
442
+
443
+ # Calculate statistics
444
+ valid_count = sum(1 for r in results if r.get("valid_stac", False))
445
+ invalid_count = len(results) - valid_count
446
+
447
+ # Print details of failed validations first (only in non-verbose mode)
448
+ if not no_output and invalid_count > 0 and not verbose:
449
+ click.secho("Failed validations:", fg="red")
450
+
451
+ # Group errors by (message, schema) and collect item IDs
452
+ from collections import defaultdict
453
+
454
+ error_groups = defaultdict(list)
455
+
456
+ for result in results:
457
+ if not result.get("valid_stac", False):
458
+ # Try to get item ID from result, fallback to path
459
+ item_id = result.get("item_id", result.get("path", "unknown"))
460
+
461
+ # Handle both old format (errors array) and new format (error_message/failed_schema)
462
+ if "errors" in result:
463
+ # Old format: errors array with dict entries
464
+ for error in result["errors"]:
465
+ if isinstance(error, dict):
466
+ error_msg = error.get("message", str(error))
467
+ error_schema = error.get("schema", "")
468
+ error_key = (error_msg, error_schema)
469
+ else:
470
+ error_key = (str(error), "")
471
+ error_groups[error_key].append(item_id)
472
+ elif "error_message" in result:
473
+ # New format: error_message and failed_schema fields
474
+ error_msg = result.get("error_message", "")
475
+ error_schema = result.get("failed_schema", "")
476
+ error_key = (error_msg, error_schema)
477
+ error_groups[error_key].append(item_id)
478
+
479
+ # Print grouped errors with compact formatting
480
+ for idx, ((error_msg, error_schema), item_ids) in enumerate(
481
+ sorted(error_groups.items())
482
+ ):
483
+ # Add blank line between error sections (but not before the first one)
484
+ if idx > 0:
485
+ click.secho()
486
+
487
+ # Format item IDs compactly (max 5 per line)
488
+ if len(item_ids) <= 5:
489
+ ids_str = ", ".join(str(id) for id in item_ids)
490
+ click.secho(f" [{ids_str}]", fg="red")
491
+ else:
492
+ # For many items, show first few and count
493
+ first_few = ", ".join(str(id) for id in item_ids[:5])
494
+ remaining = len(item_ids) - 5
495
+ click.secho(f" [{first_few}, ... +{remaining} more]", fg="red")
496
+
497
+ # Display error message
498
+ click.secho(f" - {error_msg}", fg="yellow")
499
+
500
+ # Display schema if available
501
+ if error_schema:
502
+ click.secho(f" Schema: {error_schema}", fg="cyan")
503
+
504
+ # Print full JSON output in verbose mode
505
+ if not no_output and verbose:
506
+ click.echo(json.dumps(results, indent=4))
507
+
508
+ # Print summary at the bottom
509
+ click.secho()
510
+ click.secho("Validation Summary:", bold=True)
511
+ click.secho(f" Total files: {len(results)}")
512
+ click.secho(f" CPU cores used: {optimal_workers}")
513
+ click.secho(f" ✅ Valid: {valid_count}")
514
+ click.secho(f" ❌ Invalid: {invalid_count}")
515
+
516
+ duration = time.time() - start_time
517
+ click.secho()
518
+ click.secho(f"Validation completed in {format_duration(duration)}", fg="green")
519
+ click.secho()
520
+
521
+ sys.exit(0 if invalid_count == 0 else 1)
522
+
523
+ except Exception as e:
524
+ click.secho(f"Error during batch validation: {e}", fg="red")
525
+ sys.exit(1)
526
+
527
+
528
+ @click.command()
529
+ @click.argument("stac_file")
530
+ @click.option(
531
+ "--quiet",
532
+ "-q",
533
+ is_flag=True,
534
+ help="Suppress individual item logs.",
535
+ )
536
+ @click.option(
537
+ "--verbose",
538
+ "-v",
539
+ is_flag=True,
540
+ help="Show full validation logs for all items. By default, a limited sample of item logs is shown.",
541
+ )
542
+ def fast(stac_file: str, quiet: bool, verbose: bool):
543
+ """High-speed validation using fastjsonschema and local caching."""
544
+ try:
545
+ fv = FastValidator(stac_file, quiet=quiet, verbose=verbose)
546
+ fv.run()
547
+ sys.exit(0 if fv.valid else 1)
548
+ except RuntimeError as e:
549
+ click.secho(f"\n🚨 FATAL ERROR: {e}", fg="red", bold=True)
550
+ sys.exit(1)
551
+
552
+
553
+ @click.group()
554
+ def cli():
555
+ """STAC Validator - Validate STAC files against the STAC specification.
556
+
557
+ \b
558
+ Usage:
559
+ stac-validator validate <file> [options]
560
+ stac-validator batch <files> [options]
561
+ stac-validator batch <file> --feature-collection [options]
562
+ stac-validator fast <file> [options]
563
+ """
564
+ pass
565
+
566
+
567
+ # Register commands
568
+ cli.add_command(main, name="validate")
569
+ cli.add_command(batch, name="batch")
570
+ cli.add_command(fast, name="fast")
571
+
572
+
573
+ if __name__ == "__main__":
574
+ cli()