openmapstack 0.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.
- openmapstack/__init__.py +6 -0
- openmapstack/__main__.py +3 -0
- openmapstack/checks/__init__.py +112 -0
- openmapstack/checks/geodata.py +318 -0
- openmapstack/checks/overrides.py +263 -0
- openmapstack/checks/presentation.py +168 -0
- openmapstack/checks/project.py +261 -0
- openmapstack/checks/provenance.py +133 -0
- openmapstack/checks/qgis.py +832 -0
- openmapstack/checks/rerun.py +340 -0
- openmapstack/checks/spatial.py +65 -0
- openmapstack/checks/validation.py +288 -0
- openmapstack/checks/visual.py +642 -0
- openmapstack/cli.py +431 -0
- openmapstack/expectations.py +284 -0
- openmapstack/integrity.py +137 -0
- openmapstack/project.py +79 -0
- openmapstack/rerun.py +332 -0
- openmapstack/schema.py +39 -0
- openmapstack/schemas/__init__.py +1 -0
- openmapstack/schemas/project-v1.schema.json +264 -0
- openmapstack/validation.py +1019 -0
- openmapstack/verify.py +386 -0
- openmapstack-0.2.0.dist-info/METADATA +268 -0
- openmapstack-0.2.0.dist-info/RECORD +29 -0
- openmapstack-0.2.0.dist-info/WHEEL +5 -0
- openmapstack-0.2.0.dist-info/entry_points.txt +2 -0
- openmapstack-0.2.0.dist-info/licenses/LICENSE +21 -0
- openmapstack-0.2.0.dist-info/top_level.txt +1 -0
openmapstack/rerun.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Clean-rerun protocol for an ``openmapstack-project/v1`` artifact.
|
|
2
|
+
|
|
3
|
+
Rebuild a project in an empty workspace from only its manifest, its declared
|
|
4
|
+
immutable inputs, and its declared dependencies -- then execute the one
|
|
5
|
+
canonical entrypoint and revalidate what it produced.
|
|
6
|
+
|
|
7
|
+
This is the strongest correctness signal available on data nobody has a known
|
|
8
|
+
answer for. It needs no oracle: a pipeline that cannot reproduce itself from
|
|
9
|
+
source plus manifest is untrustworthy whatever its numbers say, and one that
|
|
10
|
+
mutates its own declared-immutable inputs is not reproducible at all.
|
|
11
|
+
|
|
12
|
+
Two callers share this: ``evals/run.py`` (case ``clean_rerun: {}``) and
|
|
13
|
+
``openmapstack verify --rerun``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import shlex
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Sequence
|
|
28
|
+
|
|
29
|
+
import yaml
|
|
30
|
+
|
|
31
|
+
from .validation import validate_project
|
|
32
|
+
|
|
33
|
+
CLEAN_RERUN_EVIDENCE = ".openmapstack-clean-rerun.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _output_text(value: str | bytes | None) -> str:
|
|
37
|
+
if value is None:
|
|
38
|
+
return ""
|
|
39
|
+
if isinstance(value, bytes):
|
|
40
|
+
return value.decode("utf-8", errors="replace")
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _execute_argv(command: list[str], cwd: Path, timeout_s: int | float, env: dict[str, str]) -> dict[str, Any]:
|
|
45
|
+
"""Execute a shell-free canonical project entrypoint."""
|
|
46
|
+
started = time.monotonic()
|
|
47
|
+
try:
|
|
48
|
+
proc = subprocess.run(
|
|
49
|
+
command,
|
|
50
|
+
shell=False,
|
|
51
|
+
cwd=cwd,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
timeout=timeout_s,
|
|
55
|
+
env=env,
|
|
56
|
+
)
|
|
57
|
+
return {
|
|
58
|
+
"command": command,
|
|
59
|
+
"cwd": str(cwd),
|
|
60
|
+
"returncode": proc.returncode,
|
|
61
|
+
"timed_out": False,
|
|
62
|
+
"duration_s": time.monotonic() - started,
|
|
63
|
+
"stdout": proc.stdout,
|
|
64
|
+
"stderr": proc.stderr,
|
|
65
|
+
}
|
|
66
|
+
except subprocess.TimeoutExpired as exc:
|
|
67
|
+
return {
|
|
68
|
+
"command": command,
|
|
69
|
+
"cwd": str(cwd),
|
|
70
|
+
"returncode": None,
|
|
71
|
+
"timed_out": True,
|
|
72
|
+
"timeout_s": timeout_s,
|
|
73
|
+
"duration_s": time.monotonic() - started,
|
|
74
|
+
"stdout": _output_text(exc.stdout),
|
|
75
|
+
"stderr": _output_text(exc.stderr),
|
|
76
|
+
}
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
return {
|
|
79
|
+
"command": command,
|
|
80
|
+
"cwd": str(cwd),
|
|
81
|
+
"returncode": None,
|
|
82
|
+
"timed_out": False,
|
|
83
|
+
"duration_s": time.monotonic() - started,
|
|
84
|
+
"stdout": "",
|
|
85
|
+
"stderr": f"{type(exc).__name__}: {exc}",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _safe_project_path(project_root: Path, value: Any, field_name: str) -> tuple[Path, Path]:
|
|
90
|
+
if not isinstance(value, str) or not value.strip():
|
|
91
|
+
raise ValueError(f"{field_name} must be a non-empty project-relative path")
|
|
92
|
+
relative = Path(value)
|
|
93
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
94
|
+
raise ValueError(f"{field_name} escapes the project: {value!r}")
|
|
95
|
+
target = (project_root / relative).resolve()
|
|
96
|
+
try:
|
|
97
|
+
normalized = target.relative_to(project_root.resolve())
|
|
98
|
+
except ValueError as exc:
|
|
99
|
+
raise ValueError(f"{field_name} escapes the project: {value!r}") from exc
|
|
100
|
+
return target, normalized
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _hash_immutable_inputs(rerun_root: Path, preserved: set[str]) -> dict[str, str]:
|
|
104
|
+
"""Real sha256 of every immutable source/override file actually on disk.
|
|
105
|
+
|
|
106
|
+
Only ``data/source/`` and ``data/overrides/`` are covered: these are the
|
|
107
|
+
only paths the spec declares immutable. The canonical entrypoint is
|
|
108
|
+
expected to write/replace files elsewhere (derived outputs, reports,
|
|
109
|
+
run records); it must never touch these two trees.
|
|
110
|
+
"""
|
|
111
|
+
hashes: dict[str, str] = {}
|
|
112
|
+
for relative in sorted(preserved):
|
|
113
|
+
if not (relative == "data/source" or relative == "data/overrides" or relative.startswith("data/source/") or relative.startswith("data/overrides/")):
|
|
114
|
+
continue
|
|
115
|
+
target = rerun_root / relative
|
|
116
|
+
if target.is_dir():
|
|
117
|
+
for file_path in sorted(target.rglob("*")):
|
|
118
|
+
if file_path.is_file():
|
|
119
|
+
digest = hashlib.sha256(file_path.read_bytes()).hexdigest()
|
|
120
|
+
hashes[str(file_path.relative_to(rerun_root).as_posix())] = f"sha256:{digest}"
|
|
121
|
+
elif target.is_file():
|
|
122
|
+
digest = hashlib.sha256(target.read_bytes()).hexdigest()
|
|
123
|
+
hashes[relative] = f"sha256:{digest}"
|
|
124
|
+
return hashes
|
|
125
|
+
|
|
126
|
+
def _copy_clean_rerun_path(
|
|
127
|
+
project_root: Path,
|
|
128
|
+
rerun_root: Path,
|
|
129
|
+
value: str,
|
|
130
|
+
field_name: str,
|
|
131
|
+
preserved: set[str],
|
|
132
|
+
) -> None:
|
|
133
|
+
source, relative = _safe_project_path(project_root, value, field_name)
|
|
134
|
+
relative_text = relative.as_posix()
|
|
135
|
+
if relative_text in preserved:
|
|
136
|
+
return
|
|
137
|
+
if not source.exists():
|
|
138
|
+
raise ValueError(f"declared clean-rerun dependency does not exist: {value}")
|
|
139
|
+
paths = [source]
|
|
140
|
+
if source.is_dir():
|
|
141
|
+
paths.extend(source.rglob("*"))
|
|
142
|
+
if any(path.is_symlink() for path in paths):
|
|
143
|
+
raise ValueError(f"clean-rerun dependency may not contain symlinks: {value}")
|
|
144
|
+
|
|
145
|
+
destination = rerun_root / relative
|
|
146
|
+
if source.is_dir():
|
|
147
|
+
shutil.copytree(source, destination, dirs_exist_ok=True)
|
|
148
|
+
else:
|
|
149
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
shutil.copy2(source, destination)
|
|
151
|
+
preserved.add(relative_text)
|
|
152
|
+
|
|
153
|
+
def canonical_rerun_command(
|
|
154
|
+
project_root: Path,
|
|
155
|
+
project: dict[str, Any],
|
|
156
|
+
*,
|
|
157
|
+
forbidden_fragments: Sequence[str] = (),
|
|
158
|
+
) -> tuple[list[str], list[tuple[str, str]]]:
|
|
159
|
+
"""Resolve the one canonical entrypoint plus the paths a rerun must keep.
|
|
160
|
+
|
|
161
|
+
``forbidden_fragments`` lets a caller reject commands that reach back into
|
|
162
|
+
machinery a clean rerun must not depend on. The eval runner passes its own
|
|
163
|
+
generator paths; nothing in the package knows about ``evals/``.
|
|
164
|
+
"""
|
|
165
|
+
runtime = project.get("runtime")
|
|
166
|
+
implementation = runtime.get("implementation") if isinstance(runtime, dict) else None
|
|
167
|
+
if not isinstance(implementation, dict):
|
|
168
|
+
raise ValueError("runtime.implementation is missing")
|
|
169
|
+
|
|
170
|
+
preserve: list[tuple[str, str]] = []
|
|
171
|
+
dependencies = implementation.get("dependencies") or []
|
|
172
|
+
if not isinstance(dependencies, list) or not all(isinstance(item, str) and item.strip() for item in dependencies):
|
|
173
|
+
raise ValueError("runtime.implementation.dependencies must be a list of paths")
|
|
174
|
+
preserve.extend((dependency, f"runtime.implementation.dependencies[{index}]") for index, dependency in enumerate(dependencies))
|
|
175
|
+
|
|
176
|
+
declared_command = implementation.get("command")
|
|
177
|
+
if declared_command is not None:
|
|
178
|
+
if isinstance(declared_command, str):
|
|
179
|
+
command = shlex.split(declared_command)
|
|
180
|
+
elif isinstance(declared_command, list) and all(isinstance(item, str) and item for item in declared_command):
|
|
181
|
+
command = list(declared_command)
|
|
182
|
+
else:
|
|
183
|
+
raise ValueError("runtime.implementation.command must be a string or list of strings")
|
|
184
|
+
if not command:
|
|
185
|
+
raise ValueError("runtime.implementation.command is empty")
|
|
186
|
+
|
|
187
|
+
for index, token in enumerate(command):
|
|
188
|
+
for fragment in forbidden_fragments:
|
|
189
|
+
if fragment and fragment in token:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"canonical command depends on excluded machinery: {fragment!r}"
|
|
192
|
+
)
|
|
193
|
+
token_path = Path(token)
|
|
194
|
+
if index > 0 and token_path.is_absolute():
|
|
195
|
+
raise ValueError(f"canonical command argument must not use an absolute path: {token!r}")
|
|
196
|
+
if ".." in token_path.parts:
|
|
197
|
+
raise ValueError(f"canonical command must not escape the project: {token!r}")
|
|
198
|
+
if not token.startswith("-") and not token_path.is_absolute():
|
|
199
|
+
candidate = project_root / token_path
|
|
200
|
+
if candidate.exists():
|
|
201
|
+
preserve.append((token, f"runtime.implementation.command[{index}]"))
|
|
202
|
+
return command, preserve
|
|
203
|
+
|
|
204
|
+
pipeline = implementation.get("pipeline")
|
|
205
|
+
pipeline_path, relative = _safe_project_path(project_root, pipeline, "runtime.implementation.pipeline")
|
|
206
|
+
if not pipeline_path.is_file():
|
|
207
|
+
raise ValueError(f"canonical pipeline does not exist: {pipeline!r}")
|
|
208
|
+
preserve.append((relative.as_posix(), "runtime.implementation.pipeline"))
|
|
209
|
+
if pipeline_path.suffix.lower() == ".py":
|
|
210
|
+
return [sys.executable, relative.as_posix()], preserve
|
|
211
|
+
if pipeline_path.stat().st_mode & 0o111:
|
|
212
|
+
executable = relative.as_posix()
|
|
213
|
+
return [executable if executable.startswith("./") else f"./{executable}"], preserve
|
|
214
|
+
raise ValueError("non-Python canonical pipeline is not executable and declares no command")
|
|
215
|
+
|
|
216
|
+
def _clean_rerun_environment() -> tuple[dict[str, str], list[str]]:
|
|
217
|
+
env = dict(os.environ)
|
|
218
|
+
sensitive_fragments = (
|
|
219
|
+
"ANTHROPIC",
|
|
220
|
+
"CHAT",
|
|
221
|
+
"CLAUDE",
|
|
222
|
+
"CODEX",
|
|
223
|
+
"CONVERSATION",
|
|
224
|
+
"OPENAI",
|
|
225
|
+
"PROMPT",
|
|
226
|
+
"TRANSCRIPT",
|
|
227
|
+
)
|
|
228
|
+
removed = sorted(key for key in env if any(fragment in key.upper() for fragment in sensitive_fragments))
|
|
229
|
+
for key in removed:
|
|
230
|
+
env.pop(key, None)
|
|
231
|
+
env.pop("PYTHONPATH", None)
|
|
232
|
+
env["OPENMAPSTACK_CLEAN_RERUN"] = "1"
|
|
233
|
+
return env, removed
|
|
234
|
+
|
|
235
|
+
def _write_clean_rerun_evidence(rerun_root: Path, evidence: dict[str, Any]) -> None:
|
|
236
|
+
(rerun_root / CLEAN_RERUN_EVIDENCE).write_text(json.dumps(evidence, indent=2, default=str), encoding="utf-8")
|
|
237
|
+
|
|
238
|
+
def perform_clean_rerun(
|
|
239
|
+
project_root: Path,
|
|
240
|
+
rerun_root: Path,
|
|
241
|
+
timeout_s: int | float,
|
|
242
|
+
*,
|
|
243
|
+
forbidden_fragments: Sequence[str] = (),
|
|
244
|
+
) -> dict[str, Any]:
|
|
245
|
+
"""Rebuild a project from its manifest, local immutable inputs, and declared dependencies."""
|
|
246
|
+
evidence: dict[str, Any] = {
|
|
247
|
+
"schema": "openmapstack-clean-rerun/v1",
|
|
248
|
+
"status": "failed",
|
|
249
|
+
"stage": "preparation",
|
|
250
|
+
"preserved_paths": [],
|
|
251
|
+
"excluded_artifact_classes": [
|
|
252
|
+
"derived_outputs",
|
|
253
|
+
"validation_reports",
|
|
254
|
+
"run_records",
|
|
255
|
+
"caches",
|
|
256
|
+
"presentation_artifacts",
|
|
257
|
+
"conversation_state",
|
|
258
|
+
],
|
|
259
|
+
}
|
|
260
|
+
preserved: set[str] = set()
|
|
261
|
+
try:
|
|
262
|
+
manifest_path = project_root / "project.yaml"
|
|
263
|
+
if not manifest_path.is_file():
|
|
264
|
+
raise ValueError("project.yaml is missing")
|
|
265
|
+
try:
|
|
266
|
+
project = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
|
|
267
|
+
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
|
268
|
+
raise ValueError(f"project.yaml cannot be loaded: {exc}") from exc
|
|
269
|
+
if not isinstance(project, dict):
|
|
270
|
+
raise ValueError("project.yaml must contain a mapping")
|
|
271
|
+
|
|
272
|
+
command, declared_paths = canonical_rerun_command(
|
|
273
|
+
project_root, project, forbidden_fragments=forbidden_fragments
|
|
274
|
+
)
|
|
275
|
+
_copy_clean_rerun_path(project_root, rerun_root, "project.yaml", "project manifest", preserved)
|
|
276
|
+
for conventional_path in ("data/source", "data/overrides"):
|
|
277
|
+
if (project_root / conventional_path).exists():
|
|
278
|
+
_copy_clean_rerun_path(
|
|
279
|
+
project_root,
|
|
280
|
+
rerun_root,
|
|
281
|
+
conventional_path,
|
|
282
|
+
f"clean-rerun input {conventional_path}",
|
|
283
|
+
preserved,
|
|
284
|
+
)
|
|
285
|
+
for path, field_name in declared_paths:
|
|
286
|
+
_copy_clean_rerun_path(project_root, rerun_root, path, field_name, preserved)
|
|
287
|
+
|
|
288
|
+
evidence["preserved_paths"] = sorted(preserved)
|
|
289
|
+
source_hashes_before = _hash_immutable_inputs(rerun_root, preserved)
|
|
290
|
+
evidence["command"] = command
|
|
291
|
+
env, removed_environment = _clean_rerun_environment()
|
|
292
|
+
evidence["removed_environment_keys"] = removed_environment
|
|
293
|
+
execution = _execute_argv(command, rerun_root, timeout_s, env)
|
|
294
|
+
evidence["execution"] = execution
|
|
295
|
+
if execution.get("timed_out"):
|
|
296
|
+
evidence["stage"] = "canonical_execution"
|
|
297
|
+
evidence["error"] = f"canonical entrypoint timed out after {timeout_s}s"
|
|
298
|
+
_write_clean_rerun_evidence(rerun_root, evidence)
|
|
299
|
+
return evidence
|
|
300
|
+
if execution.get("returncode") != 0:
|
|
301
|
+
evidence["stage"] = "canonical_execution"
|
|
302
|
+
evidence["error"] = f"canonical entrypoint exited with status {execution.get('returncode')}"
|
|
303
|
+
_write_clean_rerun_evidence(rerun_root, evidence)
|
|
304
|
+
return evidence
|
|
305
|
+
|
|
306
|
+
evidence["stage"] = "source_integrity"
|
|
307
|
+
source_hashes_after = _hash_immutable_inputs(rerun_root, preserved)
|
|
308
|
+
mutated = sorted(relative for relative, digest in source_hashes_before.items() if source_hashes_after.get(relative) != digest)
|
|
309
|
+
evidence["source_hashes"] = source_hashes_after
|
|
310
|
+
if mutated:
|
|
311
|
+
evidence["error"] = f"canonical entrypoint mutated declared-immutable source/override files: {mutated}"
|
|
312
|
+
evidence["mutated_source_files"] = mutated
|
|
313
|
+
_write_clean_rerun_evidence(rerun_root, evidence)
|
|
314
|
+
return evidence
|
|
315
|
+
|
|
316
|
+
evidence["stage"] = "artifact_validation"
|
|
317
|
+
validation = validate_project(rerun_root / "project.yaml", artifacts=True)
|
|
318
|
+
evidence["artifact_validation"] = validation.to_dict()
|
|
319
|
+
if not validation.ok():
|
|
320
|
+
evidence["error"] = "post-rerun artifact validation failed"
|
|
321
|
+
_write_clean_rerun_evidence(rerun_root, evidence)
|
|
322
|
+
return evidence
|
|
323
|
+
|
|
324
|
+
evidence["status"] = "passed"
|
|
325
|
+
evidence["stage"] = "complete"
|
|
326
|
+
_write_clean_rerun_evidence(rerun_root, evidence)
|
|
327
|
+
return evidence
|
|
328
|
+
except (OSError, ValueError) as exc:
|
|
329
|
+
evidence["preserved_paths"] = sorted(preserved)
|
|
330
|
+
evidence["error"] = f"{type(exc).__name__}: {exc}"
|
|
331
|
+
_write_clean_rerun_evidence(rerun_root, evidence)
|
|
332
|
+
return evidence
|
openmapstack/schema.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""JSON Schema loading and validation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from jsonschema import Draft202012Validator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _json_value(value: Any) -> Any:
|
|
14
|
+
if isinstance(value, (date, datetime)):
|
|
15
|
+
return value.isoformat()
|
|
16
|
+
if isinstance(value, dict):
|
|
17
|
+
return {key: _json_value(item) for key, item in value.items()}
|
|
18
|
+
if isinstance(value, list):
|
|
19
|
+
return [_json_value(item) for item in value]
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_packaged_schema(name: str) -> dict[str, Any]:
|
|
24
|
+
resource = files("openmapstack.schemas").joinpath(name)
|
|
25
|
+
return json.loads(resource.read_text(encoding="utf-8"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validation_errors(instance: Any, schema: dict[str, Any]) -> list[str]:
|
|
29
|
+
validator = Draft202012Validator(schema)
|
|
30
|
+
errors = sorted(validator.iter_errors(_json_value(instance)), key=lambda item: list(item.path))
|
|
31
|
+
formatted: list[str] = []
|
|
32
|
+
for error in errors:
|
|
33
|
+
location = ".".join(str(part) for part in error.absolute_path) or "$"
|
|
34
|
+
formatted.append(f"{location}: {error.message}")
|
|
35
|
+
return formatted
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def project_schema_errors(project: Any) -> list[str]:
|
|
39
|
+
return validation_errors(project, load_packaged_schema("project-v1.schema.json"))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Packaged schemas for OpenMapStack contracts."""
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openmapstack/schemas/project-v1.schema.json",
|
|
4
|
+
"title": "OpenMapStack project manifest v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": [
|
|
7
|
+
"schema", "project", "interpretation", "sources", "overrides",
|
|
8
|
+
"processing", "outputs", "validation", "presentation", "runtime", "runs"
|
|
9
|
+
],
|
|
10
|
+
"$defs": {
|
|
11
|
+
"digest": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"pattern": "^(sha256:)?[0-9a-fA-F]{64}$"
|
|
14
|
+
},
|
|
15
|
+
"fieldIdentifier": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
|
|
18
|
+
},
|
|
19
|
+
"safeProjectPath": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"minLength": 1,
|
|
22
|
+
"pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+"
|
|
23
|
+
},
|
|
24
|
+
"jsonScalar": {
|
|
25
|
+
"type": ["string", "number", "boolean", "null"]
|
|
26
|
+
},
|
|
27
|
+
"expectationAttestation": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"required": ["status"],
|
|
30
|
+
"additionalProperties": false,
|
|
31
|
+
"properties": {
|
|
32
|
+
"status": {"enum": ["unverified", "verified"]},
|
|
33
|
+
"reason": {"type": "string", "minLength": 1},
|
|
34
|
+
"verified_by": {"type": "string", "minLength": 1},
|
|
35
|
+
"verified_against": {"type": "string", "minLength": 1},
|
|
36
|
+
"verified_at": {"type": "string", "format": "date-time"},
|
|
37
|
+
"evidence_path": {"$ref": "#/$defs/safeProjectPath"},
|
|
38
|
+
"evidence_sha256": {"$ref": "#/$defs/digest"},
|
|
39
|
+
"expectation_sha256": {"$ref": "#/$defs/digest"},
|
|
40
|
+
"inputs_hash": {"$ref": "#/$defs/digest"}
|
|
41
|
+
},
|
|
42
|
+
"allOf": [
|
|
43
|
+
{
|
|
44
|
+
"if": {"properties": {"status": {"const": "verified"}}},
|
|
45
|
+
"then": {
|
|
46
|
+
"required": [
|
|
47
|
+
"verified_by", "verified_against", "verified_at",
|
|
48
|
+
"evidence_sha256", "expectation_sha256", "inputs_hash"
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
"expectation": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"required": ["id", "check", "args", "attestation"],
|
|
57
|
+
"additionalProperties": false,
|
|
58
|
+
"properties": {
|
|
59
|
+
"id": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
|
62
|
+
},
|
|
63
|
+
"check": {
|
|
64
|
+
"enum": [
|
|
65
|
+
"geodata.row_count",
|
|
66
|
+
"geodata.feature_present",
|
|
67
|
+
"geodata.feature_absent",
|
|
68
|
+
"geodata.feature_field_equals",
|
|
69
|
+
"geodata.field_range"
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
"args": {"type": "object"},
|
|
73
|
+
"attestation": {"$ref": "#/$defs/expectationAttestation"}
|
|
74
|
+
},
|
|
75
|
+
"allOf": [
|
|
76
|
+
{
|
|
77
|
+
"if": {"properties": {"check": {"const": "geodata.row_count"}}},
|
|
78
|
+
"then": {
|
|
79
|
+
"properties": {
|
|
80
|
+
"args": {
|
|
81
|
+
"type": "object",
|
|
82
|
+
"required": ["path"],
|
|
83
|
+
"additionalProperties": false,
|
|
84
|
+
"properties": {
|
|
85
|
+
"path": {"$ref": "#/$defs/safeProjectPath"},
|
|
86
|
+
"equals": {"type": "integer", "minimum": 0},
|
|
87
|
+
"at_least": {"type": "integer", "minimum": 0},
|
|
88
|
+
"at_most": {"type": "integer", "minimum": 0}
|
|
89
|
+
},
|
|
90
|
+
"anyOf": [
|
|
91
|
+
{"required": ["equals"]},
|
|
92
|
+
{"required": ["at_least"]},
|
|
93
|
+
{"required": ["at_most"]}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"if": {
|
|
101
|
+
"properties": {
|
|
102
|
+
"check": {"enum": ["geodata.feature_present", "geodata.feature_absent"]}
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
"then": {
|
|
106
|
+
"properties": {
|
|
107
|
+
"args": {
|
|
108
|
+
"type": "object",
|
|
109
|
+
"required": ["path", "id_field", "id"],
|
|
110
|
+
"additionalProperties": false,
|
|
111
|
+
"properties": {
|
|
112
|
+
"path": {"$ref": "#/$defs/safeProjectPath"},
|
|
113
|
+
"id_field": {"$ref": "#/$defs/fieldIdentifier"},
|
|
114
|
+
"id": {"$ref": "#/$defs/jsonScalar"}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"if": {"properties": {"check": {"const": "geodata.feature_field_equals"}}},
|
|
122
|
+
"then": {
|
|
123
|
+
"properties": {
|
|
124
|
+
"args": {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"required": ["path", "id_field", "id", "field", "equals"],
|
|
127
|
+
"additionalProperties": false,
|
|
128
|
+
"properties": {
|
|
129
|
+
"path": {"$ref": "#/$defs/safeProjectPath"},
|
|
130
|
+
"id_field": {"$ref": "#/$defs/fieldIdentifier"},
|
|
131
|
+
"id": {"$ref": "#/$defs/jsonScalar"},
|
|
132
|
+
"field": {"$ref": "#/$defs/fieldIdentifier"},
|
|
133
|
+
"equals": {"$ref": "#/$defs/jsonScalar"}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"if": {"properties": {"check": {"const": "geodata.field_range"}}},
|
|
141
|
+
"then": {
|
|
142
|
+
"properties": {
|
|
143
|
+
"args": {
|
|
144
|
+
"type": "object",
|
|
145
|
+
"required": ["path", "field"],
|
|
146
|
+
"additionalProperties": false,
|
|
147
|
+
"properties": {
|
|
148
|
+
"path": {"$ref": "#/$defs/safeProjectPath"},
|
|
149
|
+
"field": {"$ref": "#/$defs/fieldIdentifier"},
|
|
150
|
+
"min": {"type": "number"},
|
|
151
|
+
"max": {"type": "number"}
|
|
152
|
+
},
|
|
153
|
+
"anyOf": [
|
|
154
|
+
{"required": ["min"]},
|
|
155
|
+
{"required": ["max"]}
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
"properties": {
|
|
165
|
+
"schema": {"const": "openmapstack-project/v1"},
|
|
166
|
+
"project": {
|
|
167
|
+
"type": "object",
|
|
168
|
+
"required": ["id", "title", "question", "created_at", "updated_at", "status"],
|
|
169
|
+
"properties": {
|
|
170
|
+
"id": {"type": "string", "minLength": 1},
|
|
171
|
+
"title": {"type": "string", "minLength": 1},
|
|
172
|
+
"question": {"type": "string", "minLength": 1},
|
|
173
|
+
"created_at": {"type": "string", "minLength": 1},
|
|
174
|
+
"updated_at": {"type": "string", "minLength": 1},
|
|
175
|
+
"status": {"enum": ["draft", "in_progress", "validated", "warning", "failed"]}
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
"interpretation": {
|
|
179
|
+
"type": "object",
|
|
180
|
+
"required": ["objective", "assumptions"],
|
|
181
|
+
"properties": {
|
|
182
|
+
"objective": {"type": "string", "minLength": 1},
|
|
183
|
+
"assumptions": {"type": "array"}
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
"sources": {"type": "object", "minProperties": 1},
|
|
187
|
+
"overrides": {"type": "array"},
|
|
188
|
+
"processing": {
|
|
189
|
+
"type": "object",
|
|
190
|
+
"required": ["analysis_crs", "steps"],
|
|
191
|
+
"properties": {
|
|
192
|
+
"analysis_crs": {"type": "string", "minLength": 1},
|
|
193
|
+
"storage_crs": {"type": "string", "minLength": 1},
|
|
194
|
+
"steps": {"type": "array", "minItems": 1}
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
"outputs": {"type": "object", "minProperties": 1},
|
|
198
|
+
"validation": {
|
|
199
|
+
"type": "object",
|
|
200
|
+
"required": ["required", "domain_checks"],
|
|
201
|
+
"properties": {
|
|
202
|
+
"required": {"type": "array"},
|
|
203
|
+
"domain_checks": {"type": "array"},
|
|
204
|
+
"expectations": {
|
|
205
|
+
"type": "array",
|
|
206
|
+
"items": {"$ref": "#/$defs/expectation"}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
"presentation": {
|
|
211
|
+
"type": "object",
|
|
212
|
+
"properties": {
|
|
213
|
+
"map": {
|
|
214
|
+
"type": "object",
|
|
215
|
+
"required": ["basemap"],
|
|
216
|
+
"properties": {
|
|
217
|
+
"basemap": {
|
|
218
|
+
"type": "object",
|
|
219
|
+
"required": ["id", "attribution"],
|
|
220
|
+
"properties": {
|
|
221
|
+
"id": {"type": "string", "minLength": 1},
|
|
222
|
+
"kind": {"enum": ["raster-xyz", "raster-wms", "vector-style"]},
|
|
223
|
+
"tiles": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
|
|
224
|
+
"url": {"type": "string", "minLength": 1},
|
|
225
|
+
"attribution": {"type": "string", "minLength": 1},
|
|
226
|
+
"default_visible": {"type": "boolean"}
|
|
227
|
+
},
|
|
228
|
+
"anyOf": [{"required": ["tiles"]}, {"required": ["url"]}]
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
"runtime": {
|
|
235
|
+
"type": "object",
|
|
236
|
+
"required": ["implementation"],
|
|
237
|
+
"properties": {
|
|
238
|
+
"implementation": {
|
|
239
|
+
"type": "object",
|
|
240
|
+
"anyOf": [
|
|
241
|
+
{"required": ["pipeline"]},
|
|
242
|
+
{"required": ["command"]}
|
|
243
|
+
]
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
"runs": {
|
|
248
|
+
"type": "object",
|
|
249
|
+
"required": ["latest"],
|
|
250
|
+
"properties": {
|
|
251
|
+
"latest": {
|
|
252
|
+
"type": "object",
|
|
253
|
+
"required": [
|
|
254
|
+
"id", "started_at", "completed_at", "status",
|
|
255
|
+
"inputs_hash", "outputs_hash", "validation_report"
|
|
256
|
+
],
|
|
257
|
+
"properties": {
|
|
258
|
+
"status": {"enum": ["passed", "warning", "failed"]}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|