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
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Validation-integrity assertions: report/manifest parity, status propagation.
|
|
2
|
+
|
|
3
|
+
See references/project-spec.md section 2.6 and 6.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import (
|
|
11
|
+
AssertionResult,
|
|
12
|
+
failed,
|
|
13
|
+
get_in,
|
|
14
|
+
load_json,
|
|
15
|
+
load_project_yaml,
|
|
16
|
+
not_testable,
|
|
17
|
+
passed,
|
|
18
|
+
project_root,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
VALID_STATUSES = {"passed", "failed", "warning", "not_testable"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def required_all_present(
|
|
25
|
+
workspace: Path, project_dir: str = ".", report_path: str = "validation/latest-report.json"
|
|
26
|
+
) -> AssertionResult:
|
|
27
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
28
|
+
if proj is None:
|
|
29
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
30
|
+
required = set(get_in(proj, "validation.required", []) or [])
|
|
31
|
+
domain = {c.get("name") for c in (get_in(proj, "validation.domain_checks", []) or [])}
|
|
32
|
+
declared = required | domain
|
|
33
|
+
|
|
34
|
+
report = load_json(project_root(workspace, project_dir) / report_path)
|
|
35
|
+
if report is None:
|
|
36
|
+
return not_testable(f"no report at {report_path}", code="report_missing")
|
|
37
|
+
reported_ids = [c.get("id") for c in report.get("checks", [])]
|
|
38
|
+
|
|
39
|
+
missing = declared - set(reported_ids)
|
|
40
|
+
if missing:
|
|
41
|
+
return failed(f"declared checks missing from report: {sorted(missing)}", code="declared_check_missing")
|
|
42
|
+
|
|
43
|
+
# Each declared check must appear exactly once.
|
|
44
|
+
from collections import Counter
|
|
45
|
+
|
|
46
|
+
counts = Counter(reported_ids)
|
|
47
|
+
dupes = [cid for cid in declared if counts.get(cid, 0) > 1]
|
|
48
|
+
if dupes:
|
|
49
|
+
return failed(f"declared checks appear more than once in report: {dupes}", code="duplicate_check")
|
|
50
|
+
|
|
51
|
+
return passed(f"all {len(declared)} declared checks present exactly once in report")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def no_implicit_pass(
|
|
55
|
+
workspace: Path, project_dir: str = ".", report_path: str = "validation/latest-report.json"
|
|
56
|
+
) -> AssertionResult:
|
|
57
|
+
"""Every check must use one of the four explicit statuses; a missing
|
|
58
|
+
status field (silently treated as pass by a lazy renderer) is a failure."""
|
|
59
|
+
report = load_json(project_root(workspace, project_dir) / report_path)
|
|
60
|
+
if report is None:
|
|
61
|
+
return not_testable(f"no report at {report_path}", code="report_missing")
|
|
62
|
+
bad = [c.get("id", "?") for c in report.get("checks", []) if c.get("status") not in VALID_STATUSES]
|
|
63
|
+
if bad:
|
|
64
|
+
return failed(
|
|
65
|
+
f"checks without an explicit passed|failed|warning|not_testable status: {bad}",
|
|
66
|
+
code="implicit_status",
|
|
67
|
+
)
|
|
68
|
+
return passed("every check has an explicit status")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def warning_or_failed_propagates_to_status(
|
|
72
|
+
workspace: Path, project_dir: str = ".", report_path: str = "validation/latest-report.json"
|
|
73
|
+
) -> AssertionResult:
|
|
74
|
+
report = load_json(project_root(workspace, project_dir) / report_path)
|
|
75
|
+
if report is None:
|
|
76
|
+
return not_testable(f"no report at {report_path}", code="report_missing")
|
|
77
|
+
checks = report.get("checks", [])
|
|
78
|
+
has_bad = any(c.get("status") in ("warning", "failed", "not_testable") for c in checks)
|
|
79
|
+
overall = report.get("status")
|
|
80
|
+
if has_bad and overall == "passed":
|
|
81
|
+
return failed(
|
|
82
|
+
"report has warning/failed/not_testable checks but overall status is 'passed' "
|
|
83
|
+
"(non-passed checks must propagate)",
|
|
84
|
+
code="status_laundering",
|
|
85
|
+
)
|
|
86
|
+
if not has_bad and overall != "passed":
|
|
87
|
+
return failed(
|
|
88
|
+
f"all checks passed but overall status is {overall!r}, expected 'passed'",
|
|
89
|
+
code="status_understated",
|
|
90
|
+
)
|
|
91
|
+
return passed(f"overall status {overall!r} correctly reflects check statuses")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run_record_matches(
|
|
95
|
+
workspace: Path, project_dir: str = ".", report_path: str = "validation/latest-report.json",
|
|
96
|
+
runs_dir: str = "runs",
|
|
97
|
+
) -> AssertionResult:
|
|
98
|
+
from openmapstack.integrity import (
|
|
99
|
+
canonical_file_set_hash,
|
|
100
|
+
declared_input_paths,
|
|
101
|
+
declared_output_paths,
|
|
102
|
+
normalize_digest,
|
|
103
|
+
sha256_file,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
107
|
+
if proj is None:
|
|
108
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
109
|
+
root = project_root(workspace, project_dir)
|
|
110
|
+
report = load_json(project_root(workspace, project_dir) / report_path)
|
|
111
|
+
if report is None:
|
|
112
|
+
return not_testable(f"no report at {report_path}", code="report_missing")
|
|
113
|
+
run_id = report.get("run_id")
|
|
114
|
+
if not run_id:
|
|
115
|
+
return failed("report has no run_id", code="run_id_missing")
|
|
116
|
+
run_file = project_root(workspace, project_dir) / runs_dir / f"{run_id}.json"
|
|
117
|
+
if not run_file.exists():
|
|
118
|
+
return failed(
|
|
119
|
+
f"report references run_id {run_id!r} but {run_file} does not exist", code="run_record_missing"
|
|
120
|
+
)
|
|
121
|
+
run_record = load_json(run_file)
|
|
122
|
+
if run_record is None:
|
|
123
|
+
return failed(f"run record {run_file} unreadable", code="run_record_unreadable")
|
|
124
|
+
|
|
125
|
+
latest = get_in(proj, "runs.latest", {}) or {}
|
|
126
|
+
for hash_field, inventory_name, required in (
|
|
127
|
+
("inputs_hash", "inputs", set(declared_input_paths(root, proj))),
|
|
128
|
+
("outputs_hash", "outputs", set(declared_output_paths(proj))),
|
|
129
|
+
):
|
|
130
|
+
inventory = run_record.get(inventory_name)
|
|
131
|
+
if not isinstance(inventory, list) or not inventory:
|
|
132
|
+
return failed(
|
|
133
|
+
f"run record has no {inventory_name} inventory",
|
|
134
|
+
code="hash_inventory_missing",
|
|
135
|
+
)
|
|
136
|
+
paths: list[str] = []
|
|
137
|
+
seen: set[str] = set()
|
|
138
|
+
for item in inventory:
|
|
139
|
+
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
|
140
|
+
return failed(f"invalid {inventory_name} inventory item", code="hash_inventory_invalid")
|
|
141
|
+
relative = item["path"]
|
|
142
|
+
target = (root / relative).resolve()
|
|
143
|
+
try:
|
|
144
|
+
normalized = target.relative_to(root.resolve()).as_posix()
|
|
145
|
+
except ValueError:
|
|
146
|
+
return failed(f"unsafe inventory path: {relative}", code="hash_inventory_invalid")
|
|
147
|
+
expected_file_hash = normalize_digest(item.get("sha256"))
|
|
148
|
+
if normalized in seen or not target.is_file() or expected_file_hash is None:
|
|
149
|
+
return failed(
|
|
150
|
+
f"invalid or duplicate inventory file: {relative}", code="hash_inventory_invalid"
|
|
151
|
+
)
|
|
152
|
+
if sha256_file(target) != expected_file_hash:
|
|
153
|
+
return failed(f"inventory hash mismatch: {relative}", code="hash_mismatch")
|
|
154
|
+
seen.add(normalized)
|
|
155
|
+
paths.append(normalized)
|
|
156
|
+
omitted = sorted(required - seen)
|
|
157
|
+
if omitted:
|
|
158
|
+
return failed(
|
|
159
|
+
f"required files omitted from {inventory_name} inventory: {omitted}",
|
|
160
|
+
code="hash_inventory_incomplete",
|
|
161
|
+
)
|
|
162
|
+
actual = canonical_file_set_hash(root, paths)
|
|
163
|
+
labelled = {
|
|
164
|
+
"manifest": normalize_digest(latest.get(hash_field)),
|
|
165
|
+
"report": normalize_digest(report.get(hash_field)),
|
|
166
|
+
"run": normalize_digest(run_record.get(hash_field)),
|
|
167
|
+
}
|
|
168
|
+
if any(value is None for value in labelled.values()):
|
|
169
|
+
return failed(f"{hash_field} missing or malformed: {labelled}", code="hash_missing")
|
|
170
|
+
if any(value != actual for value in labelled.values()):
|
|
171
|
+
return failed(
|
|
172
|
+
f"{hash_field} does not match real files: declared={labelled}, actual={actual}",
|
|
173
|
+
code="hash_mismatch",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return passed(f"report run_id {run_id!r} matches a real run record with consistent hashes")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def no_prose_only_validation(
|
|
180
|
+
workspace: Path, check_id: str, project_dir: str = ".", report_path: str = "validation/latest-report.json"
|
|
181
|
+
) -> AssertionResult:
|
|
182
|
+
"""A named check must carry machine-checkable evidence (numeric/boolean
|
|
183
|
+
fields beyond status+reason), not just a status and a sentence."""
|
|
184
|
+
report = load_json(project_root(workspace, project_dir) / report_path)
|
|
185
|
+
if report is None:
|
|
186
|
+
return not_testable(f"no report at {report_path}", code="report_missing")
|
|
187
|
+
check = next((c for c in report.get("checks", []) if c.get("id") == check_id), None)
|
|
188
|
+
if check is None:
|
|
189
|
+
return failed(f"check {check_id!r} not present in report", code="check_missing")
|
|
190
|
+
evidence_keys = set(check.keys()) - {"id", "status", "reason"}
|
|
191
|
+
if not evidence_keys:
|
|
192
|
+
return failed(
|
|
193
|
+
f"check {check_id!r} has only status/reason — no machine-checkable evidence",
|
|
194
|
+
code="prose_only",
|
|
195
|
+
)
|
|
196
|
+
return passed(f"check {check_id!r} carries evidence fields: {sorted(evidence_keys)}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def report_evidence_recomputes(
|
|
200
|
+
workspace: Path,
|
|
201
|
+
evidence: list[dict],
|
|
202
|
+
project_dir: str = ".",
|
|
203
|
+
report_path: str = "validation/latest-report.json",
|
|
204
|
+
) -> AssertionResult:
|
|
205
|
+
"""Recompute supported numeric evidence from real geodata files.
|
|
206
|
+
|
|
207
|
+
Each declaration names a report check, evidence field, metric, dataset,
|
|
208
|
+
and optional id field. This avoids accepting internally consistent prose
|
|
209
|
+
or invented counters as proof that a GIS check actually ran.
|
|
210
|
+
"""
|
|
211
|
+
from .geodata import _connect, _read
|
|
212
|
+
|
|
213
|
+
report = load_json(project_root(workspace, project_dir) / report_path)
|
|
214
|
+
if report is None:
|
|
215
|
+
return not_testable(f"no report at {report_path}", code="report_missing")
|
|
216
|
+
if not isinstance(evidence, list) or not evidence:
|
|
217
|
+
return failed("no evidence recomputation declarations", code="evidence_config_missing")
|
|
218
|
+
con = _connect()
|
|
219
|
+
if con is None:
|
|
220
|
+
return not_testable("duckdb spatial not available in this environment", code="duckdb_unavailable")
|
|
221
|
+
|
|
222
|
+
checks = {
|
|
223
|
+
str(check.get("id")): check
|
|
224
|
+
for check in report.get("checks", [])
|
|
225
|
+
if isinstance(check, dict) and check.get("id")
|
|
226
|
+
}
|
|
227
|
+
mismatches: list[str] = []
|
|
228
|
+
recomputed: list[dict] = []
|
|
229
|
+
for declaration in evidence:
|
|
230
|
+
if not isinstance(declaration, dict):
|
|
231
|
+
return failed("evidence declaration must be a mapping", code="evidence_config_invalid")
|
|
232
|
+
check_id = declaration.get("check_id")
|
|
233
|
+
evidence_field = declaration.get("evidence_field")
|
|
234
|
+
metric = declaration.get("metric")
|
|
235
|
+
relative = declaration.get("path")
|
|
236
|
+
check = checks.get(str(check_id))
|
|
237
|
+
if check is None:
|
|
238
|
+
return failed(f"check {check_id!r} not present in report", code="check_missing")
|
|
239
|
+
if not all(isinstance(value, str) and value for value in (evidence_field, metric, relative)):
|
|
240
|
+
return failed(f"invalid evidence declaration for {check_id!r}", code="evidence_config_invalid")
|
|
241
|
+
target = project_root(workspace, project_dir) / relative
|
|
242
|
+
if not target.is_file():
|
|
243
|
+
return failed(f"evidence dataset does not exist: {relative}", code="file_missing")
|
|
244
|
+
try:
|
|
245
|
+
relation = _read(con, target)
|
|
246
|
+
if metric == "row_count":
|
|
247
|
+
actual = con.execute(f"SELECT COUNT(*) FROM {relation}").fetchone()[0]
|
|
248
|
+
elif metric == "invalid_geometry_count":
|
|
249
|
+
actual = con.execute(
|
|
250
|
+
f"SELECT COUNT(*) FROM {relation} WHERE NOT ST_IsValid(geom)"
|
|
251
|
+
).fetchone()[0]
|
|
252
|
+
elif metric in {"duplicate_count", "null_count"}:
|
|
253
|
+
field = declaration.get("field")
|
|
254
|
+
if not isinstance(field, str) or not field:
|
|
255
|
+
return failed(
|
|
256
|
+
f"metric {metric!r} requires field for {check_id!r}",
|
|
257
|
+
code="evidence_config_invalid",
|
|
258
|
+
)
|
|
259
|
+
identifier = field.replace('"', '""')
|
|
260
|
+
if metric == "duplicate_count":
|
|
261
|
+
actual = con.execute(
|
|
262
|
+
f'SELECT COUNT(*) FROM (SELECT "{identifier}" FROM {relation} '
|
|
263
|
+
f'GROUP BY "{identifier}" HAVING COUNT(*) > 1) duplicates'
|
|
264
|
+
).fetchone()[0]
|
|
265
|
+
else:
|
|
266
|
+
actual = con.execute(
|
|
267
|
+
f'SELECT COUNT(*) FROM {relation} WHERE "{identifier}" IS NULL'
|
|
268
|
+
).fetchone()[0]
|
|
269
|
+
else:
|
|
270
|
+
return failed(f"unsupported evidence metric: {metric}", code="evidence_config_invalid")
|
|
271
|
+
except Exception as exc: # noqa: BLE001
|
|
272
|
+
return not_testable(
|
|
273
|
+
f"could not recompute {check_id}.{evidence_field}: {exc}", code="read_error"
|
|
274
|
+
)
|
|
275
|
+
declared = check.get(evidence_field)
|
|
276
|
+
recomputed.append(
|
|
277
|
+
{"check_id": check_id, "field": evidence_field, "declared": declared, "actual": actual}
|
|
278
|
+
)
|
|
279
|
+
if declared != actual:
|
|
280
|
+
mismatches.append(f"{check_id}.{evidence_field}: declared={declared!r}, actual={actual!r}")
|
|
281
|
+
if mismatches:
|
|
282
|
+
return failed(
|
|
283
|
+
f"validation evidence does not match real data: {mismatches}",
|
|
284
|
+
code="evidence_mismatch",
|
|
285
|
+
mismatches=mismatches,
|
|
286
|
+
recomputed=recomputed,
|
|
287
|
+
)
|
|
288
|
+
return passed(f"recomputed {len(recomputed)} report evidence value(s)", recomputed=recomputed)
|