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,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")
|