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/verify.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Verify a produced project without requiring a golden answer.
|
|
2
|
+
|
|
3
|
+
`validate` audits the manifest and its bookkeeping. `verify` runs the check
|
|
4
|
+
library in `openmapstack.checks` against what the pipeline actually produced:
|
|
5
|
+
geometry read back through DuckDB Spatial, dataset CRS read from real
|
|
6
|
+
coordinates rather than the manifest's claim, validation evidence recomputed
|
|
7
|
+
from the geodata it summarises, the QGIS project loaded, and -- with
|
|
8
|
+
`--rerun` -- the whole project rebuilt from source in an empty workspace.
|
|
9
|
+
|
|
10
|
+
The checks planned here do not require a repository-owned golden answer, so
|
|
11
|
+
they transfer to data this package has never seen. They establish bounded
|
|
12
|
+
structural, provenance, artifact, and reproducibility predicates; they do not
|
|
13
|
+
claim to prove every project-specific analytical answer.
|
|
14
|
+
|
|
15
|
+
The plan is derived from the manifest rather than configured, so a project
|
|
16
|
+
cannot quietly opt out of a check by omitting it: an output declared in
|
|
17
|
+
`outputs` is an output that gets checked.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import tempfile
|
|
25
|
+
from collections.abc import Callable, Sequence
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from .checks import AssertionResult, not_testable
|
|
31
|
+
from .checks import geodata as geodata_checks
|
|
32
|
+
from .checks import overrides as overrides_checks
|
|
33
|
+
from .checks import presentation as presentation_checks
|
|
34
|
+
from .checks import project as project_checks
|
|
35
|
+
from .checks import provenance as provenance_checks
|
|
36
|
+
from .checks import qgis as qgis_checks
|
|
37
|
+
from .checks import rerun as rerun_checks
|
|
38
|
+
from .checks import validation as validation_checks
|
|
39
|
+
from .expectations import evaluate_expectation
|
|
40
|
+
from .project import load_project
|
|
41
|
+
from .rerun import perform_clean_rerun
|
|
42
|
+
|
|
43
|
+
SCHEMA = "openmapstack-verify-result/v1"
|
|
44
|
+
|
|
45
|
+
# Formats DuckDB Spatial can read back. A declared output in some other
|
|
46
|
+
# format is reported as unchecked rather than silently skipped.
|
|
47
|
+
GEODATA_SUFFIXES = {".parquet", ".gpkg", ".geojson", ".json", ".fgb", ".shp"}
|
|
48
|
+
|
|
49
|
+
_EPSG = re.compile(r"\bEPSG:\s*(\d{4,6})\b", re.IGNORECASE)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class CheckRun:
|
|
54
|
+
"""One executed check, named the way an eval case would name it."""
|
|
55
|
+
|
|
56
|
+
name: str
|
|
57
|
+
result: AssertionResult
|
|
58
|
+
args: dict[str, Any] = field(default_factory=dict)
|
|
59
|
+
evidence: dict[str, Any] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict[str, Any]:
|
|
62
|
+
payload: dict[str, Any] = {
|
|
63
|
+
"check": self.name,
|
|
64
|
+
"status": self.result.status,
|
|
65
|
+
"message": self.result.detail,
|
|
66
|
+
}
|
|
67
|
+
if self.args:
|
|
68
|
+
payload["args"] = self.args
|
|
69
|
+
if self.evidence:
|
|
70
|
+
payload["evidence"] = self.evidence
|
|
71
|
+
code = (self.result.data or {}).get("code")
|
|
72
|
+
if code:
|
|
73
|
+
payload["code"] = code
|
|
74
|
+
return payload
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class VerifyResult:
|
|
79
|
+
project_file: Path
|
|
80
|
+
checks: list[CheckRun] = field(default_factory=list)
|
|
81
|
+
rerun_evidence: dict[str, Any] | None = None
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def counts(self) -> dict[str, int]:
|
|
85
|
+
totals = {"passed": 0, "warning": 0, "not_testable": 0, "failed": 0}
|
|
86
|
+
for run in self.checks:
|
|
87
|
+
totals[run.result.status] = totals.get(run.result.status, 0) + 1
|
|
88
|
+
return totals
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def coverage(self) -> dict[str, int | float | None]:
|
|
92
|
+
"""Describe how much of the applicable plan actually executed.
|
|
93
|
+
|
|
94
|
+
``verify_project`` only adds checks that apply to the manifest: QGIS
|
|
95
|
+
checks, for example, are absent when no QGIS project is declared.
|
|
96
|
+
Every added check is therefore applicable. A ``not_testable`` result
|
|
97
|
+
means that applicable check could not execute its predicate because
|
|
98
|
+
an environmental dependency, supported artifact, or required
|
|
99
|
+
addressing information was unavailable.
|
|
100
|
+
"""
|
|
101
|
+
applicable = len(self.checks)
|
|
102
|
+
not_testable_count = self.counts["not_testable"]
|
|
103
|
+
executed = applicable - not_testable_count
|
|
104
|
+
return {
|
|
105
|
+
"applicable": applicable,
|
|
106
|
+
"executed": executed,
|
|
107
|
+
"not_testable": not_testable_count,
|
|
108
|
+
"execution_rate": executed / applicable if applicable else None,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def status(self) -> str:
|
|
113
|
+
counts = self.counts
|
|
114
|
+
if counts["failed"]:
|
|
115
|
+
return "failed"
|
|
116
|
+
if counts["warning"]:
|
|
117
|
+
return "warning"
|
|
118
|
+
if counts["not_testable"]:
|
|
119
|
+
# A completely unavailable plan is not testable. A partially
|
|
120
|
+
# executed plan is a warning: the successful checks remain useful,
|
|
121
|
+
# but the report must not present incomplete evidence as passed.
|
|
122
|
+
return "not_testable" if self.coverage["executed"] == 0 else "warning"
|
|
123
|
+
if not self.checks:
|
|
124
|
+
return "not_testable"
|
|
125
|
+
return "passed"
|
|
126
|
+
|
|
127
|
+
def ok(self, *, strict: bool = False) -> bool:
|
|
128
|
+
return self.status == "passed" if strict else self.status != "failed"
|
|
129
|
+
|
|
130
|
+
def to_dict(self) -> dict[str, Any]:
|
|
131
|
+
payload: dict[str, Any] = {
|
|
132
|
+
"schema": SCHEMA,
|
|
133
|
+
"project_file": str(self.project_file),
|
|
134
|
+
"status": self.status,
|
|
135
|
+
"counts": self.counts,
|
|
136
|
+
"coverage": self.coverage,
|
|
137
|
+
"checks": [run.to_dict() for run in self.checks],
|
|
138
|
+
}
|
|
139
|
+
if self.rerun_evidence is not None:
|
|
140
|
+
payload["clean_rerun"] = self.rerun_evidence
|
|
141
|
+
return payload
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _declared_output_paths(project: dict[str, Any]) -> list[tuple[str, str, str | None]]:
|
|
145
|
+
"""Return (output id, project-relative path, declared EPSG or None)."""
|
|
146
|
+
outputs = project.get("outputs")
|
|
147
|
+
if not isinstance(outputs, dict):
|
|
148
|
+
return []
|
|
149
|
+
found: list[tuple[str, str, str | None]] = []
|
|
150
|
+
for name, spec in outputs.items():
|
|
151
|
+
if not isinstance(spec, dict):
|
|
152
|
+
continue
|
|
153
|
+
path = spec.get("path")
|
|
154
|
+
if not isinstance(path, str) or not path.strip():
|
|
155
|
+
continue
|
|
156
|
+
match = _EPSG.search(str(spec.get("format", "")))
|
|
157
|
+
found.append((str(name), path, f"EPSG:{match.group(1)}" if match else None))
|
|
158
|
+
return found
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _immutable_input_paths(root: Path) -> list[str]:
|
|
162
|
+
paths: list[str] = []
|
|
163
|
+
for directory in ("data/source", "data/overrides"):
|
|
164
|
+
base = root / directory
|
|
165
|
+
if not base.is_dir():
|
|
166
|
+
continue
|
|
167
|
+
for item in sorted(base.rglob("*")):
|
|
168
|
+
if item.is_file():
|
|
169
|
+
paths.append(item.relative_to(root).as_posix())
|
|
170
|
+
return paths
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _run(
|
|
174
|
+
runs: list[CheckRun],
|
|
175
|
+
name: str,
|
|
176
|
+
fn: Callable[..., AssertionResult],
|
|
177
|
+
root: Path,
|
|
178
|
+
**kwargs: Any,
|
|
179
|
+
) -> None:
|
|
180
|
+
try:
|
|
181
|
+
result = fn(root, **kwargs)
|
|
182
|
+
except Exception as exc: # noqa: BLE001 - a check must never take the command down
|
|
183
|
+
result = not_testable(f"{type(exc).__name__}: {exc}", code="check_error")
|
|
184
|
+
runs.append(CheckRun(name, result, dict(kwargs)))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def verify_project(
|
|
188
|
+
project: str | Path,
|
|
189
|
+
*,
|
|
190
|
+
rerun: bool = False,
|
|
191
|
+
rerun_timeout_s: float = 1800,
|
|
192
|
+
forbidden_fragments: Sequence[str] = (),
|
|
193
|
+
) -> VerifyResult:
|
|
194
|
+
"""Run every applicable no-golden-answer check the environment supports."""
|
|
195
|
+
project_file, manifest = load_project(project)
|
|
196
|
+
root = project_file.parent
|
|
197
|
+
result = VerifyResult(project_file=project_file)
|
|
198
|
+
runs = result.checks
|
|
199
|
+
|
|
200
|
+
# -- contract: the manifest describes a resolvable, single-entrypoint project
|
|
201
|
+
_run(runs, "project.parses", project_checks.parses, root)
|
|
202
|
+
_run(runs, "project.conforms_to_schema", project_checks.conforms_to_schema, root)
|
|
203
|
+
_run(runs, "project.graph_resolves", project_checks.graph_resolves, root)
|
|
204
|
+
_run(runs, "project.one_canonical_pipeline", project_checks.one_canonical_pipeline, root)
|
|
205
|
+
_run(runs, "project.assumptions_have_rationale", project_checks.assumptions_have_rationale, root)
|
|
206
|
+
_run(
|
|
207
|
+
runs,
|
|
208
|
+
"project.status_agrees_with_validation_report",
|
|
209
|
+
project_checks.status_agrees_with_validation_report,
|
|
210
|
+
root,
|
|
211
|
+
)
|
|
212
|
+
declared = [path for _, path, _ in _declared_output_paths(manifest)]
|
|
213
|
+
if declared:
|
|
214
|
+
_run(runs, "project.declared_files_exist", project_checks.declared_files_exist, root, files=declared)
|
|
215
|
+
|
|
216
|
+
# -- provenance: sources are attributed, pinned, and licensed
|
|
217
|
+
for name, fn in (
|
|
218
|
+
("every_source_has_provider_and_access", provenance_checks.every_source_has_provider_and_access),
|
|
219
|
+
("every_source_pinned", provenance_checks.every_source_pinned),
|
|
220
|
+
("license_present_where_required", provenance_checks.license_present_where_required),
|
|
221
|
+
("rationale_present", provenance_checks.rationale_present),
|
|
222
|
+
):
|
|
223
|
+
_run(runs, f"provenance.{name}", fn, root)
|
|
224
|
+
|
|
225
|
+
# -- overrides: declared, evidenced, and verified against the real source
|
|
226
|
+
_run(runs, "overrides.every_override_has_provenance", overrides_checks.every_override_has_provenance, root)
|
|
227
|
+
_run(runs, "overrides.evidence_not_placeholder", overrides_checks.evidence_not_placeholder, root)
|
|
228
|
+
|
|
229
|
+
# -- validation: the report is complete, explicit, and matches the run record
|
|
230
|
+
for name, fn in (
|
|
231
|
+
("required_all_present", validation_checks.required_all_present),
|
|
232
|
+
("no_implicit_pass", validation_checks.no_implicit_pass),
|
|
233
|
+
("warning_or_failed_propagates_to_status", validation_checks.warning_or_failed_propagates_to_status),
|
|
234
|
+
("run_record_matches", validation_checks.run_record_matches),
|
|
235
|
+
):
|
|
236
|
+
_run(runs, f"validation.{name}", fn, root)
|
|
237
|
+
|
|
238
|
+
# -- project-specific answers: execute only when independently attested
|
|
239
|
+
expectations = (manifest.get("validation") or {}).get("expectations", [])
|
|
240
|
+
if isinstance(expectations, list):
|
|
241
|
+
seen_expectation_ids: set[str] = set()
|
|
242
|
+
for index, expectation in enumerate(expectations):
|
|
243
|
+
expectation_id = expectation.get("id") if isinstance(expectation, dict) else index
|
|
244
|
+
if isinstance(expectation_id, str) and expectation_id in seen_expectation_ids:
|
|
245
|
+
result_value = AssertionResult(
|
|
246
|
+
"failed",
|
|
247
|
+
f"expectation id {expectation_id!r} is duplicated",
|
|
248
|
+
{"code": "expectation_id_duplicate"},
|
|
249
|
+
)
|
|
250
|
+
evidence = {"class": "invalid"}
|
|
251
|
+
else:
|
|
252
|
+
result_value, evidence = evaluate_expectation(root, manifest, expectation)
|
|
253
|
+
if isinstance(expectation_id, str):
|
|
254
|
+
seen_expectation_ids.add(expectation_id)
|
|
255
|
+
check = expectation.get("check") if isinstance(expectation, dict) else None
|
|
256
|
+
args = expectation.get("args") if isinstance(expectation, dict) else None
|
|
257
|
+
report_args = {"check": check, **args} if isinstance(args, dict) else {"check": check}
|
|
258
|
+
runs.append(
|
|
259
|
+
CheckRun(
|
|
260
|
+
f"expectation.{expectation_id}",
|
|
261
|
+
result_value,
|
|
262
|
+
report_args,
|
|
263
|
+
evidence,
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# -- geodata: read the produced files, do not trust what the manifest says
|
|
268
|
+
_run(runs, "geodata.crs_not_used_for_metrics", geodata_checks.crs_not_used_for_metrics, root)
|
|
269
|
+
for name, path, epsg in _declared_output_paths(manifest):
|
|
270
|
+
if Path(path).suffix.lower() not in GEODATA_SUFFIXES:
|
|
271
|
+
runs.append(
|
|
272
|
+
CheckRun(
|
|
273
|
+
"geodata.geometry_all_valid",
|
|
274
|
+
not_testable(
|
|
275
|
+
f"output {name!r} is {Path(path).suffix or 'extensionless'}, "
|
|
276
|
+
"which DuckDB Spatial does not read back",
|
|
277
|
+
code="unsupported_format",
|
|
278
|
+
),
|
|
279
|
+
{"path": path},
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
continue
|
|
283
|
+
_run(runs, "geodata.geometry_all_valid", geodata_checks.geometry_all_valid, root, path=path)
|
|
284
|
+
if epsg:
|
|
285
|
+
_run(runs, "geodata.dataset_crs_is", geodata_checks.dataset_crs_is, root, path=path, expected=epsg)
|
|
286
|
+
else:
|
|
287
|
+
runs.append(
|
|
288
|
+
CheckRun(
|
|
289
|
+
"geodata.dataset_crs_is",
|
|
290
|
+
not_testable(
|
|
291
|
+
f"output {name!r} declares no EPSG code in its format string, "
|
|
292
|
+
"so its real CRS cannot be cross-checked",
|
|
293
|
+
code="crs_undeclared",
|
|
294
|
+
),
|
|
295
|
+
{"path": path},
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# -- presentation and QGIS: the product matches what the manifest claims
|
|
300
|
+
for name, fn in (
|
|
301
|
+
("layers_use_semantic_roles", presentation_checks.layers_use_semantic_roles),
|
|
302
|
+
("controls_match_pipeline", presentation_checks.controls_match_pipeline),
|
|
303
|
+
("edit_targets_reference_real_sources", presentation_checks.edit_targets_reference_real_sources),
|
|
304
|
+
):
|
|
305
|
+
_run(runs, f"presentation.{name}", fn, root)
|
|
306
|
+
if (root / "project.qgz").is_file():
|
|
307
|
+
for name, fn in (
|
|
308
|
+
("static_valid", qgis_checks.static_valid),
|
|
309
|
+
("styles_declared", qgis_checks.styles_declared),
|
|
310
|
+
("groups_match_manifest", qgis_checks.groups_match_manifest),
|
|
311
|
+
("every_layer_declares_crs", qgis_checks.every_layer_declares_crs),
|
|
312
|
+
("runtime_load", qgis_checks.runtime_load),
|
|
313
|
+
("layers_match_manifest", qgis_checks.layers_match_manifest),
|
|
314
|
+
("every_declared_layer_renders", qgis_checks.every_declared_layer_renders),
|
|
315
|
+
):
|
|
316
|
+
_run(runs, f"qgis.{name}", fn, root)
|
|
317
|
+
|
|
318
|
+
# -- reproducibility
|
|
319
|
+
_run(runs, "rerun.no_chat_dependency", rerun_checks.no_chat_dependency, root)
|
|
320
|
+
if rerun:
|
|
321
|
+
_verify_clean_rerun(
|
|
322
|
+
result,
|
|
323
|
+
root,
|
|
324
|
+
manifest,
|
|
325
|
+
timeout_s=rerun_timeout_s,
|
|
326
|
+
forbidden_fragments=forbidden_fragments,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _verify_clean_rerun(
|
|
333
|
+
result: VerifyResult,
|
|
334
|
+
root: Path,
|
|
335
|
+
manifest: dict[str, Any],
|
|
336
|
+
*,
|
|
337
|
+
timeout_s: float,
|
|
338
|
+
forbidden_fragments: Sequence[str],
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Rebuild the project from source and compare, then discard the copy."""
|
|
341
|
+
rerun_root = Path(tempfile.mkdtemp(prefix="openmapstack-verify-rerun-"))
|
|
342
|
+
try:
|
|
343
|
+
result.rerun_evidence = perform_clean_rerun(
|
|
344
|
+
root, rerun_root, timeout_s, forbidden_fragments=forbidden_fragments
|
|
345
|
+
)
|
|
346
|
+
runs = result.checks
|
|
347
|
+
_run(
|
|
348
|
+
runs,
|
|
349
|
+
"rerun.clean_execution_succeeded",
|
|
350
|
+
rerun_checks.clean_execution_succeeded,
|
|
351
|
+
root,
|
|
352
|
+
rerun_workspace=str(rerun_root),
|
|
353
|
+
)
|
|
354
|
+
outputs = [
|
|
355
|
+
path
|
|
356
|
+
for _, path, _ in _declared_output_paths(manifest)
|
|
357
|
+
if Path(path).suffix.lower() in GEODATA_SUFFIXES
|
|
358
|
+
]
|
|
359
|
+
if outputs:
|
|
360
|
+
_run(
|
|
361
|
+
runs,
|
|
362
|
+
"rerun.outputs_semantically_equal",
|
|
363
|
+
rerun_checks.outputs_semantically_equal,
|
|
364
|
+
root,
|
|
365
|
+
rerun_workspace=str(rerun_root),
|
|
366
|
+
paths=outputs,
|
|
367
|
+
)
|
|
368
|
+
_run(
|
|
369
|
+
runs,
|
|
370
|
+
"rerun.validation_report_reproducible",
|
|
371
|
+
rerun_checks.validation_report_reproducible,
|
|
372
|
+
root,
|
|
373
|
+
rerun_workspace=str(rerun_root),
|
|
374
|
+
)
|
|
375
|
+
immutable = _immutable_input_paths(root)
|
|
376
|
+
if immutable:
|
|
377
|
+
_run(
|
|
378
|
+
runs,
|
|
379
|
+
"overrides.source_files_byte_identical",
|
|
380
|
+
overrides_checks.source_files_byte_identical,
|
|
381
|
+
root,
|
|
382
|
+
rerun_workspace=str(rerun_root),
|
|
383
|
+
paths=immutable,
|
|
384
|
+
)
|
|
385
|
+
finally:
|
|
386
|
+
shutil.rmtree(rerun_root, ignore_errors=True)
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openmapstack
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Validate, run, and inspect reproducible OpenMapStack projects
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: jsonschema<5,>=4
|
|
9
|
+
Requires-Dist: PyYAML<7,>=6
|
|
10
|
+
Provides-Extra: geo
|
|
11
|
+
Requires-Dist: duckdb>=1.2; extra == "geo"
|
|
12
|
+
Provides-Extra: visual
|
|
13
|
+
Requires-Dist: playwright>=1.40; extra == "visual"
|
|
14
|
+
Provides-Extra: all
|
|
15
|
+
Requires-Dist: openmapstack[geo,visual]; extra == "all"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# openmapstack
|
|
19
|
+
|
|
20
|
+
**Geospatial questions → reproducible, validated GIS analysis project (with very nice interactive map).**
|
|
21
|
+
|
|
22
|
+
Install:
|
|
23
|
+
```bash
|
|
24
|
+
npx skills add jaakla/openmapstack -g
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
OpenMapStack gives your favorite AI agent: Claude Code, Codex, Cursor, OpenCode, and 50+ other agents a production workflow from **authoritative data discovery** through analysis to interactive web and GIS deliverables. Material workflows become inspectable and repeatable well-defined projects in a `yaml` file with pinned sources, explicit assumptions and CRS choices, deterministic processing, isolated overrides, machine-readable validation, and surfaced provenance.
|
|
28
|
+
|
|
29
|
+
It is open-first and cloud-native by default, built on shoulders of the awesome Open GIS stack: STAC for discovery; GeoParquet, COG, and PMTiles for storage and delivery; DuckDB and PostGIS for compute; and QGIS, MapLibre, and Martin for presentation. It also uses GDAL/OGR, GeoPandas, xarray/rioxarray, PDAL, routing engines, spatial SQL, and pragmatic hosted services when scale or reliability requires them.
|
|
30
|
+
|
|
31
|
+
## What's in this repo
|
|
32
|
+
|
|
33
|
+
- [SKILL.md](SKILL.md) — the skill entry point: triggers, global defaults, format and compute decision matrices, anti-patterns, and a quick triage guide.
|
|
34
|
+
- [references/data-sources.md](references/data-sources.md) - lists OSM, Overture, Sentinel/Landsat, regional portals, STAC catalogs and others.
|
|
35
|
+
- [references/services-and-scale.md](references/services-and-scale.md) - depending on case use local installs or hosted/SaaS services for global-scale basemaps, elevation, routing, geocoding, place search, and postcodes.
|
|
36
|
+
- [references/formats-and-crs.md](references/formats-and-crs.md) - how to choose formats, conversions, projections, EPSG codes.
|
|
37
|
+
- [references/processing.md](references/processing.md) - when and how to use GDAL/OGR, GeoPandas, xarray, DuckDB, PostGIS, PDAL and other open geo processing tools.
|
|
38
|
+
- [references/analytics.md](references/analytics.md) — do vector/raster analytics, terrain, hydrology, network, point clouds, geocoding etc.
|
|
39
|
+
- [references/web-delivery.md](references/web-delivery.md) — renderer selection for maps, PMTiles, MVT, Martin, TiTiler, MapLibre, deck.gl, kepler.gl, and lonboard formats and engines.
|
|
40
|
+
- [references/qgis.md](references/qgis.md) — QGIS desktop, plugins, PyQGIS, Processing, QGIS MCP.
|
|
41
|
+
- [references/validation-and-ops.md](references/validation-and-ops.md) — validation, manifests, attribution, and deployment checks, including the machine-readable reproducible-project contract.
|
|
42
|
+
- [references/project-spec.md](references/project-spec.md) — the specific`openmapstack-project/v1` schema: compiling any material analysis into a reproducible GIS project (`project.yaml`, pipeline, source provenance, overrides, validation, semantic presentation, QGIS output).
|
|
43
|
+
- [templates/](templates/) — ready scaffolds (`project.yaml`, `pipeline.py`, `presentation.yaml`, `validation.yaml`) for new projects.
|
|
44
|
+
- [examples/tartu-development/](examples/tartu-development/) — a fully-worked reproducible project matching the acceptance scenario: source provenance + timestamps, explicit assumptions, two verified project overrides (a scenario attribute change with prior-value verification, and hypothetical scenario geometry), deterministic pipeline, machine-readable validation, and semantic presentation.
|
|
45
|
+
- [evals/](evals/) — the eval suite grading whether an agent reaches the right analytical answer, respects the GIS-method guardrails, and reruns reproducibly, with the `openmapstack-project/v1` contract as the substrate that makes those independently checkable: `python evals/run.py --mode fixture` runs deterministic, no-LLM checks against real generated artifacts (analytical correctness against known geospatial truth, metric CRS, source immutability, schema, overrides, validation integrity, presentation contract, and clean reruns), plus adversarial cases and a pluggable live-agent benchmark (Claude Code, Codex, and any OpenAI-compatible API such as OpenRouter — URL and model via `OPENAI_COMPATIBLE_*` env, API key as a secret).
|
|
46
|
+
- [`openmapstack/`](openmapstack/) — the installable `openmapstack validate/run/inspect` CLI for auditing and executing `openmapstack-project/v1` projects, plus [`openmapstack/checks/`](openmapstack/checks/): the reusable, semantic check library. All but five of its checks are oracle-free, so the same functions that grade the eval suite also grade a user's own project on data this repository has never seen.
|
|
47
|
+
- [`.claude-plugin/`](.claude-plugin/) — Claude Code plugin and marketplace manifests, so the repository can also be installed with `/plugin install`. Validated in CI by [`.github/workflows/plugin.yml`](.github/workflows/plugin.yml).
|
|
48
|
+
|
|
49
|
+
My local Estonia-specific guidance (Maa- ja Ruumiamet, ETAK, EPSG:3301 / L-EST97) is included for convenience. But all the global sources are incuded for world-wide coverage.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
The recommended way is the [skills CLI](https://github.com/vercel-labs/skills), which works for Claude Code, Cursor, OpenCode, Codex, and 50+ other agents.
|
|
54
|
+
|
|
55
|
+
### Recommended: skills CLI
|
|
56
|
+
|
|
57
|
+
Install globally (available in every project):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npx skills add jaakla/openmapstack -g
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Update later with `npx skills update open-map-stack`. Remove with `npx skills remove open-map-stack`.
|
|
64
|
+
|
|
65
|
+
### Claude Code plugin (optional)
|
|
66
|
+
|
|
67
|
+
Claude Code users can install the same repository as a plugin instead. This adds
|
|
68
|
+
versioned installs, `/plugin update`, and project-scoped installs that a team
|
|
69
|
+
picks up from a repository's `.claude/settings.json`:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
/plugin marketplace add jaakla/openmapstack
|
|
73
|
+
/plugin install open-map-stack@open-map-stack
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The repository is its own marketplace, so no separate marketplace repo is
|
|
77
|
+
needed. The plugin wraps the same root `SKILL.md` — nothing is duplicated, and
|
|
78
|
+
the skills-CLI install path above keeps working unchanged.
|
|
79
|
+
|
|
80
|
+
### Install the project CLI
|
|
81
|
+
|
|
82
|
+
The skills installer loads the agent instructions; the Python package provides
|
|
83
|
+
the project commands. From a clone of this repository:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
python3 -m pip install .
|
|
87
|
+
openmapstack --version
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
For development, the commands can also run directly without installation:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
python3 -m openmapstack --help
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Manual install (fallback)
|
|
97
|
+
|
|
98
|
+
If you'd rather not use the CLI, clone directly into your agent's skills directory. For Claude Code:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
# User-level (every project)
|
|
102
|
+
git clone https://github.com/jaakla/openmapstack.git ~/.claude/skills/open-map-stack
|
|
103
|
+
|
|
104
|
+
# Project-level (one repo)
|
|
105
|
+
git clone https://github.com/jaakla/openmapstack.git .claude/skills/open-map-stack
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Verify
|
|
109
|
+
|
|
110
|
+
Start Claude Code and run `/skills open-map-stack` should appear in the list. The expected layout is:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
<skills-dir>/open-map-stack/
|
|
114
|
+
├── SKILL.md
|
|
115
|
+
├── references/
|
|
116
|
+
│ ├── analytics.md
|
|
117
|
+
│ ├── data-sources.md
|
|
118
|
+
│ ├── formats-and-crs.md
|
|
119
|
+
│ ├── processing.md
|
|
120
|
+
│ ├── project-spec.md
|
|
121
|
+
│ ├── qgis.md
|
|
122
|
+
│ ├── services-and-scale.md
|
|
123
|
+
│ ├── spatial-sql.md
|
|
124
|
+
│ ├── validation-and-ops.md
|
|
125
|
+
│ └── web-delivery.md
|
|
126
|
+
├── templates/
|
|
127
|
+
│ ├── project.yaml
|
|
128
|
+
│ ├── pipeline.py
|
|
129
|
+
│ ├── presentation.yaml
|
|
130
|
+
│ └── validation.yaml
|
|
131
|
+
├── examples/
|
|
132
|
+
│ └── tartu-development/
|
|
133
|
+
└── .claude-plugin/ # Claude Code plugin + marketplace manifests
|
|
134
|
+
├── plugin.json
|
|
135
|
+
└── marketplace.json
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Use
|
|
139
|
+
|
|
140
|
+
The skill auto-activates when you ask Claude about geospatial work — terms like GIS, OpenStreetMap, Overture, Sentinel, Landsat, LiDAR, GeoTIFF, shapefile, GeoPackage, raster/vector tiles, isochrones, spatial joins, EPSG codes, and projections will all trigger it. You don't need to invoke it manually, but sometimes hinting "use open-map-stack skills" helps.
|
|
141
|
+
|
|
142
|
+
Example prompts that engage the skill:
|
|
143
|
+
|
|
144
|
+
- "Pull all buildings in Tartu from Overture and publish them as a PMTiles layer."
|
|
145
|
+
- "Compute average NDVI for these polygons from Sentinel-2 over the last 12 months."
|
|
146
|
+
- "Reproject this GeoTIFF from EPSG:3301 to EPSG:3857 as a COG."
|
|
147
|
+
- "Set up an OSRM routing server from a Estonia OSM extract."
|
|
148
|
+
- "Build an isochrone API around these points."
|
|
149
|
+
|
|
150
|
+
If you want to force the skill to load, you can reference it explicitly:
|
|
151
|
+
|
|
152
|
+
> Use the open-map-stack skill to convert this shapefile to GeoParquet.
|
|
153
|
+
|
|
154
|
+
## Project CLI
|
|
155
|
+
|
|
156
|
+
The CLI operates on an `openmapstack-project/v1` manifest. A project directory may
|
|
157
|
+
be supplied in place of its `project.yaml` file.
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
# Audit the complete artifact, including outputs, report, and run record.
|
|
161
|
+
openmapstack validate path/to/project.yaml
|
|
162
|
+
|
|
163
|
+
# Check the produced artifacts without requiring a golden answer.
|
|
164
|
+
openmapstack verify path/to/project.yaml
|
|
165
|
+
|
|
166
|
+
# Run the one canonical pipeline, then validate what it produced.
|
|
167
|
+
openmapstack run path/to/project.yaml
|
|
168
|
+
|
|
169
|
+
# Review sources, versions, overrides, ordered steps, outputs, and latest run.
|
|
170
|
+
openmapstack inspect path/to/project.yaml
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Useful automation options:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
openmapstack validate project.yaml --json --output validation/cli-report.json
|
|
177
|
+
openmapstack validate project.yaml --strict # warnings also return non-zero
|
|
178
|
+
openmapstack validate project.yaml --preflight # skip not-yet-generated artifacts
|
|
179
|
+
openmapstack run project.yaml --dry-run
|
|
180
|
+
openmapstack run project.yaml --json
|
|
181
|
+
openmapstack inspect project.yaml --json
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### `openmapstack verify` — check the analysis, not just the paperwork
|
|
185
|
+
|
|
186
|
+
`validate` audits the manifest and its bookkeeping. `verify` runs the check
|
|
187
|
+
library in `openmapstack/checks/` against what the pipeline actually produced:
|
|
188
|
+
geometry read back through DuckDB Spatial, dataset CRS read from the artifact
|
|
189
|
+
rather than the manifest's claim, validation evidence recomputed from the
|
|
190
|
+
geodata it summarises, and QGIS project structure and runtime loading where
|
|
191
|
+
PyQGIS is available.
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
openmapstack verify path/to/project.yaml
|
|
195
|
+
openmapstack verify path/to/project.yaml --rerun # + rebuild from source and compare
|
|
196
|
+
openmapstack verify path/to/project.yaml --json --output validation/verify-report.json
|
|
197
|
+
openmapstack verify path/to/project.yaml --strict # warnings and not-testable also return 1
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
These checks require no repository-owned golden answer, so they work on data
|
|
201
|
+
neither this repository nor the model has seen. They establish bounded
|
|
202
|
+
structural, provenance, artifact, and reproducibility predicates; they do not
|
|
203
|
+
prove every project-specific analytical answer.
|
|
204
|
+
|
|
205
|
+
`--rerun` is the strongest signal available without a known answer. It rebuilds
|
|
206
|
+
the project in an empty workspace from only the manifest, the declared
|
|
207
|
+
immutable inputs, and the declared dependencies, runs the one canonical
|
|
208
|
+
entrypoint, re-hashes the sources, and compares the outputs semantically. A
|
|
209
|
+
pipeline that cannot reproduce itself, or that mutates its own declared
|
|
210
|
+
immutable inputs, is not trustworthy whatever its numbers say.
|
|
211
|
+
|
|
212
|
+
The check plan is derived from the manifest rather than configured, so a
|
|
213
|
+
project cannot opt out of a check by omitting it: a declared output is a
|
|
214
|
+
checked output. A check whose dependency is missing reports `not_testable` and
|
|
215
|
+
is counted separately — never a silent pass. A mixture of executed and
|
|
216
|
+
`not_testable` checks has aggregate status `warning`, and every report includes
|
|
217
|
+
`applicable`, `executed`, and `execution_rate` coverage. Install
|
|
218
|
+
`openmapstack[geo]` for the DuckDB-backed geodata checks; PyQGIS comes from a
|
|
219
|
+
system QGIS install.
|
|
220
|
+
|
|
221
|
+
See [the applicability reference](docs/verify-applicability.md) for the exact
|
|
222
|
+
plan conditions, dependencies, current regression evidence, and deliberate
|
|
223
|
+
exclusions. In particular, browser/dashboard checks are not yet part of the
|
|
224
|
+
automatic `verify` plan.
|
|
225
|
+
|
|
226
|
+
Project-specific known answers can be declared under
|
|
227
|
+
`validation.expectations[]`. The five allowlisted checks cover row count,
|
|
228
|
+
feature presence/absence, one feature-field value, and field range. New
|
|
229
|
+
expectations start as `attestation.status: unverified`; they produce a warning
|
|
230
|
+
and are not executed. The JSON report supplies the exact
|
|
231
|
+
`expected_expectation_sha256` an independent reviewer must bind, together with
|
|
232
|
+
the current `runs.latest.inputs_hash`. Changing the expected check, arguments,
|
|
233
|
+
inputs, or a retained local evidence file invalidates the attestation and
|
|
234
|
+
returns it to warning status. See
|
|
235
|
+
[the project contract](references/project-spec.md#26-validation).
|
|
236
|
+
|
|
237
|
+
`validate` checks manifest structure, source retrieval/version/licensing data,
|
|
238
|
+
CRS declarations, processing graph resolution, override provenance and files,
|
|
239
|
+
output existence, validation-report parity/status propagation, override
|
|
240
|
+
application results, and run-record identity/hashes. GIS-specific checks such as
|
|
241
|
+
geometry validity remain the pipeline's responsibility; the CLI verifies that
|
|
242
|
+
each declared check appears exactly once with an explicit result.
|
|
243
|
+
|
|
244
|
+
Normal validation warnings return exit code 0 so known limitations remain
|
|
245
|
+
representable. Failures return 1; malformed invocation or an unstartable runtime
|
|
246
|
+
returns 2. `--strict` makes warnings return 1.
|
|
247
|
+
|
|
248
|
+
## What this skill will and won't do
|
|
249
|
+
|
|
250
|
+
**Will:**
|
|
251
|
+
- Recommend modern, cloud-native formats (GeoParquet, COG, PMTiles) and flag legacy patterns (Shapefile output, MBTiles for new deployments).
|
|
252
|
+
- Push spatial joins to DuckDB / PostGIS instead of Python loops.
|
|
253
|
+
- Discover data via STAC before downloading.
|
|
254
|
+
- Preserve license metadata (OSM ODbL, Overture per-source, Sentinel attribution).
|
|
255
|
+
- Pin dataset versions for reproducibility (Overture releases, STAC item IDs, OSM extract dates).
|
|
256
|
+
- Compile material multi-stage analysis into a reproducible GIS project (`project.yaml` + pipeline + overrides + validation), deriving the final map/dashboard from it.
|
|
257
|
+
|
|
258
|
+
**Won't:**
|
|
259
|
+
- Trigger on simple location lookups ("what city is this?") or casual map references with no analytical work.
|
|
260
|
+
- Default to proprietary services when an open/self-hosted option fits the scale, quality, privacy, and budget.
|
|
261
|
+
|
|
262
|
+
## License
|
|
263
|
+
|
|
264
|
+
Licensed under the [MIT License](LICENSE).
|
|
265
|
+
|
|
266
|
+
## Contributing
|
|
267
|
+
|
|
268
|
+
Issues and PRs welcome at [github.com/jaakla/openmapstack](https://github.com/jaakla/openmapstack). When adding a new tool or workflow, place it in the matching reference file and add a one-row entry to the relevant decision matrix in [SKILL.md](SKILL.md).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
openmapstack/__init__.py,sha256=WThDw9BtH52ssdkKMhoZMFMhzsKwkIhqIe10M6EkPvo,217
|
|
2
|
+
openmapstack/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
openmapstack/cli.py,sha256=rNHA3_yHSiTVlQb66KBPk3T54TmACFWdSMNxMQQZGB4,18254
|
|
4
|
+
openmapstack/expectations.py,sha256=EW6RvMOwMGs8Z_XQnqdZIsHZTtdSUC6nnYCSkiu7oWM,11411
|
|
5
|
+
openmapstack/integrity.py,sha256=PNbX0JlZbcTXf0JjqqrWo9AzMfQpqEdCVoGPn7QmBvM,5289
|
|
6
|
+
openmapstack/project.py,sha256=9etwwdDE-gg-meiAPaFXMKqoigj7-DULkY0aj7DQV-k,2526
|
|
7
|
+
openmapstack/rerun.py,sha256=XhYG-rakU3DfCk_m2nZJ1mwkEEiJA0W4uMnUDJu3VIw,13875
|
|
8
|
+
openmapstack/schema.py,sha256=O3JbWN-pHGhYwB4wsLXEeNwoMnUuT2R1KWclX76Bc7Y,1313
|
|
9
|
+
openmapstack/validation.py,sha256=FD0ecrDc-4qTMN3Rhymt6AuQj3IbuicwsyFT88ycT_s,50307
|
|
10
|
+
openmapstack/verify.py,sha256=kaKGHNhBt6t1ufc5S5ousMEnb72XP7bxGo2IS3-C0ME,15493
|
|
11
|
+
openmapstack/checks/__init__.py,sha256=tp12swLm3JlXT6LWLDyERA8VJQrqujaqbiwV_NOYeBg,3974
|
|
12
|
+
openmapstack/checks/geodata.py,sha256=R1xOZiB4xBzePxmkIZc-5ONOmUZTXqYFXYieyR6dYbw,14374
|
|
13
|
+
openmapstack/checks/overrides.py,sha256=A6kYKp-l9lqULhnKXUd8u3LzDM1d76cA5CLZ3ApwniE,10816
|
|
14
|
+
openmapstack/checks/presentation.py,sha256=f4usu-VOufJg3pyQA2XjvCuUR-l3uPSK3LPpSQAzVUk,8167
|
|
15
|
+
openmapstack/checks/project.py,sha256=lcmjiOx57xRYHxYW2hI7uW4u8pkRp3CTATJ0gDV_US0,11121
|
|
16
|
+
openmapstack/checks/provenance.py,sha256=KAqg4WkXPVH4N32jBt2gDhwkBfZzvz7gngy19AA2Mc4,6049
|
|
17
|
+
openmapstack/checks/qgis.py,sha256=lM0JFYOQd40PiMbsC-Y3LbIWBsIWSjciMYm33vNkJqk,36363
|
|
18
|
+
openmapstack/checks/rerun.py,sha256=sVZjWfIZMb_ETLjs0h6yMGmUZvjSFyFiHOnZKv6xxV4,14059
|
|
19
|
+
openmapstack/checks/spatial.py,sha256=4gmso0sfelkWaqVjiA7MnNUO-p3RAFgMhRiZHtCOzuE,2060
|
|
20
|
+
openmapstack/checks/validation.py,sha256=ofjFccvz_PlhuZQyPGwU56Q4Ns0kg9CBm929AHojJJ4,12925
|
|
21
|
+
openmapstack/checks/visual.py,sha256=K9NAkvYW9IcNE5ukbm6xgWiU9y8GymtMarkWCASNG0c,29074
|
|
22
|
+
openmapstack/schemas/__init__.py,sha256=TMmxU-wMXRk2wx9E8Qa8jmGZnDUUxbvGSdFzUckXNjo,51
|
|
23
|
+
openmapstack/schemas/project-v1.schema.json,sha256=pvKq2jaTo9yUgoiJUHszNUWnDdy5ZhJEcrjQ1fbJREQ,8615
|
|
24
|
+
openmapstack-0.2.0.dist-info/licenses/LICENSE,sha256=1VX6fZbdvQREb_ybu1xwivIw82HB5cQbDChwNADPJ-0,1070
|
|
25
|
+
openmapstack-0.2.0.dist-info/METADATA,sha256=LOhI3z0ebW-CvBvujJspRibxqV-RWmVF1vBkc68j4ag,14729
|
|
26
|
+
openmapstack-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
27
|
+
openmapstack-0.2.0.dist-info/entry_points.txt,sha256=2vTunbyTwb17cjl-n0JidVBjIL3P_0j1p729squzUXQ,55
|
|
28
|
+
openmapstack-0.2.0.dist-info/top_level.txt,sha256=b9wNEcuTS-e5TJfRhtC6plL30a8NYJXugmx18ngXaDI,13
|
|
29
|
+
openmapstack-0.2.0.dist-info/RECORD,,
|