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,520 @@
1
+ """Concurrent batch validation of STAC files using multiprocessing."""
2
+
3
+ import concurrent.futures
4
+ import json
5
+ import logging
6
+ import multiprocessing
7
+ from itertools import islice
8
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
9
+
10
+ from .utilities import _build_cached_validator
11
+ from .validate import StacValidate
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def _warm_schema_cache(stac_version: str = "1.0.0") -> None:
17
+ """
18
+ Pre-warm the schema cache before forking worker processes.
19
+
20
+ This function loads and compiles core STAC schemas in the main process.
21
+ When ProcessPoolExecutor forks child processes, they inherit the parent's
22
+ memory via Copy-on-Write (CoW), so all workers get a fully-warmed cache
23
+ without making any network requests.
24
+
25
+ Args:
26
+ stac_version: STAC version to use for schema selection (e.g., "1.0.0" or "1.1.0")
27
+
28
+ Core schemas warmed:
29
+ - STAC Item
30
+ - STAC Collection
31
+ - STAC Catalog
32
+ """
33
+ try:
34
+ import os
35
+
36
+ # Use local schema files bundled with the package
37
+ # Support both v1.0.0 and v1.1.0
38
+ schema_dir = os.path.join(
39
+ os.path.dirname(__file__), "local_schemas", stac_version
40
+ )
41
+
42
+ core_schemas = [
43
+ os.path.join(schema_dir, "item.json"),
44
+ os.path.join(schema_dir, "collection.json"),
45
+ os.path.join(schema_dir, "catalog.json"),
46
+ ]
47
+
48
+ from .utilities import fetch_and_parse_schema
49
+
50
+ # Load and cache each schema
51
+ for schema_path in core_schemas:
52
+ try:
53
+ schema = fetch_and_parse_schema(schema_path)
54
+ # Convert to JSON string and cache via _build_cached_validator
55
+ schema_json = json.dumps(schema, sort_keys=True, separators=(",", ":"))
56
+ _build_cached_validator(schema_json)
57
+ except Exception:
58
+ # If a schema fails to load, continue with others
59
+ pass
60
+
61
+ except Exception:
62
+ # If cache warming fails entirely, continue without it
63
+ pass
64
+
65
+
66
+ def get_optimal_worker_count(max_workers: Optional[int] = None) -> int:
67
+ """
68
+ Get the optimal number of worker processes.
69
+
70
+ Detects available CPU cores, accounting for containerized environments.
71
+ Falls back to os.sched_getaffinity() on Linux for Docker/container support.
72
+
73
+ Args:
74
+ max_workers: Maximum number of workers to use. Options:
75
+ - None or 0: Use all available cores (default)
76
+ - Positive int: Use that many cores (capped at available)
77
+ - Negative int: Use all cores minus that many (e.g., -1 reserves 1 core for OS)
78
+
79
+ Returns:
80
+ Optimal number of worker processes
81
+
82
+ Note:
83
+ In containerized environments (Docker, ECS), this uses os.sched_getaffinity()
84
+ on Linux to detect the actual cores allocated to the container, rather than
85
+ the host machine's total cores.
86
+ """
87
+ import os as os_module
88
+
89
+ # Try to get container-aware core count on Linux
90
+ try:
91
+ if hasattr(os_module, "sched_getaffinity"):
92
+ # Linux: respects Docker/container CPU limits
93
+ total_cores = len(os_module.sched_getaffinity(0))
94
+ else:
95
+ # Fallback for non-Linux systems
96
+ total_cores = multiprocessing.cpu_count()
97
+ except Exception:
98
+ # Fallback if anything goes wrong
99
+ total_cores = multiprocessing.cpu_count()
100
+
101
+ if max_workers is None or max_workers == 0:
102
+ # Use all available cores
103
+ return total_cores
104
+ elif max_workers < 0:
105
+ # Use all cores minus the specified amount (useful for reserving cores for OS)
106
+ return max(1, total_cores + max_workers)
107
+ else:
108
+ # Use the specified number, but cap at available cores
109
+ return min(max_workers, total_cores)
110
+
111
+
112
+ def _validate_single_file(file_path: str) -> Tuple[str, bool, List[str]]:
113
+ """
114
+ Worker function that runs on an individual CPU core.
115
+ Validates a single STAC file and returns results.
116
+
117
+ Each worker process has its own isolated schema cache for optimal performance.
118
+
119
+ Args:
120
+ file_path: Path to the STAC JSON file to validate.
121
+
122
+ Returns:
123
+ Tuple of (file_path, is_valid, list_of_errors)
124
+ """
125
+ errors = []
126
+
127
+ try:
128
+ # Use StacValidate for comprehensive validation (includes version check, core, and extensions)
129
+ # Each worker process has its own isolated schema cache (no need to reconfigure)
130
+ validator = StacValidate(file_path)
131
+ validator.run()
132
+
133
+ # Collect errors from validation
134
+ # validator.message is a list of result objects
135
+ if validator.message:
136
+ try:
137
+ messages = validator.message
138
+ if isinstance(messages, list) and len(messages) > 0:
139
+ msg_obj = messages[0]
140
+ if not msg_obj.get("valid_stac", False):
141
+ # Try multiple error field names
142
+ if "errors" in msg_obj:
143
+ errors.extend(msg_obj["errors"])
144
+ elif "error_message" in msg_obj:
145
+ errors.append(msg_obj["error_message"])
146
+ else:
147
+ errors.append(
148
+ f"{msg_obj.get('error_type', 'ValidationError')}"
149
+ )
150
+ except (KeyError, IndexError, TypeError):
151
+ # If we can't parse, just use the raw message
152
+ if validator.message:
153
+ errors.append(str(validator.message))
154
+
155
+ except Exception as e:
156
+ # Catch any exception including StacValidate instantiation failures
157
+ # This prevents UnboundLocalError if validator creation fails
158
+ return file_path, False, [f"Critical error processing file: {str(e)}"]
159
+
160
+ is_valid = len(errors) == 0
161
+ return file_path, is_valid, errors
162
+
163
+
164
+ def _validate_dict(
165
+ item_dict: Dict[str, Any], source_path: str
166
+ ) -> Tuple[str, bool, Dict[str, Any], str]:
167
+ """
168
+ Worker function that validates a STAC item dictionary directly (no temp files).
169
+
170
+ This function receives pickled dictionaries from the main process, avoiding
171
+ expensive disk I/O. Each worker process has its own isolated schema cache.
172
+
173
+ Args:
174
+ item_dict: Dictionary representation of a STAC item to validate.
175
+ source_path: Display path for result reporting (e.g., "file.json[0]").
176
+
177
+ Returns:
178
+ Tuple of (source_path, is_valid, message_dict, item_id)
179
+ where message_dict is the complete validation result from StacValidate
180
+ """
181
+ item_id = item_dict.get("id", "unknown")
182
+ message = {}
183
+
184
+ try:
185
+ # Validate the dictionary directly without writing to disk
186
+ # StacValidate can accept a dict via validate_dict() method
187
+ validator = StacValidate()
188
+ validator.stac_content = item_dict
189
+
190
+ # Set STAC version from item
191
+ validator.version = item_dict.get("stac_version", "1.0.0")
192
+
193
+ # Run validation on the dictionary
194
+ validator.validate_dict(item_dict)
195
+
196
+ # Get the validation result message
197
+ if validator.message:
198
+ messages = validator.message
199
+ if isinstance(messages, list) and len(messages) > 0:
200
+ message = messages[0]
201
+ else:
202
+ message = messages if isinstance(messages, dict) else {}
203
+
204
+ # Set path to the display path (not a real file)
205
+ message["path"] = source_path
206
+
207
+ except Exception as e:
208
+ # Catch any exception during validation
209
+ message = {
210
+ "path": source_path,
211
+ "valid_stac": False,
212
+ "error_type": "Exception",
213
+ "error_message": f"Critical error processing item: {str(e)}",
214
+ "failed_schema": "",
215
+ }
216
+
217
+ is_valid = message.get("valid_stac", False)
218
+ return source_path, is_valid, message, item_id
219
+
220
+
221
+ def validate_concurrently(
222
+ file_paths: List[str],
223
+ max_workers: Optional[int] = None,
224
+ show_progress: bool = True,
225
+ feature_collection: bool = False,
226
+ batch_size: int = 2000,
227
+ ) -> List[Dict[str, Any]]:
228
+ """
229
+ Validates a list of STAC files concurrently using available CPU cores.
230
+
231
+ Uses ProcessPoolExecutor to bypass Python's GIL by creating separate Python
232
+ processes for each worker. Pre-warms the schema cache in the main process
233
+ before forking, so all workers inherit a fully-populated cache via Copy-on-Write.
234
+
235
+ Args:
236
+ file_paths: List of paths to STAC JSON files or FeatureCollections.
237
+ max_workers: Number of CPU cores to use. Options:
238
+ - None or 0: Use all available cores (default)
239
+ - Positive int: Use that many cores (capped at available)
240
+ - Negative int: Use all cores minus that many (e.g., -1 = all cores - 1)
241
+ show_progress: Whether to display a progress bar (requires tqdm).
242
+ feature_collection: If True, treat files as FeatureCollections and validate each feature.
243
+ batch_size: Number of items to process at a time to bound memory usage (default: 2000).
244
+
245
+ Returns:
246
+ List of result dictionaries with keys: path, valid_stac, errors
247
+ """
248
+ # If feature_collection mode, extract features and delegate to validate_dicts
249
+ # for memory-safe chunked processing
250
+ if feature_collection:
251
+ error_results: List[Dict[str, Any]] = []
252
+
253
+ def item_iter() -> Iterable[Dict[str, Any]]:
254
+ """
255
+ Lazily iterate over all items (features or standalone objects)
256
+ from the provided file_paths, annotating each with source
257
+ metadata. Any file-level read/parse errors are recorded in
258
+ error_results and skipped.
259
+ """
260
+ for file_path in file_paths:
261
+ try:
262
+ with open(file_path, "r") as f:
263
+ data = json.load(f)
264
+
265
+ # Check if it's a FeatureCollection
266
+ if (
267
+ isinstance(data, dict)
268
+ and data.get("type") == "FeatureCollection"
269
+ ):
270
+ features = data.get("features", [])
271
+ for idx, feature in enumerate(features):
272
+ # Attach source info for result display
273
+ feature["__source_file__"] = file_path
274
+ feature["__feature_index__"] = idx
275
+ yield feature
276
+ else:
277
+ # Regular file, treat as single item
278
+ data["__source_file__"] = file_path
279
+ data["__feature_index__"] = None
280
+ yield data
281
+
282
+ except Exception as e:
283
+ # Accumulate error for this file and continue processing others
284
+ error_results.append(
285
+ {
286
+ "path": file_path,
287
+ "valid_stac": False,
288
+ "errors": [f"Failed to read file: {str(e)}"],
289
+ }
290
+ )
291
+
292
+ # Delegate to validate_dicts for chunked, memory-safe processing
293
+ # validate_dicts will detect STAC version from first item and warm cache
294
+ validation_results = validate_dicts(
295
+ item_iter(),
296
+ max_workers=max_workers,
297
+ show_progress=show_progress,
298
+ chunk_size=batch_size,
299
+ )
300
+
301
+ # Combine error results with validation results
302
+ return error_results + validation_results
303
+
304
+ # Standard file path validation (no FeatureCollection expansion)
305
+ results: List[Dict[str, Any]] = []
306
+
307
+ try:
308
+ from tqdm import tqdm
309
+
310
+ has_tqdm = True
311
+ except ImportError:
312
+ has_tqdm = False
313
+ show_progress = False
314
+
315
+ optimal_workers = get_optimal_worker_count(max_workers)
316
+
317
+ if not file_paths:
318
+ return results
319
+
320
+ try:
321
+ with concurrent.futures.ProcessPoolExecutor(
322
+ max_workers=optimal_workers
323
+ ) as executor:
324
+
325
+ # Submit all tasks to the pool
326
+ future_to_file = {
327
+ executor.submit(_validate_single_file, path): path
328
+ for path in file_paths
329
+ }
330
+
331
+ # Wrap the as_completed iterator with tqdm for a nice progress bar
332
+ iterator = concurrent.futures.as_completed(future_to_file)
333
+ if show_progress and has_tqdm:
334
+ iterator = tqdm( # type: ignore
335
+ iterator,
336
+ total=len(file_paths),
337
+ desc="Validating STAC Items",
338
+ )
339
+
340
+ # Collect results as they finish
341
+ for future in iterator:
342
+ file_path, is_valid, errors = future.result()
343
+
344
+ result = {
345
+ "path": file_path,
346
+ "valid_stac": is_valid,
347
+ }
348
+
349
+ if errors:
350
+ result["errors"] = errors
351
+
352
+ results.append(result)
353
+
354
+ finally:
355
+ pass
356
+
357
+ return results
358
+
359
+
360
+ def _chunked_iterable(iterable: Iterable, size: int):
361
+ """Yield successive n-sized chunks from an iterable."""
362
+ it = iter(iterable)
363
+ while True:
364
+ chunk = list(islice(it, size))
365
+ if not chunk:
366
+ break
367
+ yield chunk
368
+
369
+
370
+ def validate_dicts(
371
+ items: Iterable[Dict[str, Any]],
372
+ max_workers: Optional[int] = None,
373
+ show_progress: bool = False,
374
+ chunk_size: int = 2000,
375
+ ) -> List[Dict[str, Any]]:
376
+ """
377
+ Validate an iterable of STAC item dictionaries concurrently.
378
+
379
+ Passes dictionaries directly to worker processes via pickle (memory-based),
380
+ avoiding expensive disk I/O. Pre-warms the schema cache in the main process
381
+ before forking, so all workers inherit a fully-populated cache via Copy-on-Write.
382
+ Processes items in bounded chunks to prevent out-of-memory errors.
383
+
384
+ Args:
385
+ items: Iterable of STAC item dictionaries to validate
386
+ max_workers: Maximum number of worker processes. Options:
387
+ - None or 0: Use all available cores (default)
388
+ - Positive int: Use that many cores (capped at available)
389
+ - Negative int: Use all cores minus that many
390
+ show_progress: Whether to show progress bar (default: False). Set to True for CLI usage.
391
+ chunk_size: Number of items to process at a time to bound memory usage.
392
+
393
+ Returns:
394
+ List of validation results with path, valid_stac, and errors (if any)
395
+ """
396
+ all_results = []
397
+
398
+ try:
399
+ from tqdm import tqdm
400
+
401
+ has_tqdm = True
402
+ except ImportError:
403
+ has_tqdm = False
404
+ show_progress = False
405
+
406
+ optimal_workers = get_optimal_worker_count(max_workers)
407
+
408
+ # Detect STAC version from first item and warm cache accordingly
409
+ stac_version = "1.0.0" # default
410
+ items_list = list(items)
411
+ if items_list:
412
+ first_item = items_list[0]
413
+ stac_version = first_item.get("stac_version", "1.0.0")
414
+
415
+ # Pre-warm the schema cache in the main process before forking workers
416
+ # This leverages Copy-on-Write so all workers inherit the warmed cache
417
+ _warm_schema_cache(stac_version=stac_version)
418
+
419
+ # Create a single executor for the entire run to reuse workers across chunks
420
+ # This preserves the schema cache across chunks and eliminates fork/spawn overhead
421
+ try:
422
+ with concurrent.futures.ProcessPoolExecutor(
423
+ max_workers=optimal_workers
424
+ ) as executor:
425
+ total_items = len(items_list)
426
+ progress_bar = None
427
+
428
+ # Initialize progress bar if available
429
+ if show_progress and has_tqdm:
430
+ progress_bar = tqdm( # type: ignore
431
+ total=total_items,
432
+ desc="Validating Items",
433
+ unit="item",
434
+ )
435
+
436
+ # Process items in chunks: submit a chunk, collect results, then submit next
437
+ # This bounds in-flight futures and memory usage while reusing workers
438
+ for chunk in _chunked_iterable(items_list, chunk_size):
439
+ if not chunk:
440
+ continue
441
+
442
+ # Submit all items in this chunk
443
+ future_to_item = {}
444
+ for idx, item in enumerate(chunk):
445
+ # Extract source metadata if present
446
+ source_file = item.get("__source_file__")
447
+ feature_index = item.get("__feature_index__")
448
+
449
+ # Create display path for results
450
+ if source_file is not None:
451
+ display_path = (
452
+ f"{source_file}[{feature_index}]"
453
+ if feature_index is not None
454
+ else source_file
455
+ )
456
+ else:
457
+ display_path = f"item-{idx}"
458
+
459
+ # Create payload without internal metadata keys
460
+ payload = {
461
+ k: v
462
+ for k, v in item.items()
463
+ if k not in ("__source_file__", "__feature_index__")
464
+ }
465
+
466
+ # Submit to worker - dictionary is pickled automatically
467
+ future = executor.submit(_validate_dict, payload, display_path)
468
+ future_to_item[future] = (display_path, idx)
469
+
470
+ # Collect results for this chunk as they finish
471
+ for future in concurrent.futures.as_completed(future_to_item):
472
+ display_path, idx = future_to_item[future]
473
+ source_path, is_valid, message, item_id = future.result()
474
+
475
+ # Use the complete message from validation
476
+ result = message.copy() if message else {}
477
+
478
+ # Ensure required fields are present
479
+ if "path" not in result:
480
+ result["path"] = source_path
481
+ if "valid_stac" not in result:
482
+ result["valid_stac"] = is_valid
483
+
484
+ # Add item_id for reference
485
+ result["item_id"] = item_id
486
+
487
+ all_results.append(result)
488
+
489
+ # Update progress bar
490
+ if progress_bar:
491
+ progress_bar.update(1)
492
+
493
+ # Close progress bar
494
+ if progress_bar:
495
+ progress_bar.close()
496
+
497
+ except Exception as e:
498
+ # If processing fails, record error for all items
499
+ for idx, item in enumerate(items_list):
500
+ source_file = item.get("__source_file__")
501
+ feature_index = item.get("__feature_index__")
502
+
503
+ if source_file is not None:
504
+ display_path = (
505
+ f"{source_file}[{feature_index}]"
506
+ if feature_index is not None
507
+ else source_file
508
+ )
509
+ else:
510
+ display_path = f"item-{idx}"
511
+
512
+ all_results.append(
513
+ {
514
+ "path": display_path,
515
+ "valid_stac": False,
516
+ "errors": [f"Chunk processing error: {str(e)}"],
517
+ }
518
+ )
519
+
520
+ return all_results