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,832 @@
|
|
|
1
|
+
"""QGIS project static-validity assertions.
|
|
2
|
+
|
|
3
|
+
See references/project-spec.md section 5. PyQGIS runtime validation is out
|
|
4
|
+
of scope for fixture CI (heavy dependency); this module implements the
|
|
5
|
+
static minimum described in the spec — never an implicit pass when runtime
|
|
6
|
+
validation isn't available.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import html
|
|
12
|
+
import re
|
|
13
|
+
import tempfile
|
|
14
|
+
import zipfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from . import AssertionResult, failed, get_in, load_project_yaml, not_testable, passed, project_root
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _extract_qgs_xml(qgz_path: Path) -> str | None:
|
|
22
|
+
if qgz_path.suffix == ".qgs":
|
|
23
|
+
return qgz_path.read_text(encoding="utf-8", errors="ignore")
|
|
24
|
+
if qgz_path.suffix == ".qgz":
|
|
25
|
+
with zipfile.ZipFile(qgz_path) as zf:
|
|
26
|
+
qgs_names = [n for n in zf.namelist() if n.endswith(".qgs")]
|
|
27
|
+
if not qgs_names:
|
|
28
|
+
return None
|
|
29
|
+
return zf.read(qgs_names[0]).decode("utf-8", errors="ignore")
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
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> referencing
|
|
35
|
+
a relative file path resolves on disk, and GeoPackage datasources declare
|
|
36
|
+
a layername= (otherwise GDAL silently loads a non-spatial attribute table)."""
|
|
37
|
+
qgz_path = project_root(workspace, project_dir) / path
|
|
38
|
+
if not qgz_path.exists():
|
|
39
|
+
return failed(f"{path} does not exist", code="file_missing")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
xml = _extract_qgs_xml(qgz_path)
|
|
43
|
+
except zipfile.BadZipFile:
|
|
44
|
+
return failed(f"{path} is not a valid zip archive", code="not_a_zip")
|
|
45
|
+
if xml is None:
|
|
46
|
+
return failed(f"{path} does not contain a .qgs document", code="no_qgs_document")
|
|
47
|
+
|
|
48
|
+
datasources = re.findall(r"<datasource>(.*?)</datasource>", xml, re.DOTALL)
|
|
49
|
+
if not datasources:
|
|
50
|
+
return failed(f"{path} declares no layers (no <datasource> elements)", code="no_layers")
|
|
51
|
+
|
|
52
|
+
errors: list[str] = []
|
|
53
|
+
root = project_root(workspace, project_dir)
|
|
54
|
+
for ds in datasources:
|
|
55
|
+
ds = ds.strip()
|
|
56
|
+
# remote/WMS/WFS datasources use key=value query strings, not file paths.
|
|
57
|
+
if ds.startswith(("http", "type=xyz", "contextualWMSLegend", "crs=")) or "url=" in ds:
|
|
58
|
+
continue
|
|
59
|
+
raw_path = ds.split("|", 1)[0]
|
|
60
|
+
if raw_path.lower().endswith(".gpkg") and "layername=" not in ds:
|
|
61
|
+
errors.append(f"GeoPackage datasource missing layername=: {ds}")
|
|
62
|
+
resolved = (root / raw_path).resolve() if raw_path.startswith("./") or not raw_path.startswith("/") else Path(raw_path)
|
|
63
|
+
if raw_path and not str(raw_path).startswith(("http",)) and not resolved.exists():
|
|
64
|
+
errors.append(f"datasource file does not exist: {raw_path}")
|
|
65
|
+
|
|
66
|
+
if errors:
|
|
67
|
+
return failed("; ".join(errors), errors=errors, code="broken_datasource")
|
|
68
|
+
return passed(f"{path} static-valid: {len(datasources)} datasource(s), all files resolve")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_QGIS_APPLICATION: Any = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _qgis_application() -> Any:
|
|
75
|
+
"""Return a process-wide QgsApplication singleton.
|
|
76
|
+
|
|
77
|
+
PyQGIS's QgsApplication is not safe to construct/initQgis()+exitQgis()
|
|
78
|
+
repeatedly within one process — a second cycle reliably crashes the
|
|
79
|
+
interpreter (observed as a native ``free(): invalid pointer`` abort).
|
|
80
|
+
The eval runner may call this assertion many times across cases in one
|
|
81
|
+
process, so the application is created once per process and kept alive
|
|
82
|
+
rather than torn down after each call.
|
|
83
|
+
"""
|
|
84
|
+
global _QGIS_APPLICATION
|
|
85
|
+
if _QGIS_APPLICATION is None:
|
|
86
|
+
import os
|
|
87
|
+
|
|
88
|
+
from qgis.core import QgsApplication # type: ignore
|
|
89
|
+
|
|
90
|
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
91
|
+
app = QgsApplication([], False)
|
|
92
|
+
app.initQgis()
|
|
93
|
+
_QGIS_APPLICATION = app
|
|
94
|
+
return _QGIS_APPLICATION
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def runtime_load(
|
|
98
|
+
workspace: Path,
|
|
99
|
+
path: str = "project.qgz",
|
|
100
|
+
project_dir: str = ".",
|
|
101
|
+
render_png: str | None = None,
|
|
102
|
+
) -> AssertionResult:
|
|
103
|
+
"""PyQGIS runtime layer validity check. Records not_testable (never an
|
|
104
|
+
implicit pass) when PyQGIS is unavailable, matching the spec's explicit
|
|
105
|
+
four-state contract.
|
|
106
|
+
|
|
107
|
+
Beyond "does it load", this checks every layer is valid, records
|
|
108
|
+
datasource/geometry-type/CRS per layer, records the layer tree's
|
|
109
|
+
top-level group names as evidence (``groups_match_manifest`` is what
|
|
110
|
+
asserts on them), and (with ``render_png``) renders a controlled-extent
|
|
111
|
+
PNG as PR 7 evidence that the project is actually drawable, not merely
|
|
112
|
+
loadable.
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
from qgis.core import QgsProject # type: ignore
|
|
116
|
+
except ImportError:
|
|
117
|
+
return not_testable(
|
|
118
|
+
"PyQGIS is not installed in this execution environment", code="pyqgis_unavailable"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
qgz_path = project_root(workspace, project_dir) / path
|
|
122
|
+
if not qgz_path.exists():
|
|
123
|
+
return failed(f"{path} does not exist", code="file_missing")
|
|
124
|
+
|
|
125
|
+
_qgis_application()
|
|
126
|
+
# QgsProject.instance() is the only project object PyQGIS reliably loads
|
|
127
|
+
# layers into in this offscreen/headless setup; a freshly constructed
|
|
128
|
+
# QgsProject() silently loads zero layers. clear() resets it between
|
|
129
|
+
# calls so repeated invocations in one process never leak state between
|
|
130
|
+
# unrelated .qgz files.
|
|
131
|
+
project = QgsProject.instance()
|
|
132
|
+
project.clear()
|
|
133
|
+
try:
|
|
134
|
+
if not project.read(str(qgz_path)):
|
|
135
|
+
return failed(f"{path} failed to load in PyQGIS", code="load_failed")
|
|
136
|
+
|
|
137
|
+
layers = project.mapLayers()
|
|
138
|
+
invalid = [lyr.name() for lyr in layers.values() if not lyr.isValid()]
|
|
139
|
+
if invalid:
|
|
140
|
+
return failed(f"invalid layers: {invalid}", code="invalid_layers")
|
|
141
|
+
|
|
142
|
+
layer_details = []
|
|
143
|
+
for lyr in layers.values():
|
|
144
|
+
detail: dict[str, Any] = {
|
|
145
|
+
"name": lyr.name(),
|
|
146
|
+
"source": lyr.source(),
|
|
147
|
+
"crs": lyr.crs().authid() or None,
|
|
148
|
+
}
|
|
149
|
+
if hasattr(lyr, "geometryType"):
|
|
150
|
+
try:
|
|
151
|
+
detail["geometry_type"] = int(lyr.geometryType())
|
|
152
|
+
except Exception: # noqa: BLE001
|
|
153
|
+
pass
|
|
154
|
+
layer_details.append(detail)
|
|
155
|
+
|
|
156
|
+
group_names = {
|
|
157
|
+
child.name()
|
|
158
|
+
for child in project.layerTreeRoot().children()
|
|
159
|
+
if hasattr(child, "name") and hasattr(child, "children")
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
render_error = None
|
|
163
|
+
render_target = None
|
|
164
|
+
if render_png:
|
|
165
|
+
# Resolve relative render paths against the project, not the
|
|
166
|
+
# runner's cwd, so case definitions stay cwd-independent.
|
|
167
|
+
render_target = Path(render_png)
|
|
168
|
+
if not render_target.is_absolute():
|
|
169
|
+
render_target = project_root(workspace, project_dir) / render_png
|
|
170
|
+
render_error = _render_extent_png(project, layers, render_target)
|
|
171
|
+
|
|
172
|
+
if render_error:
|
|
173
|
+
return failed(
|
|
174
|
+
f"loaded but render failed: {render_error}",
|
|
175
|
+
code="render_failed",
|
|
176
|
+
layers=layer_details,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# A loadable, "valid" project that paints nothing is still broken:
|
|
180
|
+
# analyze the rendered snapshot for the empty-map failure mode
|
|
181
|
+
# (missing layers, collapsed extent, gross CRS displacement).
|
|
182
|
+
if render_target is not None:
|
|
183
|
+
from .visual import _is_blank, image_stats # local import: stdlib-only module
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
stats = image_stats(render_target)
|
|
187
|
+
except ValueError as exc:
|
|
188
|
+
return failed(
|
|
189
|
+
f"rendered snapshot is not decodable: {exc}",
|
|
190
|
+
code="render_undecodable",
|
|
191
|
+
layers=layer_details,
|
|
192
|
+
)
|
|
193
|
+
if _is_blank(stats):
|
|
194
|
+
return failed(
|
|
195
|
+
"project renders to a blank image "
|
|
196
|
+
f"({stats['modal_color_fraction']:.1%} one color) — layers may be missing, "
|
|
197
|
+
"the extent collapsed, or the CRS is displaced",
|
|
198
|
+
code="blank_render",
|
|
199
|
+
stats=stats,
|
|
200
|
+
layers=layer_details,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return passed(
|
|
204
|
+
f"all {len(layers)} layers valid under PyQGIS runtime",
|
|
205
|
+
layers=layer_details,
|
|
206
|
+
layer_tree_groups=sorted(group_names),
|
|
207
|
+
rendered=bool(render_png) and not render_error,
|
|
208
|
+
)
|
|
209
|
+
finally:
|
|
210
|
+
project.clear()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _render_extent_png(
|
|
214
|
+
project: Any,
|
|
215
|
+
layers: dict,
|
|
216
|
+
output_path: Path,
|
|
217
|
+
*,
|
|
218
|
+
include_basemap: bool = True,
|
|
219
|
+
extent_layers: dict | None = None,
|
|
220
|
+
) -> str | None:
|
|
221
|
+
"""Render the project to a fixed-size PNG in its own layer-tree order.
|
|
222
|
+
Returns an error string on failure, or None on success. Isolated so a
|
|
223
|
+
rendering backend problem never masks the load/validity result above it.
|
|
224
|
+
|
|
225
|
+
The controlled extent is derived only from local vector/raster data
|
|
226
|
+
layers (never basemap XYZ/WMS tile layers, whose reported extent is the
|
|
227
|
+
whole world and would zoom out past anything meaningful), reprojected
|
|
228
|
+
into the destination CRS so mismatched-CRS layers cannot silently
|
|
229
|
+
collapse the frame.
|
|
230
|
+
|
|
231
|
+
Draw order comes from the project's layer tree, not from
|
|
232
|
+
``mapLayers()``, whose ordering is an incidental artifact of layer ids:
|
|
233
|
+
it sorted the opaque parcel fill above the POI markers and painted them
|
|
234
|
+
out of the snapshot entirely.
|
|
235
|
+
|
|
236
|
+
``include_basemap=False`` renders local data only. Per-layer visibility
|
|
237
|
+
comparisons use it so their result cannot turn on whether a tile server
|
|
238
|
+
answered, and pass ``extent_layers`` to pin the frame to the full layer
|
|
239
|
+
set: recomputing the extent from a reduced set would move the whole
|
|
240
|
+
image, so every comparison would "differ" and a layer that paints
|
|
241
|
+
nothing would look like one that paints.
|
|
242
|
+
"""
|
|
243
|
+
try:
|
|
244
|
+
from qgis.core import ( # type: ignore
|
|
245
|
+
QgsCoordinateReferenceSystem,
|
|
246
|
+
QgsCoordinateTransform,
|
|
247
|
+
QgsMapRendererParallelJob,
|
|
248
|
+
QgsMapSettings,
|
|
249
|
+
QgsRectangle,
|
|
250
|
+
)
|
|
251
|
+
from qgis.PyQt.QtCore import QSize # type: ignore
|
|
252
|
+
from qgis.PyQt.QtGui import QColor # type: ignore
|
|
253
|
+
except ImportError as exc: # noqa: BLE001
|
|
254
|
+
return f"render dependencies unavailable: {exc}"
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
# The layer tree is the authored draw order (its first entry paints
|
|
258
|
+
# on top); mapLayers() is an id-keyed mapping with no visual meaning.
|
|
259
|
+
ordered = [lyr for lyr in project.layerTreeRoot().layerOrder() if lyr is not None]
|
|
260
|
+
known = set(layers.values())
|
|
261
|
+
ordered = [lyr for lyr in ordered if lyr in known] or list(layers.values())
|
|
262
|
+
valid_layers = [lyr for lyr in ordered if lyr.isValid()]
|
|
263
|
+
if not valid_layers:
|
|
264
|
+
return "no valid layers to render"
|
|
265
|
+
|
|
266
|
+
def is_tile_layer(lyr: Any) -> bool:
|
|
267
|
+
return str(lyr.dataProvider().name() if lyr.dataProvider() else "") in {"wms", "xyz"}
|
|
268
|
+
|
|
269
|
+
# The extent never comes from tile layers, whose reported extent is
|
|
270
|
+
# the whole world; it comes from the local data under test.
|
|
271
|
+
data_layers = [lyr for lyr in valid_layers if not is_tile_layer(lyr)]
|
|
272
|
+
if not data_layers:
|
|
273
|
+
return "no local data layers to render (only remote basemap layers present)"
|
|
274
|
+
# The basemap is part of what the reader sees, so it belongs in the
|
|
275
|
+
# snapshot -- but a tile server that does not answer must degrade to
|
|
276
|
+
# a bare background, never to a failed render.
|
|
277
|
+
render_layers = valid_layers if include_basemap else data_layers
|
|
278
|
+
|
|
279
|
+
destination_authid = next(
|
|
280
|
+
(lyr.crs().authid() for lyr in data_layers if lyr.crs().authid()), None
|
|
281
|
+
) or project.crs().authid()
|
|
282
|
+
if not destination_authid:
|
|
283
|
+
return "no layer or project declares a usable CRS"
|
|
284
|
+
# Re-constructing CRS objects from their authid string sidesteps a
|
|
285
|
+
# PyQGIS quirk where layer-attached CRS/transform objects report
|
|
286
|
+
# isValid()==False even though the authid itself is well-formed.
|
|
287
|
+
destination_crs = QgsCoordinateReferenceSystem(destination_authid)
|
|
288
|
+
|
|
289
|
+
extent_source = data_layers
|
|
290
|
+
if extent_layers is not None:
|
|
291
|
+
extent_source = [
|
|
292
|
+
lyr for lyr in extent_layers.values() if lyr.isValid() and not is_tile_layer(lyr)
|
|
293
|
+
] or data_layers
|
|
294
|
+
|
|
295
|
+
extent = None
|
|
296
|
+
for lyr in extent_source:
|
|
297
|
+
layer_extent = lyr.extent()
|
|
298
|
+
if layer_extent.isNull() or layer_extent.isEmpty():
|
|
299
|
+
continue
|
|
300
|
+
layer_authid = lyr.crs().authid()
|
|
301
|
+
if layer_authid and layer_authid != destination_authid:
|
|
302
|
+
try:
|
|
303
|
+
transform = QgsCoordinateTransform(
|
|
304
|
+
QgsCoordinateReferenceSystem(layer_authid), destination_crs, project
|
|
305
|
+
)
|
|
306
|
+
layer_extent = transform.transformBoundingBox(layer_extent)
|
|
307
|
+
except Exception: # noqa: BLE001
|
|
308
|
+
continue
|
|
309
|
+
extent = QgsRectangle(layer_extent) if extent is None else _combine_extent(extent, layer_extent)
|
|
310
|
+
if extent is None or extent.isEmpty():
|
|
311
|
+
return "no non-basemap layer declares a usable extent"
|
|
312
|
+
extent.grow(max(extent.width(), extent.height(), 1.0) * 0.1)
|
|
313
|
+
|
|
314
|
+
settings = QgsMapSettings()
|
|
315
|
+
settings.setLayers(render_layers)
|
|
316
|
+
settings.setDestinationCrs(destination_crs)
|
|
317
|
+
settings.setExtent(extent)
|
|
318
|
+
settings.setOutputSize(QSize(800, 600))
|
|
319
|
+
settings.setBackgroundColor(QColor(255, 255, 255))
|
|
320
|
+
job = QgsMapRendererParallelJob(settings)
|
|
321
|
+
job.start()
|
|
322
|
+
job.waitForFinished()
|
|
323
|
+
image = job.renderedImage()
|
|
324
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
325
|
+
if not image.save(str(output_path)):
|
|
326
|
+
return "failed to save rendered PNG"
|
|
327
|
+
if not output_path.is_file() or output_path.stat().st_size == 0:
|
|
328
|
+
return "rendered PNG is missing or empty"
|
|
329
|
+
return None
|
|
330
|
+
except Exception as exc: # noqa: BLE001
|
|
331
|
+
return f"{type(exc).__name__}: {exc}"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _combine_extent(a: Any, b: Any) -> Any:
|
|
335
|
+
a.combineExtentWith(b)
|
|
336
|
+
return a
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _qgs_xml(workspace: Path, path: str, project_dir: str) -> tuple[str | None, Path | None, str | None]:
|
|
340
|
+
"""Extracted .qgs XML plus a stable failure code when extraction is
|
|
341
|
+
impossible: ``file_missing`` (caller usually reports first), or
|
|
342
|
+
``not_a_zip``. ``None`` XML with a ``None`` code means the container
|
|
343
|
+
opened but holds no .qgs document (caller reports ``no_qgs_document``)."""
|
|
344
|
+
qgz_path = project_root(workspace, project_dir) / path
|
|
345
|
+
if not qgz_path.exists():
|
|
346
|
+
return None, qgz_path, "file_missing"
|
|
347
|
+
try:
|
|
348
|
+
with zipfile.ZipFile(qgz_path):
|
|
349
|
+
pass
|
|
350
|
+
except zipfile.BadZipFile:
|
|
351
|
+
return None, qgz_path, "not_a_zip"
|
|
352
|
+
return _extract_qgs_xml(qgz_path), qgz_path, None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def styles_declared(workspace: Path, path: str = "project.qgz", project_dir: str = ".") -> AssertionResult:
|
|
356
|
+
"""Every map layer in the .qgs document declares a non-empty renderer
|
|
357
|
+
(a style). A layer without one renders as an invisible default and the
|
|
358
|
+
map silently shows less than the manifest claims."""
|
|
359
|
+
xml, _qgz_path, error = _qgs_xml(workspace, path, project_dir)
|
|
360
|
+
if error == "file_missing":
|
|
361
|
+
return failed(f"{path} does not exist", code="file_missing")
|
|
362
|
+
if error == "not_a_zip":
|
|
363
|
+
return failed(f"{path} is not a valid zip archive", code="not_a_zip")
|
|
364
|
+
if xml is None:
|
|
365
|
+
return failed(f"{path} does not contain a .qgs document", code="no_qgs_document")
|
|
366
|
+
maplayers = re.findall(r"<maplayer[ >].*?</maplayer>", xml, re.DOTALL)
|
|
367
|
+
if not maplayers:
|
|
368
|
+
return failed(f"{path} declares no map layers", code="no_layers")
|
|
369
|
+
unstyled = []
|
|
370
|
+
checked = 0
|
|
371
|
+
for layer_xml in maplayers:
|
|
372
|
+
name_match = re.search(r"<layername>(.*?)</layername>", layer_xml, re.DOTALL)
|
|
373
|
+
name = name_match.group(1) if name_match else "?"
|
|
374
|
+
# Raster layers (tiled basemaps) carry no renderer-v2; their styling
|
|
375
|
+
# is intrinsic to the tile source.
|
|
376
|
+
if re.search(r'<maplayer[^>]*type="raster"', layer_xml):
|
|
377
|
+
continue
|
|
378
|
+
checked += 1
|
|
379
|
+
renderer = re.search(r"<renderer-v2\s([^>]*)>", layer_xml)
|
|
380
|
+
if renderer is None or not renderer.group(1).strip():
|
|
381
|
+
unstyled.append(name)
|
|
382
|
+
if unstyled:
|
|
383
|
+
return failed(
|
|
384
|
+
f"map layers without a declared renderer/style: {unstyled}",
|
|
385
|
+
code="missing_layer_style",
|
|
386
|
+
unstyled=unstyled,
|
|
387
|
+
)
|
|
388
|
+
return passed(f"all {checked} vector map layers declare a renderer/style")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _normalize_group_name(value: Any) -> str:
|
|
392
|
+
"""Fold a layer-group id or title to a comparable form: XML entities
|
|
393
|
+
resolved, lowercased, with spaces, underscores and hyphens all treated
|
|
394
|
+
as the same separator.
|
|
395
|
+
|
|
396
|
+
Tree names are read straight out of the .qgs document, so a title
|
|
397
|
+
carrying ``&`` or ``<`` arrives escaped (``Schools & Kindergartens``,
|
|
398
|
+
``Road <= 2 km``). Comparing that against the manifest's raw title
|
|
399
|
+
would fail a layer tree that mirrors the manifest exactly.
|
|
400
|
+
"""
|
|
401
|
+
return re.sub(r"[\s_-]+", " ", html.unescape(str(value or ""))).strip().lower()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def groups_match_manifest(workspace: Path, path: str = "project.qgz", project_dir: str = ".") -> AssertionResult:
|
|
405
|
+
"""The .qgz layer tree must mirror the manifest's
|
|
406
|
+
``presentation.map.layer_groups`` — the spec requires the QGIS project
|
|
407
|
+
to be a layer-tree mirror of the web dashboard's visual hierarchy.
|
|
408
|
+
|
|
409
|
+
A tree group matches a declared group when its ``name`` is either the
|
|
410
|
+
group's ``id`` or its human ``title``, compared case- and
|
|
411
|
+
separator-insensitively. QGIS layer trees are authored for readers, and
|
|
412
|
+
the spec's own examples name groups by title ("Analysis Results"), so
|
|
413
|
+
requiring the raw id would fail projects that follow the spec.
|
|
414
|
+
"""
|
|
415
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
416
|
+
if proj is None:
|
|
417
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
418
|
+
declared = get_in(proj, "presentation.map.layer_groups", []) or []
|
|
419
|
+
if not declared:
|
|
420
|
+
return passed("manifest declares no layer groups (vacuously true)")
|
|
421
|
+
|
|
422
|
+
xml, _qgz_path, error = _qgs_xml(workspace, path, project_dir)
|
|
423
|
+
if error == "file_missing":
|
|
424
|
+
return failed(f"{path} does not exist", code="file_missing")
|
|
425
|
+
if error == "not_a_zip":
|
|
426
|
+
return failed(f"{path} is not a valid zip archive", code="not_a_zip")
|
|
427
|
+
if xml is None:
|
|
428
|
+
return failed(f"{path} does not contain a .qgs document", code="no_qgs_document")
|
|
429
|
+
tree_groups = re.findall(r'<layer-tree-group[^>]*\bname="([^"]+)"', xml)
|
|
430
|
+
present = {_normalize_group_name(name) for name in tree_groups}
|
|
431
|
+
missing = [
|
|
432
|
+
group.get("id")
|
|
433
|
+
for group in declared
|
|
434
|
+
if not ({_normalize_group_name(group.get("id")), _normalize_group_name(group.get("title"))} - {""}) & present
|
|
435
|
+
]
|
|
436
|
+
if missing:
|
|
437
|
+
return failed(
|
|
438
|
+
f"manifest layer groups absent from the .qgz layer tree: {missing} "
|
|
439
|
+
f"(found: {tree_groups})",
|
|
440
|
+
code="layer_group_missing_from_qgis",
|
|
441
|
+
missing=missing,
|
|
442
|
+
found=tree_groups,
|
|
443
|
+
)
|
|
444
|
+
return passed(f"all {len(declared)} manifest layer groups present in the .qgz layer tree")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _is_remote_basemap_source(source: str) -> bool:
|
|
448
|
+
"""Tiled XYZ/WMS basemap layers (declared via presentation.map.basemap)
|
|
449
|
+
are background references, not undeclared data layers.
|
|
450
|
+
|
|
451
|
+
QGIS raster provider URIs are ``key=value`` pairs in no guaranteed
|
|
452
|
+
order, so a real WMS layer starts ``crs=...`` as often as ``url=...``.
|
|
453
|
+
Match on the keys that identify a remote tile/service source wherever
|
|
454
|
+
they appear rather than on the first one only.
|
|
455
|
+
"""
|
|
456
|
+
lowered = source.lower()
|
|
457
|
+
if lowered.startswith(("type=xyz", "url=http")):
|
|
458
|
+
return True
|
|
459
|
+
return any(marker in lowered for marker in ("type=xyz", "&url=http", "contextualwmslegend", "tilematrixset="))
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
_OUTPUT_FORMAT_SUFFIXES = (
|
|
463
|
+
"_geojson", "_parquet", "_gpkg", "_geoparquet", "_fgb", "_csv", "_pmtiles", "_json",
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
# A layer the manifest declares as browser-local draft state (matching
|
|
468
|
+
# ``presentation.editing.draft_persistence``) never becomes a file in the
|
|
469
|
+
# run: it lives in the viewer's browser until it is exported as an override
|
|
470
|
+
# bundle and applied by the pipeline. It is declared so the dashboard legend
|
|
471
|
+
# is complete, not as a claim about a delivered dataset, so the QGIS product
|
|
472
|
+
# is not expected to carry it.
|
|
473
|
+
_CLIENT_LOCAL_PERSISTENCE = {"local_storage", "session_storage", "browser_local"}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _is_client_local(layer: dict[str, Any]) -> bool:
|
|
477
|
+
return str(layer.get("persistence") or "").strip().lower() in _CLIENT_LOCAL_PERSISTENCE
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _base_output_key(key: str) -> str | None:
|
|
481
|
+
"""The dataset key behind a format-variant output key, or None.
|
|
482
|
+
|
|
483
|
+
``candidate_parcels_geojson`` and ``candidate_parcels_gpkg`` are the same
|
|
484
|
+
dataset in two formats; both resolve to ``candidate_parcels``.
|
|
485
|
+
"""
|
|
486
|
+
for suffix in _OUTPUT_FORMAT_SUFFIXES:
|
|
487
|
+
if key.endswith(suffix) and len(key) > len(suffix):
|
|
488
|
+
return key[: -len(suffix)]
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _manifest_layer_files(proj: dict[str, Any]) -> dict[str, list[str]]:
|
|
493
|
+
"""Map manifest presentation layer ``source`` keys to project-relative
|
|
494
|
+
file paths. A source key is either an output key (``outputs.*.path``;
|
|
495
|
+
an output may have several format variants) or an override layer id
|
|
496
|
+
(``overrides[].layer`` → geometry_file path)."""
|
|
497
|
+
resolved: dict[str, list[str]] = {}
|
|
498
|
+
for key, output in (proj.get("outputs") or {}).items():
|
|
499
|
+
if not (isinstance(output, dict) and output.get("path")):
|
|
500
|
+
continue
|
|
501
|
+
resolved.setdefault(key, []).append(output["path"])
|
|
502
|
+
# Format variants convention: the same dataset may be emitted under
|
|
503
|
+
# sibling output keys like ``<key>_geojson`` / ``<key>_parquet``.
|
|
504
|
+
# Only strip a known format suffix -- splitting on the last
|
|
505
|
+
# underscore would alias ``education_pois`` to ``education`` and
|
|
506
|
+
# could match a manifest layer that means something else entirely.
|
|
507
|
+
base = _base_output_key(key)
|
|
508
|
+
if base:
|
|
509
|
+
resolved.setdefault(base, []).append(output["path"])
|
|
510
|
+
for override in proj.get("overrides") or []:
|
|
511
|
+
layer = override.get("layer")
|
|
512
|
+
geometry_file = (override.get("geometry_file") or {}).get("path") if isinstance(override.get("geometry_file"), dict) else None
|
|
513
|
+
if layer and geometry_file:
|
|
514
|
+
resolved.setdefault(layer, []).append(geometry_file)
|
|
515
|
+
return resolved
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _acceptable_files(resolved: dict[str, list[str]], key: str | None) -> list[str]:
|
|
519
|
+
"""Every project-relative file that may stand for manifest layer ``key``.
|
|
520
|
+
|
|
521
|
+
The declared variant comes first, then its format siblings. A web map
|
|
522
|
+
reads ``final-candidates.json`` while the QGIS companion opens the
|
|
523
|
+
``.gpkg`` written from the same step; they are one layer in two formats,
|
|
524
|
+
and demanding the .qgz open the GeoJSON would force the desktop project
|
|
525
|
+
onto the weaker file purely to satisfy a string match.
|
|
526
|
+
"""
|
|
527
|
+
if not key:
|
|
528
|
+
return []
|
|
529
|
+
files = list(resolved.get(key) or [])
|
|
530
|
+
base = _base_output_key(key)
|
|
531
|
+
if base:
|
|
532
|
+
files.extend(path for path in resolved.get(base, []) if path not in files)
|
|
533
|
+
return files
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def layers_match_manifest(workspace: Path, path: str = "project.qgz", project_dir: str = ".") -> AssertionResult:
|
|
537
|
+
"""Runtime check (requires PyQGIS) that every layer the manifest's
|
|
538
|
+
``presentation.map.layers`` claims is actually loaded in the .qgz with a
|
|
539
|
+
known CRS and the declared geometry family. Manifest claims absent from
|
|
540
|
+
the QGIS product fail; extra undeclared data layers are reported as a
|
|
541
|
+
warning."""
|
|
542
|
+
try:
|
|
543
|
+
from qgis.core import QgsProject # type: ignore
|
|
544
|
+
except ImportError:
|
|
545
|
+
return not_testable(
|
|
546
|
+
"PyQGIS is not installed in this execution environment", code="pyqgis_unavailable"
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
550
|
+
if proj is None:
|
|
551
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
552
|
+
manifest_layers = get_in(proj, "presentation.map.layers", []) or []
|
|
553
|
+
if not manifest_layers:
|
|
554
|
+
return passed("manifest declares no map layers (vacuously true)")
|
|
555
|
+
|
|
556
|
+
qgz_path = project_root(workspace, project_dir) / path
|
|
557
|
+
if not qgz_path.exists():
|
|
558
|
+
return failed(f"{path} does not exist", code="file_missing")
|
|
559
|
+
|
|
560
|
+
_qgis_application()
|
|
561
|
+
project = QgsProject.instance()
|
|
562
|
+
project.clear()
|
|
563
|
+
try:
|
|
564
|
+
if not project.read(str(qgz_path)):
|
|
565
|
+
return failed(f"{path} failed to load in PyQGIS", code="load_failed")
|
|
566
|
+
|
|
567
|
+
layers_by_file: dict[str, Any] = {}
|
|
568
|
+
for lyr in project.mapLayers().values():
|
|
569
|
+
source_file = str(lyr.source()).split("|", 1)[0]
|
|
570
|
+
layers_by_file[Path(source_file).name] = lyr
|
|
571
|
+
|
|
572
|
+
root = project_root(workspace, project_dir)
|
|
573
|
+
expected_files = _manifest_layer_files(proj)
|
|
574
|
+
errors: list[str] = []
|
|
575
|
+
matched: list[str] = []
|
|
576
|
+
skipped: list[str] = []
|
|
577
|
+
for layer in manifest_layers:
|
|
578
|
+
key = layer.get("source")
|
|
579
|
+
if _is_client_local(layer):
|
|
580
|
+
skipped.append(key)
|
|
581
|
+
continue
|
|
582
|
+
candidates = _acceptable_files(expected_files, key)
|
|
583
|
+
if not candidates:
|
|
584
|
+
errors.append(
|
|
585
|
+
f"manifest layer {key!r} does not resolve to any output or override geometry file"
|
|
586
|
+
)
|
|
587
|
+
continue
|
|
588
|
+
filenames = [Path(relative).name for relative in candidates]
|
|
589
|
+
lyr = next((layers_by_file[name] for name in filenames if name in layers_by_file), None)
|
|
590
|
+
if lyr is None:
|
|
591
|
+
errors.append(
|
|
592
|
+
f"manifest layer {key!r} ({filenames[0]}) is not loaded in {path}"
|
|
593
|
+
)
|
|
594
|
+
continue
|
|
595
|
+
if not lyr.isValid():
|
|
596
|
+
errors.append(f"manifest layer {key!r} ({Path(lyr.source()).name}) is invalid under PyQGIS")
|
|
597
|
+
continue
|
|
598
|
+
if not lyr.crs().authid():
|
|
599
|
+
errors.append(f"manifest layer {key!r} ({Path(lyr.source()).name}) has no resolvable CRS")
|
|
600
|
+
declared_geometry = (layer.get("geometry") or "").lower()
|
|
601
|
+
if declared_geometry:
|
|
602
|
+
# QgsGeometryType: Point=0, Line=1, Polygon=2; multi-ness is
|
|
603
|
+
# carried separately, so only the base family must match.
|
|
604
|
+
expected_type = {"point": 0, "line": 1, "polygon": 2}.get(declared_geometry)
|
|
605
|
+
if expected_type is not None and int(lyr.geometryType()) != expected_type:
|
|
606
|
+
errors.append(
|
|
607
|
+
f"manifest layer {key!r} declares {declared_geometry} geometry "
|
|
608
|
+
f"but the QGIS layer {Path(lyr.source()).name} is geometryType={int(lyr.geometryType())}"
|
|
609
|
+
)
|
|
610
|
+
matched.append(key)
|
|
611
|
+
|
|
612
|
+
declared_files = {
|
|
613
|
+
Path(candidate).name
|
|
614
|
+
for manifest_layer in manifest_layers
|
|
615
|
+
for candidate in _acceptable_files(expected_files, manifest_layer.get("source"))
|
|
616
|
+
}
|
|
617
|
+
undeclared = sorted(
|
|
618
|
+
name for name, lyr in layers_by_file.items()
|
|
619
|
+
if name not in declared_files
|
|
620
|
+
and not _is_remote_basemap_source(str(lyr.source()))
|
|
621
|
+
)
|
|
622
|
+
if errors:
|
|
623
|
+
return failed("; ".join(errors), code="manifest_layer_mismatch", errors=errors, matched=matched)
|
|
624
|
+
return passed(
|
|
625
|
+
f"all {len(manifest_layers) - len(skipped)} file-backed manifest layers load in {path} "
|
|
626
|
+
"with resolvable CRS and matching geometry"
|
|
627
|
+
+ (f" ({len(skipped)} browser-local draft layer(s) skipped)" if skipped else ""),
|
|
628
|
+
matched=matched,
|
|
629
|
+
undeclared_layers=undeclared,
|
|
630
|
+
client_local_layers=skipped,
|
|
631
|
+
)
|
|
632
|
+
finally:
|
|
633
|
+
project.clear()
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def every_declared_layer_renders(
|
|
637
|
+
workspace: Path,
|
|
638
|
+
path: str = "project.qgz",
|
|
639
|
+
project_dir: str = ".",
|
|
640
|
+
) -> AssertionResult:
|
|
641
|
+
"""Every layer the manifest declares must contribute visible pixels to
|
|
642
|
+
the rendered map, not merely load.
|
|
643
|
+
|
|
644
|
+
`runtime_load` proves layers are valid and the render is not blank, and
|
|
645
|
+
`layers_match_manifest` proves they are present with the right CRS and
|
|
646
|
+
geometry -- yet a layer can satisfy all of that and still be invisible:
|
|
647
|
+
buried under an opaque fill by layer-tree order, styled with zero
|
|
648
|
+
opacity, or scale-limited out of the frame. That is how the reference
|
|
649
|
+
project's POI markers were absent from every QGIS snapshot while all
|
|
650
|
+
three checks passed.
|
|
651
|
+
|
|
652
|
+
Each declared layer is removed from an otherwise identical render and
|
|
653
|
+
the result must differ. The frame is pinned to the full layer set and
|
|
654
|
+
the basemap is excluded, so the only thing that can change is the layer
|
|
655
|
+
under test.
|
|
656
|
+
"""
|
|
657
|
+
try:
|
|
658
|
+
from qgis.core import QgsProject # type: ignore
|
|
659
|
+
except ImportError:
|
|
660
|
+
return not_testable(
|
|
661
|
+
"PyQGIS is not installed in this execution environment", code="pyqgis_unavailable"
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
from .visual import images_differ # local import: stdlib-only module
|
|
665
|
+
|
|
666
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
667
|
+
if proj is None:
|
|
668
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
669
|
+
manifest_layers = get_in(proj, "presentation.map.layers", []) or []
|
|
670
|
+
if not manifest_layers:
|
|
671
|
+
return passed("manifest declares no map layers (vacuously true)")
|
|
672
|
+
|
|
673
|
+
qgz_path = project_root(workspace, project_dir) / path
|
|
674
|
+
if not qgz_path.exists():
|
|
675
|
+
return failed(f"{path} does not exist", code="file_missing")
|
|
676
|
+
|
|
677
|
+
_qgis_application()
|
|
678
|
+
project = QgsProject.instance()
|
|
679
|
+
project.clear()
|
|
680
|
+
try:
|
|
681
|
+
if not project.read(str(qgz_path)):
|
|
682
|
+
return failed(f"{path} failed to load in PyQGIS", code="load_failed")
|
|
683
|
+
|
|
684
|
+
by_filename: dict[str, Any] = {}
|
|
685
|
+
for lyr in project.mapLayers().values():
|
|
686
|
+
by_filename[Path(str(lyr.source()).split("|", 1)[0]).name] = lyr
|
|
687
|
+
|
|
688
|
+
expected_files = _manifest_layer_files(proj)
|
|
689
|
+
targets: dict[str, Any] = {}
|
|
690
|
+
for layer in manifest_layers:
|
|
691
|
+
if _is_client_local(layer):
|
|
692
|
+
continue
|
|
693
|
+
key = layer.get("source")
|
|
694
|
+
for relative in _acceptable_files(expected_files, key):
|
|
695
|
+
found = by_filename.get(Path(relative).name)
|
|
696
|
+
if found is not None:
|
|
697
|
+
targets[key] = found
|
|
698
|
+
break
|
|
699
|
+
if not targets:
|
|
700
|
+
return failed(
|
|
701
|
+
"no manifest layer resolves to a layer loaded in the QGIS project",
|
|
702
|
+
code="manifest_layer_mismatch",
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
all_layers = {lyr.id(): lyr for lyr in project.mapLayers().values()}
|
|
706
|
+
with tempfile.TemporaryDirectory(prefix="openmapstack-qgis-render-") as tmp:
|
|
707
|
+
tmp_dir = Path(tmp)
|
|
708
|
+
baseline = tmp_dir / "baseline.png"
|
|
709
|
+
error = _render_extent_png(
|
|
710
|
+
project, all_layers, baseline, include_basemap=False, extent_layers=all_layers
|
|
711
|
+
)
|
|
712
|
+
if error:
|
|
713
|
+
return failed(f"baseline render failed: {error}", code="render_failed")
|
|
714
|
+
|
|
715
|
+
invisible: list[str] = []
|
|
716
|
+
fractions: dict[str, float] = {}
|
|
717
|
+
for key, target in targets.items():
|
|
718
|
+
without = {lid: lyr for lid, lyr in all_layers.items() if lid != target.id()}
|
|
719
|
+
candidate = tmp_dir / f"without-{target.id()}.png"
|
|
720
|
+
error = _render_extent_png(
|
|
721
|
+
project, without, candidate, include_basemap=False, extent_layers=all_layers
|
|
722
|
+
)
|
|
723
|
+
if error:
|
|
724
|
+
return failed(
|
|
725
|
+
f"render without manifest layer {key!r} failed: {error}", code="render_failed"
|
|
726
|
+
)
|
|
727
|
+
differs, fraction = images_differ(baseline, candidate)
|
|
728
|
+
fractions[key] = fraction
|
|
729
|
+
if not differs:
|
|
730
|
+
invisible.append(key)
|
|
731
|
+
|
|
732
|
+
if invisible:
|
|
733
|
+
return failed(
|
|
734
|
+
"manifest layers load but paint nothing in the rendered map "
|
|
735
|
+
f"(hidden by draw order, styling, or scale limits): {invisible}",
|
|
736
|
+
code="declared_layer_not_visible",
|
|
737
|
+
invisible=invisible,
|
|
738
|
+
render_diff_fraction=fractions,
|
|
739
|
+
)
|
|
740
|
+
return passed(
|
|
741
|
+
f"all {len(targets)} manifest layers contribute visible content to the rendered map",
|
|
742
|
+
render_diff_fraction=fractions,
|
|
743
|
+
)
|
|
744
|
+
finally:
|
|
745
|
+
project.clear()
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def every_layer_declares_crs(
|
|
749
|
+
workspace: Path,
|
|
750
|
+
path: str = "project.qgz",
|
|
751
|
+
project_dir: str = ".",
|
|
752
|
+
) -> AssertionResult:
|
|
753
|
+
"""Every map layer must declare a complete CRS and enable reprojection.
|
|
754
|
+
|
|
755
|
+
A layer with no declared CRS is assumed to be in the project CRS and is
|
|
756
|
+
never reprojected. For a Web Mercator tile basemap in a project using a
|
|
757
|
+
national grid, that silently paints the background map thousands of
|
|
758
|
+
kilometres from the data: the reference project rendered Estonian
|
|
759
|
+
parcels over the Belgian Ardennes while every other check passed. A
|
|
760
|
+
An authority id alone is not sufficient: QGIS preserves ``authid()`` for
|
|
761
|
+
an otherwise invalid CRS and then silently cannot build coordinate
|
|
762
|
+
transforms. A confidently wrong map is worse than a missing one, and
|
|
763
|
+
this is a static check so it gates on every PR rather than on the weekly
|
|
764
|
+
render.
|
|
765
|
+
"""
|
|
766
|
+
xml, _qgz_path, error = _qgs_xml(workspace, path, project_dir)
|
|
767
|
+
if error == "file_missing":
|
|
768
|
+
return failed(f"{path} does not exist", code="file_missing")
|
|
769
|
+
if error == "not_a_zip":
|
|
770
|
+
return failed(f"{path} is not a valid zip archive", code="not_a_zip")
|
|
771
|
+
if xml is None:
|
|
772
|
+
return failed(f"{path} does not contain a .qgs document", code="no_qgs_document")
|
|
773
|
+
|
|
774
|
+
maplayers = re.findall(r"<maplayer[ >].*?</maplayer>", xml, re.DOTALL)
|
|
775
|
+
if not maplayers:
|
|
776
|
+
return failed(f"{path} declares no map layers", code="no_layers")
|
|
777
|
+
|
|
778
|
+
missing: list[str] = []
|
|
779
|
+
incomplete: list[str] = []
|
|
780
|
+
declared: dict[str, str] = {}
|
|
781
|
+
for layer_xml in maplayers:
|
|
782
|
+
name_match = re.search(r"<layername>(.*?)</layername>", layer_xml, re.DOTALL)
|
|
783
|
+
name = name_match.group(1) if name_match else "?"
|
|
784
|
+
authid = re.search(r"<authid>(.*?)</authid>", layer_xml, re.DOTALL)
|
|
785
|
+
if authid is None or not authid.group(1).strip():
|
|
786
|
+
missing.append(name)
|
|
787
|
+
else:
|
|
788
|
+
declared[name] = authid.group(1).strip()
|
|
789
|
+
spatialrefsys = re.search(
|
|
790
|
+
r"<spatialrefsys(?:\s[^>]*)?>(.*?)</spatialrefsys>",
|
|
791
|
+
layer_xml,
|
|
792
|
+
re.DOTALL,
|
|
793
|
+
)
|
|
794
|
+
definition = spatialrefsys.group(1) if spatialrefsys else ""
|
|
795
|
+
has_wkt = bool(re.search(r"<wkt>\s*\S.*?</wkt>", definition, re.DOTALL))
|
|
796
|
+
has_proj4 = bool(re.search(r"<proj4>\s*\S.*?</proj4>", definition, re.DOTALL))
|
|
797
|
+
if not (has_wkt or has_proj4):
|
|
798
|
+
incomplete.append(name)
|
|
799
|
+
if missing:
|
|
800
|
+
return failed(
|
|
801
|
+
f"map layers with no declared CRS (they will be assumed to be in the project CRS "
|
|
802
|
+
f"and never reprojected): {missing}",
|
|
803
|
+
code="layer_crs_undeclared",
|
|
804
|
+
missing=missing,
|
|
805
|
+
declared=declared,
|
|
806
|
+
)
|
|
807
|
+
if incomplete:
|
|
808
|
+
return failed(
|
|
809
|
+
"map layers declare an authority id but no WKT/PROJ definition, so QGIS "
|
|
810
|
+
f"cannot build coordinate transforms: {incomplete}",
|
|
811
|
+
code="layer_crs_incomplete",
|
|
812
|
+
incomplete=incomplete,
|
|
813
|
+
declared=declared,
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
projections_enabled = re.search(
|
|
817
|
+
r"<ProjectionsEnabled(?:\s[^>]*)?>\s*1\s*</ProjectionsEnabled>",
|
|
818
|
+
xml,
|
|
819
|
+
re.DOTALL,
|
|
820
|
+
)
|
|
821
|
+
if projections_enabled is None:
|
|
822
|
+
return failed(
|
|
823
|
+
"project does not enable CRS transformations with "
|
|
824
|
+
"SpatialRefSys/ProjectionsEnabled=1",
|
|
825
|
+
code="project_reprojection_disabled",
|
|
826
|
+
declared=declared,
|
|
827
|
+
)
|
|
828
|
+
return passed(
|
|
829
|
+
f"all {len(declared)} map layers declare complete CRS definitions and project "
|
|
830
|
+
"reprojection is enabled",
|
|
831
|
+
declared=declared,
|
|
832
|
+
)
|