openmapstack 0.3.0__py3-none-any.whl → 0.5.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 +23 -2
- openmapstack/checks/__init__.py +4 -3
- openmapstack/checks/_axis_order.py +66 -0
- openmapstack/checks/credits.py +318 -0
- openmapstack/checks/delivery.py +231 -0
- openmapstack/checks/findings.py +19 -0
- openmapstack/checks/geodata.py +352 -14
- openmapstack/checks/integration.py +39 -0
- openmapstack/checks/overrides.py +10 -7
- openmapstack/checks/presentation.py +238 -5
- openmapstack/checks/project.py +38 -10
- openmapstack/checks/qgis.py +177 -18
- openmapstack/checks/rerun.py +102 -0
- openmapstack/checks/spatial.py +17 -7
- openmapstack/checks/tables.py +330 -0
- openmapstack/checks/tiling.py +83 -0
- openmapstack/checks/validation.py +51 -4
- openmapstack/checks/visual.py +845 -53
- openmapstack/cli.py +361 -10
- openmapstack/collection.py +191 -0
- openmapstack/connectors/__init__.py +101 -14
- openmapstack/connectors/bigquery.py +410 -0
- openmapstack/connectors/motherduck.py +279 -0
- openmapstack/credits.py +150 -0
- openmapstack/declarations.py +41 -0
- openmapstack/delivery.py +209 -0
- openmapstack/environment.py +193 -0
- openmapstack/findings.py +197 -0
- openmapstack/integration.py +154 -0
- openmapstack/integrations/__init__.py +1 -0
- openmapstack/integrations/qgis.py +132 -0
- openmapstack/integrity.py +16 -9
- openmapstack/parameters.py +64 -2
- openmapstack/rerun.py +19 -0
- openmapstack/sampling.py +202 -0
- openmapstack/schema.py +38 -1
- openmapstack/schemas/findings-v1.schema.json +53 -0
- openmapstack/schemas/project-v1.schema.json +318 -7
- openmapstack/schemas/skill-snapshot-v2.schema.json +25 -0
- openmapstack/snapshot.py +3 -0
- openmapstack/validation.py +302 -98
- openmapstack/verify.py +127 -9
- openmapstack-0.5.0.dist-info/METADATA +565 -0
- openmapstack-0.5.0.dist-info/RECORD +61 -0
- {openmapstack-0.3.0.dist-info → openmapstack-0.5.0.dist-info}/WHEEL +1 -1
- 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.5.0.dist-info}/entry_points.txt +0 -0
- {openmapstack-0.3.0.dist-info → openmapstack-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {openmapstack-0.3.0.dist-info → openmapstack-0.5.0.dist-info}/top_level.txt +0 -0
openmapstack/__init__.py
CHANGED
openmapstack/api.py
CHANGED
|
@@ -65,7 +65,13 @@ CHECK_MODULES = (
|
|
|
65
65
|
"overrides",
|
|
66
66
|
"validation",
|
|
67
67
|
"geodata",
|
|
68
|
+
"tiling",
|
|
69
|
+
"tables",
|
|
70
|
+
"findings",
|
|
71
|
+
"credits",
|
|
68
72
|
"presentation",
|
|
73
|
+
"delivery",
|
|
74
|
+
"integration",
|
|
69
75
|
"qgis",
|
|
70
76
|
"visual",
|
|
71
77
|
"rerun",
|
|
@@ -79,16 +85,23 @@ DIMENSIONS = {
|
|
|
79
85
|
"overrides": "override_handling",
|
|
80
86
|
"provenance": "provenance",
|
|
81
87
|
"geodata": "gis_correctness",
|
|
88
|
+
"tiling": "gis_correctness",
|
|
89
|
+
"tables": "table_correctness",
|
|
90
|
+
"findings": "reproducibility_compliance",
|
|
91
|
+
"credits": "presentation_contract",
|
|
82
92
|
"validation": "validation_integrity",
|
|
83
93
|
"qgis": "presentation_contract",
|
|
84
94
|
"presentation": "presentation_contract",
|
|
95
|
+
"delivery": "presentation_contract",
|
|
96
|
+
"integration": "reproducibility_compliance",
|
|
85
97
|
"visual": "visual_judgement",
|
|
86
98
|
"rerun": "rerun_success",
|
|
87
99
|
"metamorphic": "metamorphic_evidence",
|
|
88
100
|
}
|
|
89
101
|
|
|
90
|
-
#
|
|
91
|
-
# only
|
|
102
|
+
# Checks that need an independent known answer. Automatic user-project plans
|
|
103
|
+
# admit only the allowlisted validation.expectations[] attestations; external
|
|
104
|
+
# harnesses may supply their own oracles to the other known-answer checks.
|
|
92
105
|
KNOWN_ANSWER_CHECKS = frozenset(
|
|
93
106
|
{
|
|
94
107
|
"geodata.row_count",
|
|
@@ -96,6 +109,9 @@ KNOWN_ANSWER_CHECKS = frozenset(
|
|
|
96
109
|
"geodata.feature_absent",
|
|
97
110
|
"geodata.feature_field_equals",
|
|
98
111
|
"geodata.field_range",
|
|
112
|
+
"geodata.coordinate_pair_equals",
|
|
113
|
+
"tiling.command_matches",
|
|
114
|
+
"delivery.selection_is",
|
|
99
115
|
}
|
|
100
116
|
)
|
|
101
117
|
|
|
@@ -263,6 +279,11 @@ def api_info() -> dict[str, Any]:
|
|
|
263
279
|
"package_version": __version__,
|
|
264
280
|
"check_api_version": CHECK_API_VERSION,
|
|
265
281
|
"project_schema": PROJECT_SCHEMA,
|
|
282
|
+
"delivery_schemas": ["openmapstack-delivery/v1"],
|
|
283
|
+
"integration_schemas": ["openmapstack-integrations/v1"],
|
|
284
|
+
"delivery_kinds": ["dashboard", "qgis", "observable"],
|
|
285
|
+
"skill_snapshot_schemas": ["openmapstack-skill-snapshot/v1", "openmapstack-skill-snapshot/v2"],
|
|
286
|
+
"benchmark_arm_schemas": ["openmapstack-benchmark-arm/v1", "openmapstack-benchmark-arm/v2"],
|
|
266
287
|
"result_schemas": {
|
|
267
288
|
"check": CHECK_RESULT_SCHEMA,
|
|
268
289
|
"verify": VERIFY_RESULT_SCHEMA,
|
openmapstack/checks/__init__.py
CHANGED
|
@@ -22,9 +22,10 @@ That is the point of it living in the shipped package rather than under
|
|
|
22
22
|
any correct project on any data, so they transfer to data this repository
|
|
23
23
|
has never seen. The exceptions — the ones that need a known answer — are
|
|
24
24
|
`geodata.row_count(equals=)`, `feature_present`, `feature_absent`,
|
|
25
|
-
`feature_field_equals`,
|
|
26
|
-
only
|
|
27
|
-
never
|
|
25
|
+
`feature_field_equals`, `field_range`, `coordinate_pair_equals`, and `tiling.command_matches`. The automatic
|
|
26
|
+
user-project plan admits only allowlisted, input-bound `validation.expectations`
|
|
27
|
+
attestations, never an answer the pipeline computed and certified for itself.
|
|
28
|
+
External harnesses can supply independent oracles to other known-answer checks.
|
|
28
29
|
"""
|
|
29
30
|
|
|
30
31
|
from __future__ import annotations
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Detect obvious omissions, without trying to prove Python connection state.
|
|
2
|
+
|
|
3
|
+
Setup anywhere in the source is only a reason to defer to runtime evidence.
|
|
4
|
+
Bindings, execution order, aliases and side effects are deliberately not
|
|
5
|
+
interpreted: this advisory check can never certify coordinate order.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
_SENSITIVE = re.compile(r"\bST_Transform\s*\(|\bCOPY\b[\s\S]*\bFORMAT\s+['\"]?GDAL\b", re.I)
|
|
13
|
+
_HELPERS = {'configure_spatial_axes', 'connect_spatial', 'require_spatial'}
|
|
14
|
+
_SQL_METHODS = {'execute', 'sql', 'query'}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def inspect_python(text: str) -> list[int]:
|
|
18
|
+
"""Return sensitive SQL lines only when there is no setup evidence.
|
|
19
|
+
|
|
20
|
+
Evidence is syntactic presence, not proof that a setting executes, remains
|
|
21
|
+
set, or belongs to the connection doing the work. Dynamic SQL also defers:
|
|
22
|
+
it might contain setup that cannot be recognized as a literal here.
|
|
23
|
+
"""
|
|
24
|
+
tree = ast.parse(text)
|
|
25
|
+
duckdb_seen = False
|
|
26
|
+
helper_names = set(_HELPERS)
|
|
27
|
+
sql_names = set()
|
|
28
|
+
for node in ast.walk(tree):
|
|
29
|
+
if isinstance(node, ast.Import):
|
|
30
|
+
duckdb_seen |= any(alias.name == 'duckdb' for alias in node.names)
|
|
31
|
+
elif isinstance(node, ast.ImportFrom):
|
|
32
|
+
if node.module == 'duckdb':
|
|
33
|
+
duckdb_seen = True
|
|
34
|
+
sql_names.update(alias.asname or alias.name for alias in node.names
|
|
35
|
+
if alias.name in _SQL_METHODS)
|
|
36
|
+
elif node.level == 0 and node.module == 'openmapstack.checks.spatial':
|
|
37
|
+
duckdb_seen = True
|
|
38
|
+
helper_names.update(alias.asname or alias.name for alias in node.names
|
|
39
|
+
if alias.name in _HELPERS)
|
|
40
|
+
if not duckdb_seen:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
sensitive = []
|
|
44
|
+
setup_or_unknown = False
|
|
45
|
+
for node in ast.walk(tree):
|
|
46
|
+
if not isinstance(node, ast.Call):
|
|
47
|
+
continue
|
|
48
|
+
fn = node.func
|
|
49
|
+
name = fn.attr if isinstance(fn, ast.Attribute) else fn.id if isinstance(fn, ast.Name) else None
|
|
50
|
+
if name in helper_names:
|
|
51
|
+
setup_or_unknown = True
|
|
52
|
+
if not ((isinstance(fn, ast.Attribute) and name in _SQL_METHODS)
|
|
53
|
+
or (isinstance(fn, ast.Name) and name in sql_names)):
|
|
54
|
+
continue
|
|
55
|
+
sql = node.args[0] if node.args else next(
|
|
56
|
+
(kw.value for kw in node.keywords if kw.arg in ('query', 'sql')), None)
|
|
57
|
+
if not isinstance(sql, ast.Constant) or not isinstance(sql.value, str):
|
|
58
|
+
setup_or_unknown = True
|
|
59
|
+
continue
|
|
60
|
+
# A mention may be a RESET, quoted value, or conditional setup. None
|
|
61
|
+
# proves correctness; all make the omission diagnosis inconclusive.
|
|
62
|
+
if 'geometry_always_xy' in sql.value.lower() or '--' in sql.value or '/*' in sql.value:
|
|
63
|
+
setup_or_unknown = True
|
|
64
|
+
if _SENSITIVE.search(sql.value):
|
|
65
|
+
sensitive.append(node.lineno)
|
|
66
|
+
return sorted(set(sensitive)) if not setup_or_unknown else []
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Declared delivery identity and actual HTML/QGIS credit values.
|
|
2
|
+
|
|
3
|
+
No network is used: links must be structurally valid and match the manifest;
|
|
4
|
+
remote endpoint availability and natural-language translation are not certified.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
import re
|
|
10
|
+
import xml.etree.ElementTree as ET
|
|
11
|
+
import zipfile
|
|
12
|
+
from html.parser import HTMLParser
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
from . import AssertionResult, failed, load_project_yaml, passed, project_root, warning, not_testable
|
|
17
|
+
from .. import credits
|
|
18
|
+
from ..delivery import targets
|
|
19
|
+
from ..project import get_in, project_path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def declaration_valid(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
23
|
+
project = load_project_yaml(workspace, project_dir)
|
|
24
|
+
if project is None:
|
|
25
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
26
|
+
if not isinstance(project.get("project"), dict):
|
|
27
|
+
return failed("project metadata must be an object", code="credits_declaration_invalid")
|
|
28
|
+
if not credits.declared(project):
|
|
29
|
+
return warning("legacy v1 project has neither author nor generated_with; add both before new delivery",
|
|
30
|
+
code="credits_legacy_missing")
|
|
31
|
+
errors = credits.declaration_errors(project)
|
|
32
|
+
if errors:
|
|
33
|
+
return failed("; ".join(errors), code="credits_declaration_invalid")
|
|
34
|
+
try:
|
|
35
|
+
credits.credit_payload(project)
|
|
36
|
+
except (ValueError, TypeError, AttributeError) as exc:
|
|
37
|
+
return failed(str(exc), code="credits_declaration_invalid")
|
|
38
|
+
return passed("delivery author and toolkit metadata are complete")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _surfaces(root: Path, project: dict) -> dict[str, str]:
|
|
42
|
+
selected = targets(project)
|
|
43
|
+
if selected:
|
|
44
|
+
surfaces = {project["outputs"][target["output"]]["path"]: target["kind"] for target in selected}
|
|
45
|
+
for view in project.get("views") or []:
|
|
46
|
+
if isinstance(view, dict):
|
|
47
|
+
surfaces[project["outputs"][view["output"]]["path"]] = "dashboard"
|
|
48
|
+
return surfaces
|
|
49
|
+
surfaces = {}
|
|
50
|
+
for output in (project.get("outputs") or {}).values():
|
|
51
|
+
path = output.get("path", "") if isinstance(output, dict) else ""
|
|
52
|
+
suffix = Path(path).suffix.lower()
|
|
53
|
+
if suffix in {".html", ".qgz", ".qgs"}:
|
|
54
|
+
surfaces[path] = "qgis" if suffix != ".html" else "dashboard"
|
|
55
|
+
if get_in(project, "presentation", "dashboard") is not None:
|
|
56
|
+
surfaces.setdefault("dashboard.html", "dashboard")
|
|
57
|
+
if (root / "project.qgz").is_file():
|
|
58
|
+
surfaces.setdefault("project.qgz", "qgis")
|
|
59
|
+
return surfaces
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _HTML(HTMLParser):
|
|
63
|
+
def __init__(self):
|
|
64
|
+
super().__init__(convert_charrefs=True)
|
|
65
|
+
self.stack = []
|
|
66
|
+
self.nodes = []
|
|
67
|
+
self.text_parts = []
|
|
68
|
+
|
|
69
|
+
def handle_starttag(self, tag, attrs):
|
|
70
|
+
node = {"tag": tag, "attrs": dict(attrs), "text": "", "own_text": "", "parents": list(self.stack)}
|
|
71
|
+
self.nodes.append(node)
|
|
72
|
+
if tag not in {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"}:
|
|
73
|
+
self.stack.append(node)
|
|
74
|
+
|
|
75
|
+
def handle_endtag(self, tag):
|
|
76
|
+
for index in range(len(self.stack) - 1, -1, -1):
|
|
77
|
+
if self.stack[index]["tag"] == tag:
|
|
78
|
+
del self.stack[index:]
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
def handle_data(self, data):
|
|
82
|
+
if any(node["tag"] in {"script", "style", "template"} for node in self.stack):
|
|
83
|
+
return
|
|
84
|
+
for node in self.stack:
|
|
85
|
+
node["text"] += data
|
|
86
|
+
if self.stack:
|
|
87
|
+
self.stack[-1]["own_text"] += data
|
|
88
|
+
self.text_parts.append((self.stack[-1], data))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _AmbiguousStyle(Exception):
|
|
92
|
+
"""Inline CSS outside the deliberately small static visibility contract."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _inline_hidden(style: str) -> bool:
|
|
96
|
+
"""Recognize simple declarations only; leave CSS interpretation to Chromium."""
|
|
97
|
+
if not style:
|
|
98
|
+
return False
|
|
99
|
+
if re.search(r"[\"'()/{}\\]|--", style):
|
|
100
|
+
raise _AmbiguousStyle("comments, strings, functions or custom properties")
|
|
101
|
+
seen, hidden = set(), False
|
|
102
|
+
for declaration in style.split(";"):
|
|
103
|
+
if not declaration.strip():
|
|
104
|
+
continue
|
|
105
|
+
match = re.fullmatch(r"\s*([a-zA-Z][\w-]*)\s*:\s*([^:;!]+?)(?:\s*!\s*important)?\s*", declaration, re.I)
|
|
106
|
+
if not match:
|
|
107
|
+
raise _AmbiguousStyle("not a simple declaration list")
|
|
108
|
+
name, value = match[1].lower(), match[2].strip().lower()
|
|
109
|
+
if name in seen:
|
|
110
|
+
raise _AmbiguousStyle("repeated declaration: " + name)
|
|
111
|
+
seen.add(name)
|
|
112
|
+
if name == "opacity":
|
|
113
|
+
if not re.fullmatch(r"[+-]?(?:\d*\.\d+|\d+)", value) or Decimal(value) < 0:
|
|
114
|
+
raise _AmbiguousStyle("opacity requires a simple nonnegative number")
|
|
115
|
+
hidden |= Decimal(value) == 0
|
|
116
|
+
elif name in {"display", "visibility"}:
|
|
117
|
+
allowed = ({"none", "block", "inline", "inline-block", "flex", "grid", "contents"}
|
|
118
|
+
if name == "display" else {"visible", "hidden", "collapse"})
|
|
119
|
+
if value not in allowed:
|
|
120
|
+
raise _AmbiguousStyle("unsupported " + name + " value")
|
|
121
|
+
hidden |= value in {"none", "hidden", "collapse"}
|
|
122
|
+
return hidden
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _reachable_panel(node: dict, nodes: list[dict], visiting: tuple[int, ...]) -> bool:
|
|
126
|
+
"""A hidden panel needs an unambiguous, visible and enabled tab control.
|
|
127
|
+
|
|
128
|
+
Static associations do not prove that its handler works; browser QA does.
|
|
129
|
+
Cycles and a control inside its own hidden panel cannot establish access.
|
|
130
|
+
"""
|
|
131
|
+
attrs = node["attrs"]
|
|
132
|
+
panel_id = attrs.get("id")
|
|
133
|
+
if not panel_id or sum(n["attrs"].get("id") == panel_id for n in nodes) != 1:
|
|
134
|
+
return False
|
|
135
|
+
labels = attrs.get("aria-labelledby", "").split()
|
|
136
|
+
for tab in nodes:
|
|
137
|
+
values = tab["attrs"]
|
|
138
|
+
tab_id = values.get("id")
|
|
139
|
+
if (values.get("role") != "tab" or not tab_id or tab_id not in labels
|
|
140
|
+
or panel_id not in values.get("aria-controls", "").split()
|
|
141
|
+
or sum(n["attrs"].get("id") == tab_id for n in nodes) != 1
|
|
142
|
+
or any(parent is node for parent in tab["parents"])
|
|
143
|
+
or (tab["tag"] == "input" and values.get("type", "").lower() == "hidden")
|
|
144
|
+
or "disabled" in values or values.get("aria-disabled") == "true"
|
|
145
|
+
or any("disabled" in parent["attrs"] and parent["tag"] == "fieldset"
|
|
146
|
+
or parent["attrs"].get("aria-disabled") == "true" for parent in tab["parents"])
|
|
147
|
+
or values.get("aria-selected", "false") != "false"):
|
|
148
|
+
continue
|
|
149
|
+
label_ids = values.get("aria-labelledby", "").split()
|
|
150
|
+
descendants = [n for n in nodes if n is tab or any(parent is tab for parent in n["parents"])]
|
|
151
|
+
# Implicit names use rendered content, not text/alt hidden inside the
|
|
152
|
+
# control. Explicit aria-labelledby references may name hidden nodes.
|
|
153
|
+
visible_content = [n for n in descendants if not _hidden(n, nodes, visiting)]
|
|
154
|
+
named = (any(n["own_text"].strip() or (n is not tab and n["attrs"].get("alt", "").strip()) for n in visible_content)
|
|
155
|
+
or values.get("aria-label", "").strip()
|
|
156
|
+
or any(n["attrs"].get("id") in label_ids and n["text"].strip() for n in nodes)
|
|
157
|
+
)
|
|
158
|
+
if not named:
|
|
159
|
+
continue
|
|
160
|
+
if not _hidden(tab, nodes, visiting):
|
|
161
|
+
return True
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _hidden(node: dict, nodes: list[dict], visiting: tuple[int, ...] = ()) -> bool:
|
|
166
|
+
if id(node) in visiting:
|
|
167
|
+
return True
|
|
168
|
+
visiting = (*visiting, id(node))
|
|
169
|
+
for ancestor in [node, *node["parents"]]:
|
|
170
|
+
attrs = ancestor["attrs"]
|
|
171
|
+
if ancestor["tag"] in {"script", "style", "template", "noscript"}:
|
|
172
|
+
return True
|
|
173
|
+
if "inert" in attrs:
|
|
174
|
+
return True
|
|
175
|
+
inline_hidden = _inline_hidden(attrs.get("style") or "")
|
|
176
|
+
if "hidden" in attrs or attrs.get("aria-hidden") == "true" or inline_hidden:
|
|
177
|
+
if attrs.get("role") != "tabpanel" or not _reachable_panel(ancestor, nodes, visiting):
|
|
178
|
+
return True
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _visible_text(node: dict, parser: _HTML) -> str:
|
|
183
|
+
# Preserve document order, including text on both sides of nested markup.
|
|
184
|
+
return "".join(text for owner, text in parser.text_parts
|
|
185
|
+
if (owner is node or any(parent is node for parent in owner["parents"]))
|
|
186
|
+
and not _hidden(owner, parser.nodes))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _html_errors(path: Path, expected: dict) -> list[str]:
|
|
190
|
+
parser = _HTML()
|
|
191
|
+
parser.feed(path.read_text(encoding="utf-8"))
|
|
192
|
+
blocks = [node for node in parser.nodes if "data-openmapstack-credits" in node["attrs"]]
|
|
193
|
+
if len(blocks) != 1:
|
|
194
|
+
return ["view must expose exactly one non-hidden credits block"]
|
|
195
|
+
block = blocks[0]
|
|
196
|
+
# Even empty descendants can affect layout; unsupported styles never earn
|
|
197
|
+
# a static visibility pass merely because they contain no text.
|
|
198
|
+
for node in parser.nodes:
|
|
199
|
+
if node is block or any(parent is block for parent in node["parents"]):
|
|
200
|
+
_inline_hidden(node["attrs"].get("style") or "")
|
|
201
|
+
if _hidden(block, parser.nodes):
|
|
202
|
+
return ["view must expose exactly one non-hidden credits block"]
|
|
203
|
+
errors = []
|
|
204
|
+
def field(role):
|
|
205
|
+
values = [node for node in parser.nodes if node["attrs"].get("data-oms-credit") == role and any(parent is block for parent in node["parents"])]
|
|
206
|
+
return values[0] if len(values) == 1 and not _hidden(values[0], parser.nodes) else None
|
|
207
|
+
|
|
208
|
+
author, toolkit = expected["author"], expected["generated_with"]
|
|
209
|
+
block_text = _visible_text(block, parser)
|
|
210
|
+
normalize = lambda text: " ".join(text.split())
|
|
211
|
+
for role, href, labels in [
|
|
212
|
+
("toolkit", toolkit["url"], [toolkit["tool"], toolkit["tool"] + " toolkit"]),
|
|
213
|
+
("author", "mailto:" + quote(author["email"], safe="@+.-_"),
|
|
214
|
+
[author["name"], f"{author['name']} ({author['email']})"]),
|
|
215
|
+
]:
|
|
216
|
+
node = field(role)
|
|
217
|
+
if node is None or node["tag"] != "a" or node["attrs"].get("href") != href or normalize(_visible_text(node, parser)) not in [normalize(label) for label in labels]:
|
|
218
|
+
errors.append(f"{role} credit or link differs from the manifest")
|
|
219
|
+
if author["email"] not in block_text:
|
|
220
|
+
errors.append("author email missing from visible credits")
|
|
221
|
+
if author.get("url"):
|
|
222
|
+
node = field("author-url")
|
|
223
|
+
if node is None or node["tag"] != "a" or node["attrs"].get("href") != author["url"] or not _visible_text(node, parser).strip():
|
|
224
|
+
errors.append("author website credit/link differs from the manifest")
|
|
225
|
+
node = field("analysis")
|
|
226
|
+
try:
|
|
227
|
+
if node is None or node["tag"] != "time" or credits.instant(node["attrs"].get("datetime")) != credits.instant(expected["analysis_at"]) or credits.instant(_visible_text(node, parser).strip()) != credits.instant(expected["analysis_at"]):
|
|
228
|
+
raise ValueError("stale timestamp")
|
|
229
|
+
except (TypeError, ValueError):
|
|
230
|
+
errors.append("visible analysis timestamp/offset differs from the canonical completion time")
|
|
231
|
+
clock_source = node["attrs"].get("data-oms-clock-source") if node else None
|
|
232
|
+
expected_source = "project.updated_at" if expected["fallback"] else "runs.latest.completed_at"
|
|
233
|
+
# Old helper output had only an English fallback label. New renderers bind
|
|
234
|
+
# the source independently so labels, including that explanation, translate.
|
|
235
|
+
if (clock_source != expected_source if clock_source is not None else
|
|
236
|
+
("no canonical run yet" in block_text) != expected["fallback"]):
|
|
237
|
+
errors.append("pre-run timestamp fallback label differs")
|
|
238
|
+
return errors
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _qgis_errors(path: Path, expected: dict) -> list[str]:
|
|
242
|
+
if path.suffix.lower() == ".qgz":
|
|
243
|
+
with zipfile.ZipFile(path) as archive:
|
|
244
|
+
names = [name for name in archive.namelist() if name.endswith(".qgs")]
|
|
245
|
+
if len(names) != 1:
|
|
246
|
+
return ["QGZ must contain exactly one QGS member"]
|
|
247
|
+
root = ET.fromstring(archive.read(names[0]))
|
|
248
|
+
else:
|
|
249
|
+
root = ET.fromstring(path.read_bytes())
|
|
250
|
+
nodes = root.findall("projectMetadata")
|
|
251
|
+
if len(nodes) != 1:
|
|
252
|
+
return ["QGIS must expose one desktop-visible metadata block"]
|
|
253
|
+
node = nodes[0]
|
|
254
|
+
# Three leading logical credit lines, followed by unrestricted provenance.
|
|
255
|
+
# Labels translate; the identity, ISO instant and field-name source do not.
|
|
256
|
+
header = node.findtext("abstract", "").splitlines()[:3]
|
|
257
|
+
author, toolkit = expected["author"], expected["generated_with"]
|
|
258
|
+
errors = []
|
|
259
|
+
if not header or toolkit["tool"] not in header[0]:
|
|
260
|
+
errors.append("QGIS toolkit identity missing from the credit header")
|
|
261
|
+
author_value = f"{author['name']} ({author['email']})"
|
|
262
|
+
if author_value != node.findtext("author", ""):
|
|
263
|
+
errors.append("QGIS author differs from the manifest")
|
|
264
|
+
# The translated label is free; the entire author value must match in both
|
|
265
|
+
# the abstract header and native field. Provenance elsewhere cannot supply it.
|
|
266
|
+
if len(header) < 3 or not re.fullmatch(r"(?:[^\n]+?:\s*)?" + re.escape(author_value) + r"\.?", header[2].strip()):
|
|
267
|
+
errors.append("QGIS credit header author differs from the manifest")
|
|
268
|
+
times = re.findall(r"(?<![\w])\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})(?![\w:+-])", header[1] if len(header) > 1 else "")
|
|
269
|
+
try:
|
|
270
|
+
if len(times) != 1 or credits.instant(times[0]) != credits.instant(expected["analysis_at"]):
|
|
271
|
+
raise ValueError("stale timestamp")
|
|
272
|
+
except ValueError:
|
|
273
|
+
errors.append("QGIS analysis timestamp/offset differs from the canonical completion time")
|
|
274
|
+
header_text = "\n".join(header)
|
|
275
|
+
sources = re.findall(r"\[clock:([^\]]*)\]", header_text)
|
|
276
|
+
expected_source = "project.updated_at" if expected["fallback"] else "runs.latest.completed_at"
|
|
277
|
+
if (sources != [expected_source] if "[clock:" in header_text else
|
|
278
|
+
("no canonical run yet" in header_text) != expected["fallback"]):
|
|
279
|
+
errors.append("QGIS pre-run timestamp fallback label differs")
|
|
280
|
+
urls = [link.get("url") for link in node.findall("links/link")]
|
|
281
|
+
for url in [toolkit["url"], "mailto:" + quote(author["email"], safe="@+.-_"), *([author["url"]] if author.get("url") else [])]:
|
|
282
|
+
if url not in urls:
|
|
283
|
+
errors.append(f"QGIS credit link missing: {url}")
|
|
284
|
+
return errors
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def rendered_matches(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
288
|
+
"""Check selected views, or known legacy view paths; never fetch remote links."""
|
|
289
|
+
declaration = declaration_valid(workspace, project_dir)
|
|
290
|
+
if declaration.status != "passed":
|
|
291
|
+
return declaration
|
|
292
|
+
root = project_root(workspace, project_dir)
|
|
293
|
+
project = load_project_yaml(workspace, project_dir)
|
|
294
|
+
try:
|
|
295
|
+
expected = credits.credit_payload(project)
|
|
296
|
+
surfaces = _surfaces(root, project)
|
|
297
|
+
except (ValueError, TypeError, KeyError) as exc:
|
|
298
|
+
return failed(str(exc), code="credits_declaration_invalid")
|
|
299
|
+
errors, uncertain = [], []
|
|
300
|
+
for relative, kind in surfaces.items():
|
|
301
|
+
path = project_path(root, relative)
|
|
302
|
+
if path is None or not path.is_file():
|
|
303
|
+
errors.append(f"{relative}: delivery view is missing or unsafe")
|
|
304
|
+
continue
|
|
305
|
+
try:
|
|
306
|
+
problems = _qgis_errors(path, expected) if kind == "qgis" else _html_errors(path, expected)
|
|
307
|
+
except _AmbiguousStyle as exc:
|
|
308
|
+
uncertain.append(f"{relative}: {exc}")
|
|
309
|
+
continue
|
|
310
|
+
except (OSError, UnicodeError, ValueError, ET.ParseError, zipfile.BadZipFile) as exc:
|
|
311
|
+
problems = [f"cannot inspect credits: {exc}"]
|
|
312
|
+
errors.extend(f"{relative}: {problem}" for problem in problems)
|
|
313
|
+
if errors:
|
|
314
|
+
return failed("; ".join(errors), code="credits_rendered_mismatch", errors=errors)
|
|
315
|
+
if uncertain:
|
|
316
|
+
return not_testable("ambiguous inline credits visibility; run visual.credits_visible_in_browser: "
|
|
317
|
+
+ "; ".join(uncertain), code="credits_visibility_ambiguous", surfaces=list(surfaces))
|
|
318
|
+
return passed(f"credits match in {len(surfaces)} delivery surface(s)", surfaces=list(surfaces))
|