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,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 &amp; Kindergartens``,
398
+ ``Road &lt;= 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
+ )