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,312 @@
1
+ import json
2
+ import os
3
+ import time
4
+ import urllib.error
5
+ import urllib.request
6
+ from typing import Any, Dict, List
7
+
8
+ import click
9
+ import fastjsonschema # type: ignore
10
+
11
+ # --- Caches & Config ---
12
+ SCHEMA_CACHE: Dict[str, Any] = {}
13
+ VALIDATOR_CACHE: Dict[Any, Any] = {}
14
+ QUIET_MODE: bool = False
15
+ # Store cached schemas inside the repository under local_schemas/.schemas (project-root relative)
16
+ LOCAL_SCHEMA_DIR = os.path.join(
17
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
18
+ "local_schemas",
19
+ ".schemas",
20
+ )
21
+
22
+
23
+ def get_local_path_for_uri(uri: str) -> str:
24
+ """Creates a safe local filepath for a cached schema URL."""
25
+ safe_filename = uri.replace("https://", "").replace("http://", "").replace("/", "_")
26
+ return os.path.join(LOCAL_SCHEMA_DIR, safe_filename)
27
+
28
+
29
+ def fetch_schema(uri: str) -> Dict[str, Any]:
30
+ """The Ultimate Handler: RAM -> Disk -> Network -> Disk -> RAM"""
31
+
32
+ # 1. RAM Cache
33
+ if uri in SCHEMA_CACHE:
34
+ return SCHEMA_CACHE[uri]
35
+
36
+ local_path = get_local_path_for_uri(uri)
37
+
38
+ # 2. Disk Cache
39
+ if os.path.exists(local_path):
40
+ try:
41
+ with open(local_path, "r") as f:
42
+ schema_dict = json.load(f)
43
+ SCHEMA_CACHE[uri] = schema_dict
44
+ return schema_dict
45
+ except Exception:
46
+ pass # If corrupted, fallback to network
47
+
48
+ # 3. Network Fetch
49
+ if not QUIET_MODE:
50
+ click.secho(f" [Network] Fetching: {uri}", fg="yellow", dim=True)
51
+ req = urllib.request.Request(uri, headers={"User-Agent": "stac-fast-cli/5.0"})
52
+ try:
53
+ with urllib.request.urlopen(req) as response:
54
+ schema_dict = json.loads(response.read().decode("utf-8"))
55
+ except urllib.error.URLError as e:
56
+ raise RuntimeError(f"Could not resolve schema: {uri}. Reason: {e}")
57
+
58
+ # 4. Save to Disk Cache
59
+ os.makedirs(os.path.dirname(local_path), exist_ok=True)
60
+ try:
61
+ with open(local_path, "w") as f:
62
+ json.dump(schema_dict, f)
63
+ except IOError:
64
+ pass # If we can't write to disk, no big deal, keep going
65
+
66
+ # 5. Save to RAM Cache
67
+ SCHEMA_CACHE[uri] = schema_dict
68
+ return schema_dict
69
+
70
+
71
+ def get_validator(stac_type: str, stac_version: str, extensions: List[str]):
72
+ """Builds and caches a validator based on Object Type, Version, and Extensions."""
73
+ ext_key = tuple(sorted(extensions))
74
+ cache_key = (stac_type, stac_version, ext_key)
75
+
76
+ if cache_key in VALIDATOR_CACHE:
77
+ return VALIDATOR_CACHE[cache_key], True
78
+
79
+ # Determine base schema URI
80
+ stac_type_lower = stac_type.lower()
81
+ if stac_type_lower in ["item", "feature"]:
82
+ base_uri = f"https://schemas.stacspec.org/v{stac_version}/item-spec/json-schema/item.json"
83
+ elif stac_type_lower == "collection":
84
+ base_uri = f"https://schemas.stacspec.org/v{stac_version}/collection-spec/json-schema/collection.json"
85
+ elif stac_type_lower == "catalog":
86
+ base_uri = f"https://schemas.stacspec.org/v{stac_version}/catalog-spec/json-schema/catalog.json"
87
+ else:
88
+ raise ValueError(f"Unknown STAC type for validation: {stac_type}")
89
+
90
+ schema_fragments: List[Dict[str, str]] = [{"$ref": base_uri}]
91
+ for ext in extensions:
92
+ schema_fragments.append({"$ref": ext})
93
+ dynamic_schema = {
94
+ "$schema": "http://json-schema.org/draft-07/schema#",
95
+ "allOf": schema_fragments,
96
+ }
97
+
98
+ try:
99
+ validator = fastjsonschema.compile(
100
+ dynamic_schema, handlers={"http": fetch_schema, "https": fetch_schema}
101
+ )
102
+ except Exception:
103
+ # FALLBACK: Some schemas (like Item Assets) cause fastjsonschema to generate invalid python code.
104
+ # We fall back to the standard jsonschema library.
105
+ click.secho(
106
+ " [Fallback] fastjsonschema compile failed. Using python-jsonschema.",
107
+ fg="yellow",
108
+ dim=True,
109
+ )
110
+ import jsonschema
111
+
112
+ # Create a validator using the same custom logic
113
+ def fallback_validator(data):
114
+ # We need a resolver to handle the remote $refs
115
+ resolver = jsonschema.RefResolver(
116
+ base_uri="",
117
+ referrer=dynamic_schema,
118
+ handlers={"http": fetch_schema, "https": fetch_schema},
119
+ )
120
+ jsonschema.validate(data, dynamic_schema, resolver=resolver)
121
+
122
+ validator = fallback_validator
123
+
124
+ VALIDATOR_CACHE[cache_key] = validator
125
+ return validator, False
126
+
127
+
128
+ class FastValidator:
129
+ def __init__(self, stac_file: str, quiet: bool = False, verbose: bool = False):
130
+ global QUIET_MODE
131
+ self.stac_file = stac_file
132
+ self.quiet = quiet
133
+ self.valid = True
134
+ self.verbose = verbose
135
+ QUIET_MODE = quiet
136
+
137
+ def run(self):
138
+ """Universal high-speed STAC Validator (Items, Collections, Catalogs, FeatureCollections)"""
139
+ if not self.quiet:
140
+ click.secho(f"\nšŸ“‚ Loading: {self.stac_file}", fg="blue", bold=True)
141
+
142
+ try:
143
+ if self.stac_file.startswith("http"):
144
+ req = urllib.request.Request(
145
+ self.stac_file, headers={"User-Agent": "stac-fast-cli/5.0"}
146
+ )
147
+ with urllib.request.urlopen(req) as response:
148
+ data = json.loads(response.read().decode("utf-8"))
149
+ else:
150
+ with open(self.stac_file, "r") as f:
151
+ data = json.load(f)
152
+ except Exception as e:
153
+ click.secho(f"āŒ Error reading {self.stac_file}: {e}", fg="red", bold=True)
154
+ self.valid = False
155
+ return
156
+
157
+ # Detect payload structure
158
+ obj_type = data.get("type", "")
159
+ items_to_validate = []
160
+
161
+ if obj_type == "FeatureCollection":
162
+ items_to_validate = data.get("features", [])
163
+ if not self.quiet:
164
+ click.secho(
165
+ f"šŸ“¦ Detected FeatureCollection ({len(items_to_validate)} Items)\n",
166
+ fg="cyan",
167
+ )
168
+ elif obj_type == "Feature":
169
+ items_to_validate = [data]
170
+ if not self.quiet:
171
+ click.secho("šŸ“„ Detected: STAC Item\n", fg="cyan")
172
+ elif obj_type == "Collection":
173
+ items_to_validate = [data]
174
+ if not self.quiet:
175
+ click.secho("šŸ“š Detected: STAC Collection\n", fg="cyan")
176
+ elif obj_type == "Catalog" or ("id" in data and "description" in data):
177
+ # Fallback for old catalogs missing the 'type' field
178
+ data["type"] = "Catalog"
179
+ items_to_validate = [data]
180
+ if not self.quiet:
181
+ click.secho("šŸ—‚ļø Detected: STAC Catalog\n", fg="cyan")
182
+ else:
183
+ if "type" in data:
184
+ click.secho(
185
+ f"āŒ Unknown JSON type. Unsupported 'type' value: {obj_type!r}.",
186
+ fg="red",
187
+ bold=True,
188
+ )
189
+ else:
190
+ click.secho(
191
+ "āŒ Unknown JSON type. Missing 'type' field.", fg="red", bold=True
192
+ )
193
+ self.valid = False
194
+ return
195
+
196
+ # --- Metrics ---
197
+ total_setup_ms = 0.0
198
+ total_exec_ms = 0.0
199
+ valid_count = 0
200
+ invalid_count = 0
201
+ error_registry: Dict[str, List[str]] = {}
202
+
203
+ for index, item in enumerate(items_to_validate):
204
+ # Determine specific STAC attributes for this object
205
+ item_id = item.get("id", f"unknown-{index}")
206
+ stac_version = item.get("stac_version", "1.0.0")
207
+ extensions = item.get("stac_extensions", [])
208
+
209
+ # Map Feature->Item, others keep their type
210
+ actual_type = (
211
+ "Item" if item.get("type") == "Feature" else item.get("type", "Catalog")
212
+ )
213
+
214
+ # --- Setup Timer ---
215
+ t0 = time.perf_counter()
216
+ try:
217
+ validator, is_cached = get_validator(
218
+ actual_type, stac_version, extensions
219
+ )
220
+ except Exception as e:
221
+ if not self.quiet:
222
+ click.secho(f"āŒ Setup failed for {item_id}: {e}", fg="red")
223
+ invalid_count += 1
224
+ self.valid = False
225
+ continue
226
+ t1 = time.perf_counter()
227
+ setup_time = (t1 - t0) * 1000
228
+ total_setup_ms += setup_time
229
+
230
+ # --- Execution Timer ---
231
+ t2 = time.perf_counter()
232
+ try:
233
+ validator(item)
234
+ t3 = time.perf_counter()
235
+ exec_time = (t3 - t2) * 1000
236
+ total_exec_ms += exec_time
237
+ valid_count += 1
238
+ status_text = click.style("āœ… VALID", fg="green")
239
+
240
+ except fastjsonschema.JsonSchemaValueException as e:
241
+ t3 = time.perf_counter()
242
+ exec_time = (t3 - t2) * 1000
243
+ total_exec_ms += exec_time
244
+ invalid_count += 1
245
+ self.valid = False
246
+
247
+ # --- The STAC Error Translator ---
248
+ error_msg = f"{e.name} {e.message.replace(e.name, '').strip()}"
249
+ if "disallowed definition" in error_msg:
250
+ if "collection" in error_msg:
251
+ error_msg = "STAC Spec Violation: Missing {'rel': 'collection'} in links array."
252
+ else:
253
+ error_msg = (
254
+ f"{e.name} violated a 'not' rule. Value: {repr(e.value)}"
255
+ )
256
+
257
+ # Group errors
258
+ if error_msg not in error_registry:
259
+ error_registry[error_msg] = []
260
+ error_registry[error_msg].append(item_id)
261
+ status_text = click.style("āŒ INVALID", fg="red")
262
+
263
+ if not self.quiet:
264
+ if self.verbose or index < 5 or (len(items_to_validate) < 20):
265
+ cache_icon = "⚔" if is_cached else "🐌"
266
+ click.echo(
267
+ f"[{index + 1}] ID: {item_id} | Type: {actual_type} | Cache {cache_icon} | Setup: {setup_time:>6.2f}ms | Exec: {exec_time:>5.2f}ms | {status_text}"
268
+ )
269
+ elif index == 5:
270
+ click.secho(
271
+ "... silencing output for remaining items (validating at maximum speed) ...",
272
+ dim=True,
273
+ )
274
+
275
+ # --- Summary Report ---
276
+ click.echo("\n" + "=" * 55)
277
+ click.secho("šŸ“Š VALIDATION SUMMARY", bold=True)
278
+ click.echo("=" * 55)
279
+ click.echo(f"Total Objects Processed : {len(items_to_validate)}")
280
+ click.echo(
281
+ f"Valid Objects : {click.style(str(valid_count), fg='green')}"
282
+ )
283
+
284
+ invalid_color = "red" if invalid_count > 0 else "green"
285
+ click.echo(
286
+ f"Invalid Objects : {click.style(str(invalid_count), fg=invalid_color)}"
287
+ )
288
+
289
+ click.echo("-" * 55)
290
+ click.echo(f"Total Setup Time : {total_setup_ms:.2f} ms")
291
+ click.echo(f"Total Execution Time : {total_exec_ms:.2f} ms")
292
+ if len(items_to_validate) > 0:
293
+ click.echo(
294
+ f"Average Exec per Object : {(total_exec_ms / len(items_to_validate)):.3f} ms"
295
+ )
296
+
297
+ if invalid_count > 0:
298
+ click.echo("=" * 55)
299
+ click.secho("🚨 ERROR BREAKDOWN", bold=True, fg="red")
300
+ click.echo("=" * 55)
301
+ for err_msg, affected_ids in error_registry.items():
302
+ count = len(affected_ids)
303
+ click.echo(f"\nāŒ {click.style(err_msg, fg='yellow', bold=True)}")
304
+ click.echo(
305
+ f" Affected Items: {click.style(str(count), fg='red', bold=True)}"
306
+ )
307
+ sample_ids = ", ".join(affected_ids[:3])
308
+ if count > 3:
309
+ sample_ids += f" ... (and {count - 3} more)"
310
+ click.echo(f" Examples: {sample_ids}")
311
+
312
+ click.echo("\n")