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.
@@ -0,0 +1,6 @@
1
+ """Command-line tooling for reproducible OpenMapStack projects."""
2
+
3
+ from .validation import Check, ValidationResult, validate_project
4
+
5
+ __all__ = ["Check", "ValidationResult", "validate_project"]
6
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,112 @@
1
+ """Reusable, semantic checks over an `openmapstack-project/v1` artifact.
2
+
3
+ Every check has the signature:
4
+
5
+ def fn(workspace: Path, **args) -> AssertionResult
6
+
7
+ and inspects real files in `workspace` (a project directory) rather than
8
+ assistant prose. Checks never raise for expected "could not check"
9
+ conditions — they return `not_testable` instead, matching the four-state
10
+ vocabulary (`passed | failed | warning | not_testable`) required of
11
+ `validation/latest-report.json` itself in `references/project-spec.md`
12
+ section 6.
13
+
14
+ Two callers share this library, deliberately:
15
+
16
+ - `evals/run.py` grades eval cases with it (`contract_ci`, `mutation_tests`,
17
+ `agent_benchmark`, `integration_visual`);
18
+ - `openmapstack verify` grades a *user's own* project with it.
19
+
20
+ That is the point of it living in the shipped package rather than under
21
+ `evals/`. All but a handful of these checks are oracle-free: they hold for
22
+ any correct project on any data, so they transfer to data this repository
23
+ has never seen. The exceptions — the ones that need a known answer — are
24
+ `geodata.row_count(equals=)`, `feature_present`, `feature_absent`,
25
+ `feature_field_equals`, and `field_range`. On user data those are reachable
26
+ only through allowlisted, input-bound `validation.expectations` attestations,
27
+ never from an answer the pipeline computed and certified for itself.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ import yaml
38
+
39
+ STATUSES = ("passed", "failed", "warning", "not_testable")
40
+
41
+
42
+ @dataclass
43
+ class AssertionResult:
44
+ status: str # passed | failed | warning | not_testable
45
+ detail: str = ""
46
+ data: dict[str, Any] = field(default_factory=dict)
47
+
48
+ def __post_init__(self) -> None:
49
+ if self.status not in STATUSES:
50
+ raise ValueError(f"invalid assertion status: {self.status!r}")
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ return {"status": self.status, "detail": self.detail, **self.data}
54
+
55
+
56
+ def passed(detail: str = "", **data: Any) -> AssertionResult:
57
+ return AssertionResult("passed", detail, data)
58
+
59
+
60
+ def failed(detail: str = "", *, code: str | None = None, **data: Any) -> AssertionResult:
61
+ """``code`` is a stable, machine-readable failure identifier (e.g.
62
+ ``feature_present``). Mutation cases can require the *specific* failure
63
+ they inject, not merely status ``failed``, via ``expect_code`` in
64
+ ``expected.yaml``."""
65
+ if code is not None:
66
+ data["code"] = code
67
+ return AssertionResult("failed", detail, data)
68
+
69
+
70
+ def warning(detail: str = "", *, code: str | None = None, **data: Any) -> AssertionResult:
71
+ if code is not None:
72
+ data["code"] = code
73
+ return AssertionResult("warning", detail, data)
74
+
75
+
76
+ def not_testable(detail: str = "", *, code: str | None = None, **data: Any) -> AssertionResult:
77
+ if code is not None:
78
+ data["code"] = code
79
+ return AssertionResult("not_testable", detail, data)
80
+
81
+
82
+ def load_project_yaml(workspace: Path, project_dir: str = ".") -> dict[str, Any] | None:
83
+ path = workspace / project_dir / "project.yaml"
84
+ if not path.exists():
85
+ return None
86
+ try:
87
+ with path.open("r", encoding="utf-8") as fh:
88
+ value = yaml.safe_load(fh)
89
+ except (OSError, yaml.YAMLError):
90
+ return None
91
+ return value if isinstance(value, dict) else None
92
+
93
+
94
+ def load_json(path: Path) -> dict[str, Any] | None:
95
+ if not path.exists():
96
+ return None
97
+ with path.open("r", encoding="utf-8") as fh:
98
+ return json.load(fh)
99
+
100
+
101
+ def project_root(workspace: Path, project_dir: str = ".") -> Path:
102
+ return workspace / project_dir
103
+
104
+
105
+ def get_in(data: dict, dotted: str, default: Any = None) -> Any:
106
+ """Fetch a nested dict value using a dotted path, e.g. 'project.status'."""
107
+ cur: Any = data
108
+ for part in dotted.split("."):
109
+ if not isinstance(cur, dict) or part not in cur:
110
+ return default
111
+ cur = cur[part]
112
+ return cur
@@ -0,0 +1,318 @@
1
+ """GIS-correctness assertions that inspect real geodata via DuckDB Spatial.
2
+
3
+ See references/project-spec.md section 2.4 (processing) and 6 (validation).
4
+ No dependency on any one LLM; these run against whatever files the pipeline
5
+ (or agent) actually produced.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from . import AssertionResult, failed, not_testable, passed, project_root
14
+ from .spatial import connect_spatial
15
+
16
+
17
+ def _connect():
18
+ """Load only a preinstalled Spatial extension; grading never downloads."""
19
+ return connect_spatial()
20
+
21
+
22
+ def _read(con, path: Path):
23
+ escaped_path = path.as_posix().replace("'", "''")
24
+ suffix = path.suffix.lower()
25
+ if suffix == ".parquet":
26
+ # DuckDB Spatial's native GEOMETRY type round-trips through Parquet;
27
+ # read_parquet keeps that typed column, unlike routing through GDAL.
28
+ return f"read_parquet('{escaped_path}')"
29
+ return f"ST_Read('{escaped_path}')"
30
+
31
+
32
+ # Preferred names, most conventional first. "geometry" is what GeoPandas and
33
+ # the GeoParquet spec write; "geom" is what PostGIS and this repository's own
34
+ # fixtures use. Both are common in the wild.
35
+ _GEOMETRY_NAMES = ("geom", "geometry", "geometry_col", "the_geom", "wkb_geometry", "shape")
36
+
37
+
38
+ def _geometry_column(con, rel: str, preferred: str | None = None) -> str | None:
39
+ """Find the geometry column instead of assuming one is called ``geom``.
40
+
41
+ Hardcoding a name is fine against fixtures a generator wrote and wrong
42
+ against real data: the same check would report `not_testable` on most
43
+ real-world GeoParquet, which names the column `geometry`. A check that
44
+ cannot run on the data it is meant to inspect is not a check.
45
+ """
46
+ try:
47
+ columns = con.execute(f"SELECT * FROM {rel} LIMIT 0").description or []
48
+ except Exception: # noqa: BLE001
49
+ return preferred
50
+ by_name = {str(name): str(type_name).upper() for name, type_name, *_ in columns}
51
+ if preferred and preferred in by_name:
52
+ return preferred
53
+ typed = [name for name, type_name in by_name.items() if type_name == "GEOMETRY"]
54
+ if len(typed) == 1:
55
+ return typed[0]
56
+ candidates = typed or list(by_name)
57
+ for candidate in _GEOMETRY_NAMES:
58
+ if candidate in candidates:
59
+ return candidate
60
+ return typed[0] if typed else preferred
61
+
62
+
63
+ def row_count(workspace: Path, path: str, equals: int | None = None, at_least: int | None = None,
64
+ at_most: int | None = None, project_dir: str = ".") -> AssertionResult:
65
+ target = project_root(workspace, project_dir) / path
66
+ if not target.exists():
67
+ return failed(f"{path} does not exist", code="file_missing")
68
+ con = _connect()
69
+ if con is None:
70
+ return not_testable("duckdb spatial not available in this environment", code="duckdb_unavailable")
71
+ try:
72
+ rel = _read(con, target)
73
+ count = con.execute(f"SELECT COUNT(*) FROM {rel}").fetchone()[0]
74
+ except Exception as exc: # noqa: BLE001
75
+ return not_testable(f"could not read {path}: {exc}", code="read_error")
76
+
77
+ if equals is not None and count != equals:
78
+ return failed(f"{path} row count {count} != expected {equals}", code="row_count_equals")
79
+ if at_least is not None and count < at_least:
80
+ return failed(f"{path} row count {count} < minimum {at_least}", code="row_count_at_least")
81
+ if at_most is not None and count > at_most:
82
+ return failed(f"{path} row count {count} > maximum {at_most}", code="row_count_at_most")
83
+ return passed(f"{path} row count {count} satisfies constraints", row_count=count)
84
+
85
+
86
+ def geometry_all_valid(workspace: Path, path: str, project_dir: str = ".") -> AssertionResult:
87
+ target = project_root(workspace, project_dir) / path
88
+ if not target.exists():
89
+ return failed(f"{path} does not exist", code="file_missing")
90
+ con = _connect()
91
+ if con is None:
92
+ return not_testable("duckdb spatial not available in this environment", code="duckdb_unavailable")
93
+ try:
94
+ rel = _read(con, target)
95
+ column = _geometry_column(con, rel)
96
+ if column is None:
97
+ return not_testable(f"{path} has no geometry column", code="geometry_column_missing")
98
+ total, invalid = con.execute(
99
+ f'SELECT COUNT(*), SUM(CASE WHEN NOT ST_IsValid("{column}") THEN 1 ELSE 0 END) FROM {rel}'
100
+ ).fetchone()
101
+ except Exception as exc: # noqa: BLE001
102
+ return not_testable(f"could not validate geometry in {path}: {exc}", code="read_error")
103
+ invalid = invalid or 0
104
+ if invalid:
105
+ return failed(f"{path}: {invalid}/{total} features have invalid geometry", code="invalid_geometry")
106
+ return passed(f"{path}: all {total} features have valid geometry")
107
+
108
+
109
+ def no_duplicate_ids(workspace: Path, path: str, id_field: str, project_dir: str = ".") -> AssertionResult:
110
+ target = project_root(workspace, project_dir) / path
111
+ if not target.exists():
112
+ return failed(f"{path} does not exist", code="file_missing")
113
+ con = _connect()
114
+ if con is None:
115
+ return not_testable("duckdb spatial not available in this environment", code="duckdb_unavailable")
116
+ try:
117
+ rel = _read(con, target)
118
+ dup = con.execute(
119
+ f'SELECT COUNT(*) FROM (SELECT "{id_field}" FROM {rel} '
120
+ f'GROUP BY "{id_field}" HAVING COUNT(*) > 1) t'
121
+ ).fetchone()[0]
122
+ except Exception as exc: # noqa: BLE001
123
+ return not_testable(f"could not check duplicates in {path}: {exc}", code="read_error")
124
+ if dup:
125
+ return failed(f"{path}: {dup} duplicate value(s) for {id_field}", code="duplicate_ids")
126
+ return passed(f"{path}: no duplicate {id_field} values")
127
+
128
+
129
+ def no_null_ids(workspace: Path, path: str, id_field: str, project_dir: str = ".") -> AssertionResult:
130
+ target = project_root(workspace, project_dir) / path
131
+ if not target.exists():
132
+ return failed(f"{path} does not exist", code="file_missing")
133
+ con = _connect()
134
+ if con is None:
135
+ return not_testable("duckdb spatial not available in this environment", code="duckdb_unavailable")
136
+ try:
137
+ rel = _read(con, target)
138
+ nulls = con.execute(f'SELECT COUNT(*) FROM {rel} WHERE "{id_field}" IS NULL').fetchone()[0]
139
+ except Exception as exc: # noqa: BLE001
140
+ return not_testable(f"could not check nulls in {path}: {exc}", code="read_error")
141
+ if nulls:
142
+ return failed(f"{path}: {nulls} null {id_field} values", code="null_ids")
143
+ return passed(f"{path}: no null {id_field} values")
144
+
145
+
146
+ def feature_field_equals(
147
+ workspace: Path, path: str, id_field: str, id: Any, field: str, equals: Any,
148
+ project_dir: str = ".",
149
+ ) -> AssertionResult:
150
+ target = project_root(workspace, project_dir) / path
151
+ if not target.exists():
152
+ return failed(f"{path} does not exist", code="file_missing")
153
+ con = _connect()
154
+ if con is None:
155
+ return not_testable("duckdb spatial not available in this environment", code="duckdb_unavailable")
156
+ try:
157
+ rel = _read(con, target)
158
+ row = con.execute(
159
+ f'SELECT "{field}" FROM {rel} WHERE "{id_field}" = ?', [id]
160
+ ).fetchone()
161
+ except Exception as exc: # noqa: BLE001
162
+ return not_testable(f"could not query {path}: {exc}", code="read_error")
163
+ if row is None:
164
+ return failed(f"{path}: no feature with {id_field} = {id!r}", code="feature_not_found")
165
+ actual = row[0]
166
+ if str(actual) != str(equals):
167
+ return failed(
168
+ f"{path}: feature {id!r} field {field} = {actual!r}, expected {equals!r}",
169
+ code="field_value_mismatch",
170
+ )
171
+ return passed(f"{path}: feature {id!r} field {field} == {equals!r}")
172
+
173
+
174
+ def feature_present(workspace: Path, path: str, id_field: str, id: Any, project_dir: str = ".") -> AssertionResult:
175
+ target = project_root(workspace, project_dir) / path
176
+ if not target.exists():
177
+ return failed(f"{path} does not exist", code="file_missing")
178
+ con = _connect()
179
+ if con is None:
180
+ return not_testable("duckdb spatial not available in this environment", code="duckdb_unavailable")
181
+ try:
182
+ rel = _read(con, target)
183
+ row = con.execute(f'SELECT 1 FROM {rel} WHERE "{id_field}" = ? LIMIT 1', [id]).fetchone()
184
+ except Exception as exc: # noqa: BLE001
185
+ return not_testable(f"could not query {path}: {exc}", code="read_error")
186
+ if row is None:
187
+ return failed(f"{path}: feature {id_field}={id!r} not present (expected inclusion)", code="feature_missing")
188
+ return passed(f"{path}: feature {id_field}={id!r} present")
189
+
190
+
191
+ def feature_absent(workspace: Path, path: str, id_field: str, id: Any, project_dir: str = ".") -> AssertionResult:
192
+ target = project_root(workspace, project_dir) / path
193
+ if not target.exists():
194
+ return not_testable(f"{path} does not exist, cannot confirm exclusion", code="file_missing")
195
+ con = _connect()
196
+ if con is None:
197
+ return not_testable("duckdb spatial not available in this environment", code="duckdb_unavailable")
198
+ try:
199
+ rel = _read(con, target)
200
+ row = con.execute(f'SELECT 1 FROM {rel} WHERE "{id_field}" = ? LIMIT 1', [id]).fetchone()
201
+ except Exception as exc: # noqa: BLE001
202
+ return not_testable(f"could not query {path}: {exc}", code="read_error")
203
+ if row is not None:
204
+ return failed(f"{path}: feature {id_field}={id!r} present but expected excluded", code="feature_present")
205
+ return passed(f"{path}: feature {id_field}={id!r} correctly excluded")
206
+
207
+
208
+ def field_range(
209
+ workspace: Path, path: str, field: str, min: float | None = None, max: float | None = None,
210
+ project_dir: str = ".",
211
+ ) -> AssertionResult:
212
+ target = project_root(workspace, project_dir) / path
213
+ if not target.exists():
214
+ return failed(f"{path} does not exist", code="file_missing")
215
+ con = _connect()
216
+ if con is None:
217
+ return not_testable("duckdb spatial not available in this environment", code="duckdb_unavailable")
218
+ try:
219
+ rel = _read(con, target)
220
+ clauses = []
221
+ if min is not None:
222
+ clauses.append(f'"{field}" < {min}')
223
+ if max is not None:
224
+ clauses.append(f'"{field}" > {max}')
225
+ where = " OR ".join(clauses) if clauses else "FALSE"
226
+ out_of_range = con.execute(f"SELECT COUNT(*) FROM {rel} WHERE {where}").fetchone()[0]
227
+ except Exception as exc: # noqa: BLE001
228
+ return not_testable(f"could not check range in {path}: {exc}", code="read_error")
229
+ if out_of_range:
230
+ return failed(
231
+ f"{path}: {out_of_range} feature(s) with {field} outside [{min}, {max}]",
232
+ code="field_out_of_range",
233
+ )
234
+ return passed(f"{path}: all features have {field} within [{min}, {max}]")
235
+
236
+
237
+ def crs_not_used_for_metrics(workspace: Path, project_dir: str = ".",
238
+ forbidden_crs: tuple[str, ...] = ("EPSG:4326", "EPSG:3857")) -> AssertionResult:
239
+ """Require a declared projected CRS for actual metric operations.
240
+
241
+ Read, write, storage, and reprojection steps may legitimately mention a
242
+ geographic CRS. Distance/area/buffer/length/nearest operations may not.
243
+ """
244
+ from . import get_in, load_project_yaml
245
+
246
+ proj = load_project_yaml(workspace, project_dir)
247
+ if proj is None:
248
+ return failed("project.yaml missing", code="manifest_missing")
249
+ analysis_crs = get_in(proj, "processing.analysis_crs")
250
+ if not isinstance(analysis_crs, str) or not analysis_crs.strip():
251
+ return failed("processing.analysis_crs is required", code="analysis_crs_missing")
252
+ steps = get_in(proj, "processing.steps", []) or []
253
+ metric_tokens = ("area", "buffer", "distance", "length", "nearest", "proximity")
254
+ metric_steps = [
255
+ step
256
+ for step in steps
257
+ if isinstance(step, dict)
258
+ and any(token in str(step.get("operation", "")).lower() for token in metric_tokens)
259
+ ]
260
+ if metric_steps and analysis_crs.upper() in {item.upper() for item in forbidden_crs}:
261
+ return failed(
262
+ f"processing.analysis_crs is {analysis_crs}, forbidden for metric operations",
263
+ code="forbidden_analysis_crs",
264
+ )
265
+ forbidden = {item.upper() for item in forbidden_crs}
266
+ bad_steps = [
267
+ step.get("id")
268
+ for step in metric_steps
269
+ if isinstance(step.get("crs"), str) and step["crs"].upper() in forbidden
270
+ ]
271
+ if bad_steps:
272
+ return failed(
273
+ f"steps using forbidden CRS for metric operation: {bad_steps}",
274
+ code="forbidden_step_crs",
275
+ )
276
+ return passed(
277
+ f"analysis_crs {analysis_crs} is valid for {len(metric_steps)} metric operation(s); "
278
+ "storage/load/reprojection steps were excluded"
279
+ )
280
+
281
+
282
+ def dataset_crs_is(
283
+ workspace: Path,
284
+ path: str,
285
+ expected: str,
286
+ geometry_field: str | None = None,
287
+ project_dir: str = ".",
288
+ ) -> AssertionResult:
289
+ """Read CRS metadata from the actual dataset rather than the manifest.
290
+
291
+ ``geometry_field`` is resolved from the data when not given, so this works
292
+ on real datasets whose geometry column is not called ``geom``.
293
+ """
294
+ target = project_root(workspace, project_dir) / path
295
+ if not target.exists():
296
+ return failed(f"{path} does not exist", code="file_missing")
297
+ con = _connect()
298
+ if con is None:
299
+ return not_testable("duckdb spatial not available in this environment", code="duckdb_unavailable")
300
+ try:
301
+ rel = _read(con, target)
302
+ column = _geometry_column(con, rel, geometry_field)
303
+ if column is None:
304
+ return not_testable(f"{path} has no geometry column", code="geometry_column_missing")
305
+ rows = con.execute(f'SELECT DISTINCT ST_CRS("{column}") FROM {rel}').fetchall()
306
+ except Exception as exc: # noqa: BLE001
307
+ return not_testable(f"could not inspect CRS metadata in {path}: {exc}", code="read_error")
308
+ actual = sorted({str(row[0]).upper() for row in rows if row and row[0]})
309
+ if not actual:
310
+ return failed(f"{path} has no readable CRS metadata", code="dataset_crs_missing")
311
+ if actual != [expected.upper()]:
312
+ return failed(
313
+ f"{path} CRS metadata {actual} != expected {expected.upper()}",
314
+ code="dataset_crs_mismatch",
315
+ actual=actual,
316
+ expected=expected.upper(),
317
+ )
318
+ return passed(f"{path} actual CRS metadata is {expected.upper()}", actual=actual)