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/__init__.py
ADDED
openmapstack/__main__.py
ADDED
|
@@ -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)
|