openmapstack 0.3.0__py3-none-any.whl → 0.4.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 +1 -1
- openmapstack/api.py +2 -0
- openmapstack/checks/geodata.py +79 -13
- openmapstack/checks/qgis.py +151 -4
- openmapstack/checks/rerun.py +57 -0
- openmapstack/checks/validation.py +51 -4
- openmapstack/cli.py +308 -8
- openmapstack/collection.py +191 -0
- openmapstack/connectors/__init__.py +101 -14
- openmapstack/connectors/bigquery.py +410 -0
- openmapstack/connectors/motherduck.py +272 -0
- openmapstack/parameters.py +64 -2
- openmapstack/rerun.py +6 -0
- openmapstack/sampling.py +202 -0
- openmapstack/schemas/project-v1.schema.json +3 -1
- openmapstack/schemas/skill-snapshot-v2.schema.json +25 -0
- openmapstack/snapshot.py +3 -0
- openmapstack/validation.py +92 -52
- openmapstack/verify.py +3 -0
- openmapstack-0.4.0.dist-info/METADATA +344 -0
- openmapstack-0.4.0.dist-info/RECORD +45 -0
- openmapstack-0.3.0.dist-info/METADATA +0 -300
- openmapstack-0.3.0.dist-info/RECORD +0 -40
- {openmapstack-0.3.0.dist-info → openmapstack-0.4.0.dist-info}/WHEEL +0 -0
- {openmapstack-0.3.0.dist-info → openmapstack-0.4.0.dist-info}/entry_points.txt +0 -0
- {openmapstack-0.3.0.dist-info → openmapstack-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {openmapstack-0.3.0.dist-info → openmapstack-0.4.0.dist-info}/top_level.txt +0 -0
openmapstack/__init__.py
CHANGED
openmapstack/api.py
CHANGED
|
@@ -263,6 +263,8 @@ def api_info() -> dict[str, Any]:
|
|
|
263
263
|
"package_version": __version__,
|
|
264
264
|
"check_api_version": CHECK_API_VERSION,
|
|
265
265
|
"project_schema": PROJECT_SCHEMA,
|
|
266
|
+
"skill_snapshot_schemas": ["openmapstack-skill-snapshot/v1", "openmapstack-skill-snapshot/v2"],
|
|
267
|
+
"benchmark_arm_schemas": ["openmapstack-benchmark-arm/v1", "openmapstack-benchmark-arm/v2"],
|
|
266
268
|
"result_schemas": {
|
|
267
269
|
"check": CHECK_RESULT_SCHEMA,
|
|
268
270
|
"verify": VERIFY_RESULT_SCHEMA,
|
openmapstack/checks/geodata.py
CHANGED
|
@@ -48,9 +48,9 @@ def _geometry_column(con, rel: str, preferred: str | None = None) -> str | None:
|
|
|
48
48
|
except Exception: # noqa: BLE001
|
|
49
49
|
return preferred
|
|
50
50
|
by_name = {str(name): str(type_name).upper() for name, type_name, *_ in columns}
|
|
51
|
-
if preferred
|
|
52
|
-
return preferred
|
|
53
|
-
typed = [name for name, type_name in by_name.items() if type_name == "GEOMETRY"]
|
|
51
|
+
if preferred:
|
|
52
|
+
return preferred if preferred in by_name else None
|
|
53
|
+
typed = [name for name, type_name in by_name.items() if type_name == "GEOMETRY" or type_name.startswith("GEOMETRY(")]
|
|
54
54
|
if len(typed) == 1:
|
|
55
55
|
return typed[0]
|
|
56
56
|
candidates = typed or list(by_name)
|
|
@@ -60,6 +60,37 @@ def _geometry_column(con, rel: str, preferred: str | None = None) -> str | None:
|
|
|
60
60
|
return typed[0] if typed else preferred
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
def _geometry_expression(con, rel: str, preferred: str | None = None) -> str | None:
|
|
64
|
+
"""Resolve typed geometry or WKB without assigning an invented CRS.
|
|
65
|
+
|
|
66
|
+
Spatial reads GeoParquet metadata into GEOMETRY (including CRS-qualified
|
|
67
|
+
types). Bare WKB Parquet remains BLOB: it can be checked for validity, but
|
|
68
|
+
ST_CRS on its decoded value correctly reports missing metadata.
|
|
69
|
+
"""
|
|
70
|
+
column = _geometry_column(con, rel, preferred)
|
|
71
|
+
if column is None:
|
|
72
|
+
return None
|
|
73
|
+
columns = con.execute(f"SELECT * FROM {rel} LIMIT 0").description or []
|
|
74
|
+
types = {str(name): str(kind).upper() for name, kind, *_ in columns}
|
|
75
|
+
identifier = '"' + column.replace('"', '""') + '"'
|
|
76
|
+
if types.get(column) == "BLOB":
|
|
77
|
+
return f"ST_GeomFromWKB({identifier})"
|
|
78
|
+
if types.get(column, "").startswith("GEOMETRY"):
|
|
79
|
+
return identifier
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _invalid_geometry_count(con, rel: str, preferred: str | None = None) -> int | None:
|
|
84
|
+
expression = _geometry_expression(con, rel, preferred)
|
|
85
|
+
if expression is None:
|
|
86
|
+
return None
|
|
87
|
+
# Missing geometries cannot silently count as valid via SQL NULL semantics.
|
|
88
|
+
return con.execute(
|
|
89
|
+
f"SELECT COUNT(*) FROM {rel} "
|
|
90
|
+
f"WHERE {expression} IS NULL OR NOT ST_IsValid({expression})"
|
|
91
|
+
).fetchone()[0]
|
|
92
|
+
|
|
93
|
+
|
|
63
94
|
def row_count(workspace: Path, path: str, equals: int | None = None, at_least: int | None = None,
|
|
64
95
|
at_most: int | None = None, project_dir: str = ".") -> AssertionResult:
|
|
65
96
|
target = project_root(workspace, project_dir) / path
|
|
@@ -92,12 +123,10 @@ def geometry_all_valid(workspace: Path, path: str, project_dir: str = ".") -> As
|
|
|
92
123
|
return not_testable("duckdb spatial not available in this environment", code="duckdb_unavailable")
|
|
93
124
|
try:
|
|
94
125
|
rel = _read(con, target)
|
|
95
|
-
|
|
96
|
-
if
|
|
97
|
-
return
|
|
98
|
-
total
|
|
99
|
-
f'SELECT COUNT(*), SUM(CASE WHEN NOT ST_IsValid("{column}") THEN 1 ELSE 0 END) FROM {rel}'
|
|
100
|
-
).fetchone()
|
|
126
|
+
invalid = _invalid_geometry_count(con, rel)
|
|
127
|
+
if invalid is None:
|
|
128
|
+
return failed(f"{path} has no geometry column", code="geometry_column_missing")
|
|
129
|
+
total = con.execute(f"SELECT COUNT(*) FROM {rel}").fetchone()[0]
|
|
101
130
|
except Exception as exc: # noqa: BLE001
|
|
102
131
|
return not_testable(f"could not validate geometry in {path}: {exc}", code="read_error")
|
|
103
132
|
invalid = invalid or 0
|
|
@@ -299,10 +328,14 @@ def dataset_crs_is(
|
|
|
299
328
|
return not_testable("duckdb spatial not available in this environment", code="duckdb_unavailable")
|
|
300
329
|
try:
|
|
301
330
|
rel = _read(con, target)
|
|
302
|
-
|
|
303
|
-
if
|
|
304
|
-
return
|
|
305
|
-
|
|
331
|
+
expression = _geometry_expression(con, rel, geometry_field)
|
|
332
|
+
if expression is None:
|
|
333
|
+
return failed(f"{path} has no geometry column", code="geometry_column_missing")
|
|
334
|
+
# The NULL row preserves a typed column's CRS even for an empty dataset.
|
|
335
|
+
rows = con.execute(
|
|
336
|
+
f"SELECT DISTINCT ST_CRS(geometry_value) FROM "
|
|
337
|
+
f"(SELECT {expression} AS geometry_value FROM {rel} UNION ALL SELECT NULL)"
|
|
338
|
+
).fetchall()
|
|
306
339
|
except Exception as exc: # noqa: BLE001
|
|
307
340
|
return not_testable(f"could not inspect CRS metadata in {path}: {exc}", code="read_error")
|
|
308
341
|
actual = sorted({str(row[0]).upper() for row in rows if row and row[0]})
|
|
@@ -316,3 +349,36 @@ def dataset_crs_is(
|
|
|
316
349
|
expected=expected.upper(),
|
|
317
350
|
)
|
|
318
351
|
return passed(f"{path} actual CRS metadata is {expected.upper()}", actual=actual)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# GeoParquet always stores longitude/latitude and defaults WGS 84 to
|
|
355
|
+
# OGC:CRS84, so a declared EPSG:4326 is written as either identifier.
|
|
356
|
+
_EQUIVALENT_CRS = ({"EPSG:4326", "OGC:CRS84"},)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def dataset_crs_matches_storage_crs(
|
|
360
|
+
workspace: Path,
|
|
361
|
+
path: str,
|
|
362
|
+
geometry_field: str | None = None,
|
|
363
|
+
project_dir: str = ".",
|
|
364
|
+
) -> AssertionResult:
|
|
365
|
+
"""Require a dataset's real CRS metadata to match `processing.storage_crs`.
|
|
366
|
+
|
|
367
|
+
Checks that the project writes what it declares, without prescribing
|
|
368
|
+
which storage CRS it should choose.
|
|
369
|
+
"""
|
|
370
|
+
from . import get_in, load_project_yaml
|
|
371
|
+
|
|
372
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
373
|
+
if proj is None:
|
|
374
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
375
|
+
declared = get_in(proj, "processing.storage_crs")
|
|
376
|
+
if not isinstance(declared, str) or not declared.strip():
|
|
377
|
+
return failed("processing.storage_crs is not declared", code="storage_crs_missing")
|
|
378
|
+
declared = declared.strip().upper()
|
|
379
|
+
for group in _EQUIVALENT_CRS:
|
|
380
|
+
if declared in group:
|
|
381
|
+
results = [dataset_crs_is(workspace, path, crs, geometry_field, project_dir) for crs in sorted(group)]
|
|
382
|
+
match = next((result for result in results if result.status == "passed"), None)
|
|
383
|
+
return match or results[0]
|
|
384
|
+
return dataset_crs_is(workspace, path, declared, geometry_field, project_dir)
|
openmapstack/checks/qgis.py
CHANGED
|
@@ -9,13 +9,15 @@ validation isn't available.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import html
|
|
12
|
+
import json
|
|
12
13
|
import re
|
|
14
|
+
import sqlite3
|
|
13
15
|
import tempfile
|
|
14
16
|
import zipfile
|
|
15
17
|
from pathlib import Path
|
|
16
18
|
from typing import Any
|
|
17
19
|
|
|
18
|
-
from . import AssertionResult, failed, get_in, load_project_yaml, not_testable, passed, project_root
|
|
20
|
+
from . import AssertionResult, failed, get_in, load_project_yaml, not_testable, passed, project_root, warning
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
def _extract_qgs_xml(qgz_path: Path) -> str | None:
|
|
@@ -31,9 +33,13 @@ def _extract_qgs_xml(qgz_path: Path) -> str | None:
|
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
def static_valid(workspace: Path, path: str = "project.qgz", project_dir: str = ".") -> AssertionResult:
|
|
34
|
-
"""The .qgz opens as a zip containing a .qgs, every <datasource>
|
|
35
|
-
|
|
36
|
-
a layername= (otherwise GDAL silently loads a non-spatial attribute table).
|
|
36
|
+
"""The .qgz opens as a zip containing a .qgs, every local <datasource> is a
|
|
37
|
+
relative path that resolves on disk, and GeoPackage datasources declare
|
|
38
|
+
a layername= (otherwise GDAL silently loads a non-spatial attribute table).
|
|
39
|
+
|
|
40
|
+
An absolute path fails even when it exists: a QGIS-authored project once
|
|
41
|
+
shipped the author's own paths and passed because they resolved locally.
|
|
42
|
+
"""
|
|
37
43
|
qgz_path = project_root(workspace, project_dir) / path
|
|
38
44
|
if not qgz_path.exists():
|
|
39
45
|
return failed(f"{path} does not exist", code="file_missing")
|
|
@@ -57,6 +63,10 @@ def static_valid(workspace: Path, path: str = "project.qgz", project_dir: str =
|
|
|
57
63
|
if ds.startswith(("http", "type=xyz", "contextualWMSLegend", "crs=")) or "url=" in ds:
|
|
58
64
|
continue
|
|
59
65
|
raw_path = ds.split("|", 1)[0]
|
|
66
|
+
if Path(raw_path).is_absolute() or re.match(r"^[A-Za-z]:[\\/]", raw_path):
|
|
67
|
+
# Resolves on the machine that wrote it and nowhere else.
|
|
68
|
+
errors.append(f"datasource uses an absolute path: {raw_path}")
|
|
69
|
+
continue
|
|
60
70
|
if raw_path.lower().endswith(".gpkg") and "layername=" not in ds:
|
|
61
71
|
errors.append(f"GeoPackage datasource missing layername=: {ds}")
|
|
62
72
|
resolved = (root / raw_path).resolve() if raw_path.startswith("./") or not raw_path.startswith("/") else Path(raw_path)
|
|
@@ -68,6 +78,143 @@ def static_valid(workspace: Path, path: str = "project.qgz", project_dir: str =
|
|
|
68
78
|
return passed(f"{path} static-valid: {len(datasources)} datasource(s), all files resolve")
|
|
69
79
|
|
|
70
80
|
|
|
81
|
+
#: File formats whose GDAL drivers are built into every QGIS build. Anything
|
|
82
|
+
#: else may need an optional driver: Debian/Ubuntu QGIS packages ship without
|
|
83
|
+
#: Parquet and Arrow (checked on QGIS 3.40.15, GDAL 3.12.2), and formats such
|
|
84
|
+
#: as Personal Geodatabase depend on extra libraries.
|
|
85
|
+
PORTABLE_SUFFIXES = frozenset({".gpkg", ".geojson", ".json", ".fgb", ".shp", ".tif", ".tiff"})
|
|
86
|
+
OPTIONAL_DRIVERS = {".parquet": "Parquet", ".geoparquet": "Parquet", ".arrow": "Arrow", ".feather": "Arrow", ".arrows": "Arrow"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_provider_datasource(datasource: str) -> bool:
|
|
90
|
+
"""Remote services and provider connection strings are not local files."""
|
|
91
|
+
location = datasource.split("|", 1)[0]
|
|
92
|
+
return datasource.startswith(("http", "type=xyz", "contextualWMSLegend", "crs=")) or "=" in location
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def datasources_portable(workspace: Path, path: str = "project.qgz", project_dir: str = ".") -> AssertionResult:
|
|
96
|
+
"""Every local file datasource uses a format every QGIS build reads.
|
|
97
|
+
|
|
98
|
+
Portable formats are an allowlist: GeoPackage, GeoJSON, FlatGeobuf,
|
|
99
|
+
Shapefile and GeoTIFF. A live trial pointed QGIS at GeoParquet; a stock
|
|
100
|
+
Ubuntu QGIS opened the layer invalid and drew nothing while every static
|
|
101
|
+
check passed. Remote and provider datasources are out of scope. A warning,
|
|
102
|
+
not a failure: builds with the extra driver do open other formats.
|
|
103
|
+
"""
|
|
104
|
+
xml, _qgz_path, error = _qgs_xml(workspace, path, project_dir)
|
|
105
|
+
if error == "file_missing":
|
|
106
|
+
return failed(f"{path} does not exist", code="file_missing")
|
|
107
|
+
if error == "not_a_zip":
|
|
108
|
+
return failed(f"{path} is not a valid zip archive", code="not_a_zip")
|
|
109
|
+
if xml is None:
|
|
110
|
+
return failed(f"{path} does not contain a .qgs document", code="no_qgs_document")
|
|
111
|
+
unportable: dict[str, str] = {}
|
|
112
|
+
for datasource in re.findall(r"<datasource>(.*?)</datasource>", xml, re.DOTALL):
|
|
113
|
+
datasource = datasource.strip()
|
|
114
|
+
if not datasource or _is_provider_datasource(datasource):
|
|
115
|
+
continue
|
|
116
|
+
suffix = Path(datasource.split("|", 1)[0]).suffix.lower()
|
|
117
|
+
if suffix not in PORTABLE_SUFFIXES:
|
|
118
|
+
unportable[datasource] = OPTIONAL_DRIVERS.get(suffix, f"{suffix or 'no extension'} is outside the portable set")
|
|
119
|
+
if unportable:
|
|
120
|
+
return warning(
|
|
121
|
+
"layers use formats many QGIS builds cannot open, so they load invalid there: "
|
|
122
|
+
+ ", ".join(f"{source} ({reason})" for source, reason in unportable.items())
|
|
123
|
+
+ "; point QGIS at a GeoPackage, GeoJSON or FlatGeobuf copy",
|
|
124
|
+
code="datasource_format_not_portable",
|
|
125
|
+
datasources=unportable,
|
|
126
|
+
)
|
|
127
|
+
return passed(f"{path}: every local file datasource uses a format every QGIS build reads")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _data_crs(root: Path, datasource: str) -> str | None:
|
|
131
|
+
"""The CRS a GeoJSON or GeoPackage layer's data is actually in, or ``None``.
|
|
132
|
+
|
|
133
|
+
GeoJSON without a ``crs`` member is WGS84 by RFC 7946, which is how GDAL
|
|
134
|
+
reads it. GeoPackage records the SRS per geometry table.
|
|
135
|
+
"""
|
|
136
|
+
location, *options = datasource.split("|")
|
|
137
|
+
path = root / location
|
|
138
|
+
if not path.is_file():
|
|
139
|
+
return None
|
|
140
|
+
suffix = path.suffix.lower()
|
|
141
|
+
if suffix in {".geojson", ".json"}:
|
|
142
|
+
try:
|
|
143
|
+
crs = json.loads(path.read_text(encoding="utf-8")).get("crs")
|
|
144
|
+
except (OSError, ValueError, AttributeError):
|
|
145
|
+
return None
|
|
146
|
+
if not crs:
|
|
147
|
+
return "EPSG:4326"
|
|
148
|
+
name = str((crs.get("properties") or {}).get("name", "")) if isinstance(crs, dict) else ""
|
|
149
|
+
if "CRS84" in name.upper():
|
|
150
|
+
return "EPSG:4326"
|
|
151
|
+
match = re.search(r"EPSG:+(\d+)", name, re.IGNORECASE)
|
|
152
|
+
return f"EPSG:{match.group(1)}" if match else None
|
|
153
|
+
if suffix == ".gpkg":
|
|
154
|
+
layer = next((option.split("=", 1)[1] for option in options if option.startswith("layername=")), None)
|
|
155
|
+
try:
|
|
156
|
+
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
157
|
+
try:
|
|
158
|
+
rows = connection.execute(
|
|
159
|
+
"SELECT g.table_name, s.organization, s.organization_coordsys_id FROM gpkg_geometry_columns g"
|
|
160
|
+
" JOIN gpkg_spatial_ref_sys s ON g.srs_id = s.srs_id"
|
|
161
|
+
).fetchall()
|
|
162
|
+
finally:
|
|
163
|
+
connection.close()
|
|
164
|
+
except sqlite3.Error:
|
|
165
|
+
return None
|
|
166
|
+
matches = [row for row in rows if layer is None or row[0] == layer]
|
|
167
|
+
if len(matches) != 1 or str(matches[0][1]).upper() != "EPSG":
|
|
168
|
+
return None
|
|
169
|
+
return f"EPSG:{matches[0][2]}"
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def layer_crs_matches_data(workspace: Path, path: str = "project.qgz", project_dir: str = ".") -> AssertionResult:
|
|
174
|
+
"""Each GeoJSON or GeoPackage layer's declared CRS is the CRS of its data.
|
|
175
|
+
|
|
176
|
+
QGIS trusts a hand-written ``<srs>``. A live trial declared EPSG:3301 on
|
|
177
|
+
WGS84 GeoJSON, so QGIS placed the roads a few metres from the grid's
|
|
178
|
+
origin and the map drew nothing where the analysis was, while every
|
|
179
|
+
static check passed. Layers whose data CRS cannot be read here are
|
|
180
|
+
skipped; missing files are reported by ``qgis.static_valid``.
|
|
181
|
+
"""
|
|
182
|
+
xml, _qgz_path, error = _qgs_xml(workspace, path, project_dir)
|
|
183
|
+
if error == "file_missing":
|
|
184
|
+
return failed(f"{path} does not exist", code="file_missing")
|
|
185
|
+
if error == "not_a_zip":
|
|
186
|
+
return failed(f"{path} is not a valid zip archive", code="not_a_zip")
|
|
187
|
+
if xml is None:
|
|
188
|
+
return failed(f"{path} does not contain a .qgs document", code="no_qgs_document")
|
|
189
|
+
root = project_root(workspace, project_dir)
|
|
190
|
+
compared: dict[str, dict[str, str]] = {}
|
|
191
|
+
mismatched: list[str] = []
|
|
192
|
+
for layer_xml in re.findall(r"<maplayer[ >].*?</maplayer>", xml, re.DOTALL):
|
|
193
|
+
datasource = html.unescape((re.search(r"<datasource>(.*?)</datasource>", layer_xml, re.DOTALL) or [None, ""])[1]).strip()
|
|
194
|
+
authid = re.search(r"<authid>(.*?)</authid>", layer_xml, re.DOTALL)
|
|
195
|
+
if not datasource or authid is None or _is_provider_datasource(datasource):
|
|
196
|
+
continue
|
|
197
|
+
actual = _data_crs(root, datasource)
|
|
198
|
+
if actual is None:
|
|
199
|
+
continue
|
|
200
|
+
name_match = re.search(r"<layername>(.*?)</layername>", layer_xml, re.DOTALL)
|
|
201
|
+
name = name_match.group(1).strip() if name_match else datasource
|
|
202
|
+
declared = authid.group(1).strip()
|
|
203
|
+
compared[name] = {"declared": declared, "data": actual}
|
|
204
|
+
if declared.upper() != actual.upper():
|
|
205
|
+
mismatched.append(name)
|
|
206
|
+
if mismatched:
|
|
207
|
+
return failed(
|
|
208
|
+
"layers declare a CRS their data is not in, so QGIS draws them in the wrong place: "
|
|
209
|
+
+ ", ".join(f"{name} (declared {compared[name]['declared']}, data {compared[name]['data']})" for name in mismatched),
|
|
210
|
+
code="layer_crs_mismatch",
|
|
211
|
+
layers={name: compared[name] for name in mismatched},
|
|
212
|
+
)
|
|
213
|
+
if not compared:
|
|
214
|
+
return not_testable("no local GeoJSON or GeoPackage layer to compare", code="no_comparable_layers")
|
|
215
|
+
return passed(f"all {len(compared)} comparable layers declare the CRS their data is in", layers=compared)
|
|
216
|
+
|
|
217
|
+
|
|
71
218
|
_QGIS_APPLICATION: Any = None
|
|
72
219
|
|
|
73
220
|
|
openmapstack/checks/rerun.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import json
|
|
7
7
|
import shlex
|
|
8
|
+
import sqlite3
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
@@ -171,12 +172,68 @@ def _parquet_snapshot(path: Path, ignored_fields: set[str]) -> dict[str, Any]:
|
|
|
171
172
|
return {"schema": schema, "rows": sorted(normalized_rows, key=_stable_json)}
|
|
172
173
|
|
|
173
174
|
|
|
175
|
+
def _geopackage_snapshot(path: Path, ignored_fields: set[str]) -> Any:
|
|
176
|
+
"""Schema, core metadata and rows of a GeoPackage, without its write time.
|
|
177
|
+
|
|
178
|
+
``gpkg_contents.last_change`` records when the file was written, so a
|
|
179
|
+
byte hash reports every rebuild as changed even when no feature differs.
|
|
180
|
+
Everything a consumer interprets is kept: column definitions, geometry
|
|
181
|
+
columns and the SRS definitions they reference. Geometry blobs are
|
|
182
|
+
compared as stored: one writer encodes one geometry identically.
|
|
183
|
+
"""
|
|
184
|
+
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
185
|
+
|
|
186
|
+
def metadata(query: str) -> list[list[Any]]:
|
|
187
|
+
try:
|
|
188
|
+
return [list(row) for row in connection.execute(query).fetchall()]
|
|
189
|
+
except sqlite3.OperationalError: # optional table absent
|
|
190
|
+
return []
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
contents = connection.execute(
|
|
194
|
+
"SELECT table_name, data_type, srs_id, min_x, min_y, max_x, max_y FROM gpkg_contents ORDER BY table_name"
|
|
195
|
+
).fetchall()
|
|
196
|
+
geometry_columns = metadata("SELECT * FROM gpkg_geometry_columns ORDER BY table_name")
|
|
197
|
+
spatial_reference_systems = metadata(
|
|
198
|
+
"SELECT srs_name, srs_id, organization, organization_coordsys_id, definition"
|
|
199
|
+
" FROM gpkg_spatial_ref_sys ORDER BY srs_id"
|
|
200
|
+
)
|
|
201
|
+
schemas = {}
|
|
202
|
+
tables = {}
|
|
203
|
+
for table_name, *_ in contents:
|
|
204
|
+
schemas[table_name] = [
|
|
205
|
+
list(column[1:]) for column in connection.execute(f"PRAGMA table_info({_sql_identifier(table_name)})")
|
|
206
|
+
]
|
|
207
|
+
cursor = connection.execute(f"SELECT * FROM {_sql_identifier(table_name)}")
|
|
208
|
+
names = [column[0] for column in cursor.description]
|
|
209
|
+
rows = [
|
|
210
|
+
{
|
|
211
|
+
name: value.hex() if isinstance(value, bytes) else _normalize_json(value, ignored_fields)
|
|
212
|
+
for name, value in zip(names, row, strict=True)
|
|
213
|
+
if name not in ignored_fields
|
|
214
|
+
}
|
|
215
|
+
for row in cursor.fetchall()
|
|
216
|
+
]
|
|
217
|
+
tables[table_name] = sorted(rows, key=_stable_json)
|
|
218
|
+
finally:
|
|
219
|
+
connection.close()
|
|
220
|
+
return {
|
|
221
|
+
"contents": [list(row) for row in contents],
|
|
222
|
+
"geometry_columns": geometry_columns,
|
|
223
|
+
"spatial_reference_systems": spatial_reference_systems,
|
|
224
|
+
"schemas": schemas,
|
|
225
|
+
"tables": tables,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
174
229
|
def _semantic_snapshot(path: Path, ignored_fields: set[str]) -> Any:
|
|
175
230
|
suffix = path.suffix.lower()
|
|
176
231
|
if suffix in {".json", ".geojson"}:
|
|
177
232
|
return _normalize_json(json.loads(path.read_text(encoding="utf-8")), ignored_fields)
|
|
178
233
|
if suffix in {".parquet", ".geoparquet"}:
|
|
179
234
|
return _parquet_snapshot(path, ignored_fields)
|
|
235
|
+
if suffix == ".gpkg":
|
|
236
|
+
return _geopackage_snapshot(path, ignored_fields)
|
|
180
237
|
return {"sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
|
|
181
238
|
|
|
182
239
|
|
|
@@ -176,6 +176,51 @@ def run_record_matches(
|
|
|
176
176
|
return passed(f"report run_id {run_id!r} matches a real run record with consistent hashes")
|
|
177
177
|
|
|
178
178
|
|
|
179
|
+
def sample_run_not_promoted(
|
|
180
|
+
workspace: Path, project_dir: str = ".", runs_dir: str = "runs"
|
|
181
|
+
) -> AssertionResult:
|
|
182
|
+
"""A sampled run is never the canonical run of record.
|
|
183
|
+
|
|
184
|
+
Sampling clips or thins the inputs, so its outputs are evidence that the
|
|
185
|
+
pipeline executes, never evidence of the answer. `runs.latest` is what
|
|
186
|
+
`verify`, the clean-rerun protocol, and every expectation attestation bind
|
|
187
|
+
to, so a sampled record reaching it would launder a smoke test into a
|
|
188
|
+
result. A sampled record must also state what it *realized*, not only what
|
|
189
|
+
was requested.
|
|
190
|
+
"""
|
|
191
|
+
from openmapstack.sampling import run_mode, run_record_errors
|
|
192
|
+
|
|
193
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
194
|
+
if proj is None:
|
|
195
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
196
|
+
root = project_root(workspace, project_dir)
|
|
197
|
+
directory = root / runs_dir
|
|
198
|
+
if not directory.is_dir():
|
|
199
|
+
return not_testable(f"no {runs_dir}/ directory", code="runs_dir_missing")
|
|
200
|
+
|
|
201
|
+
latest_id = str(get_in(proj, "runs.latest.id", "") or "")
|
|
202
|
+
sampled: list[str] = []
|
|
203
|
+
problems: list[str] = []
|
|
204
|
+
for record_path in sorted(directory.glob("*.json")):
|
|
205
|
+
record = load_json(record_path)
|
|
206
|
+
if not isinstance(record, dict):
|
|
207
|
+
continue
|
|
208
|
+
problems.extend(f"{record_path.name}: {problem}" for problem in run_record_errors(record))
|
|
209
|
+
if run_mode(record) == "sampled":
|
|
210
|
+
sampled.append(record_path.stem)
|
|
211
|
+
|
|
212
|
+
if latest_id and latest_id in sampled:
|
|
213
|
+
return failed(
|
|
214
|
+
f"runs.latest is sampled run {latest_id!r}; a sampled run cannot be the canonical run",
|
|
215
|
+
code="sampled_run_promoted",
|
|
216
|
+
)
|
|
217
|
+
if problems:
|
|
218
|
+
return failed(f"invalid run mode/sample declaration: {problems}", code="sample_record_invalid")
|
|
219
|
+
if not sampled:
|
|
220
|
+
return passed("no sampled run records; runs.latest is canonical")
|
|
221
|
+
return passed(f"{len(sampled)} sampled run record(s) present and none is runs.latest")
|
|
222
|
+
|
|
223
|
+
|
|
179
224
|
def no_prose_only_validation(
|
|
180
225
|
workspace: Path, check_id: str, project_dir: str = ".", report_path: str = "validation/latest-report.json"
|
|
181
226
|
) -> AssertionResult:
|
|
@@ -208,7 +253,7 @@ def report_evidence_recomputes(
|
|
|
208
253
|
and optional id field. This avoids accepting internally consistent prose
|
|
209
254
|
or invented counters as proof that a GIS check actually ran.
|
|
210
255
|
"""
|
|
211
|
-
from .geodata import _connect, _read
|
|
256
|
+
from .geodata import _connect, _invalid_geometry_count, _read
|
|
212
257
|
|
|
213
258
|
report = load_json(project_root(workspace, project_dir) / report_path)
|
|
214
259
|
if report is None:
|
|
@@ -246,9 +291,11 @@ def report_evidence_recomputes(
|
|
|
246
291
|
if metric == "row_count":
|
|
247
292
|
actual = con.execute(f"SELECT COUNT(*) FROM {relation}").fetchone()[0]
|
|
248
293
|
elif metric == "invalid_geometry_count":
|
|
249
|
-
actual = con.
|
|
250
|
-
|
|
251
|
-
|
|
294
|
+
actual = _invalid_geometry_count(con, relation, declaration.get("geometry_field"))
|
|
295
|
+
if actual is None:
|
|
296
|
+
return failed(
|
|
297
|
+
f"{relative} has no matching geometry column", code="geometry_column_missing"
|
|
298
|
+
)
|
|
252
299
|
elif metric in {"duplicate_count", "null_count"}:
|
|
253
300
|
field = declaration.get("field")
|
|
254
301
|
if not isinstance(field, str) or not field:
|