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,261 @@
1
+ """Project-contract assertions: schema, manifest graph, status/report agreement.
2
+
3
+ See references/project-spec.md sections 1, 2.1, 2.4, 4.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from . import (
11
+ AssertionResult,
12
+ failed,
13
+ get_in,
14
+ load_json,
15
+ load_project_yaml,
16
+ not_testable,
17
+ passed,
18
+ project_root,
19
+ warning,
20
+ )
21
+
22
+
23
+ def exists(workspace: Path, project_dir: str = ".", path: str = "") -> AssertionResult:
24
+ """A declared file exists relative to the project root."""
25
+ # An omitted path resolves to the project root itself, which always
26
+ # exists -- so the check would report `passed` having verified nothing.
27
+ # A check that cannot fail is worse than a missing check, because it
28
+ # reads as evidence.
29
+ if not path.strip():
30
+ return not_testable("no path argument given", code="missing_argument")
31
+ target = project_root(workspace, project_dir) / path
32
+ if target.exists():
33
+ return passed(f"{path} exists")
34
+ return failed(f"{path} does not exist", code="file_missing")
35
+
36
+
37
+ def parses(workspace: Path, project_dir: str = ".") -> AssertionResult:
38
+ proj = load_project_yaml(workspace, project_dir)
39
+ if proj is None:
40
+ return failed("project.yaml missing or unreadable", code="manifest_missing")
41
+ return passed("project.yaml parses", schema=proj.get("schema"))
42
+
43
+
44
+ def schema_is(workspace: Path, schema: str, project_dir: str = ".") -> AssertionResult:
45
+ proj = load_project_yaml(workspace, project_dir)
46
+ if proj is None:
47
+ return failed("project.yaml missing", code="manifest_missing")
48
+ actual = proj.get("schema")
49
+ if actual == schema:
50
+ return passed(f"schema == {schema}")
51
+ return failed(f"schema mismatch: expected {schema!r}, got {actual!r}", code="schema_mismatch")
52
+
53
+
54
+ def conforms_to_schema(workspace: Path, project_dir: str = ".") -> AssertionResult:
55
+ """Validate the complete manifest with the packaged formal JSON Schema."""
56
+ from openmapstack.schema import project_schema_errors
57
+
58
+ proj = load_project_yaml(workspace, project_dir)
59
+ if proj is None:
60
+ return failed("project.yaml missing", code="manifest_missing")
61
+ errors = project_schema_errors(proj)
62
+ if errors:
63
+ return failed(
64
+ f"project.yaml does not conform to the OpenMapStack v1 schema: {errors}",
65
+ code="manifest_schema_invalid",
66
+ errors=errors,
67
+ )
68
+ return passed("project.yaml conforms to the packaged OpenMapStack v1 JSON Schema")
69
+
70
+
71
+ def status_is(workspace: Path, status: str, project_dir: str = ".") -> AssertionResult:
72
+ proj = load_project_yaml(workspace, project_dir)
73
+ if proj is None:
74
+ return failed("project.yaml missing", code="manifest_missing")
75
+ actual = get_in(proj, "project.status")
76
+ if actual == status:
77
+ return passed(f"project.status == {status}")
78
+ return failed(f"project.status mismatch: expected {status!r}, got {actual!r}", code="status_mismatch")
79
+
80
+
81
+ def status_in(workspace: Path, statuses: list[str], project_dir: str = ".") -> AssertionResult:
82
+ proj = load_project_yaml(workspace, project_dir)
83
+ if proj is None:
84
+ return failed("project.yaml missing", code="manifest_missing")
85
+ actual = get_in(proj, "project.status")
86
+ if actual in statuses:
87
+ return passed(f"project.status {actual!r} in {statuses}")
88
+ return failed(f"project.status {actual!r} not in {statuses}", code="status_not_in_set")
89
+
90
+
91
+ def status_agrees_with_validation_report(
92
+ workspace: Path, project_dir: str = ".", report_path: str = "validation/latest-report.json"
93
+ ) -> AssertionResult:
94
+ """project.status must not be 'validated' unless the referenced report
95
+ status is 'passed' (all required checks passed, none warning/failed/not_testable)."""
96
+ proj = load_project_yaml(workspace, project_dir)
97
+ if proj is None:
98
+ return failed("project.yaml missing", code="manifest_missing")
99
+ proj_status = get_in(proj, "project.status")
100
+ report = load_json(project_root(workspace, project_dir) / report_path)
101
+ if report is None:
102
+ if proj_status == "validated":
103
+ return failed(
104
+ "project.status is 'validated' but no validation report exists",
105
+ code="validated_without_report",
106
+ )
107
+ return not_testable("no validation report to cross-check against project.status", code="report_missing")
108
+
109
+ report_status = report.get("status")
110
+ if proj_status == "validated" and report_status != "passed":
111
+ return failed(
112
+ f"project.status is 'validated' but report status is {report_status!r} "
113
+ "(warning/failed/not_testable must never be laundered into validated)",
114
+ code="status_laundering",
115
+ )
116
+ if proj_status == "warning" and report_status == "failed":
117
+ return failed(
118
+ "project.status is 'warning' but report status is 'failed'",
119
+ code="status_understated",
120
+ )
121
+ return passed(f"project.status {proj_status!r} agrees with report status {report_status!r}")
122
+
123
+
124
+ def graph_resolves(workspace: Path, project_dir: str = ".") -> AssertionResult:
125
+ """Every step source/input/inputs/target symbol is a sources key or an
126
+ earlier step's output; every step override id and every
127
+ outputs.*.generated_by names something real. Steps are also checked for
128
+ duplicate ids and duplicate produced symbols (a manifest that looks
129
+ complete but silently redefines a step id or output symbol is just as
130
+ unrunnable as a dangling reference)."""
131
+ proj = load_project_yaml(workspace, project_dir)
132
+ if proj is None:
133
+ return failed("project.yaml missing", code="manifest_missing")
134
+
135
+ sources = set((proj.get("sources") or {}).keys())
136
+ steps = get_in(proj, "processing.steps", []) or []
137
+ outputs = proj.get("outputs") or {}
138
+ override_ids = {
139
+ str(item.get("id"))
140
+ for item in (proj.get("overrides") or [])
141
+ if isinstance(item, dict) and item.get("id")
142
+ }
143
+
144
+ produced: set[str] = set()
145
+ step_ids: list[str] = []
146
+ errors: list[str] = []
147
+
148
+ for step in steps:
149
+ step_id = step.get("id")
150
+ if step_id:
151
+ step_ids.append(str(step_id))
152
+
153
+ for key in ("input", "source", "target"):
154
+ val = step.get(key)
155
+ if isinstance(val, str) and val not in sources and val not in produced:
156
+ errors.append(
157
+ f"step {step_id!r} {key}={val!r} resolves to neither a source nor a prior output"
158
+ )
159
+ inputs = step.get("inputs")
160
+ if isinstance(inputs, list):
161
+ for val in inputs:
162
+ if val not in sources and val not in produced:
163
+ errors.append(
164
+ f"step {step_id!r} inputs contains {val!r}, resolves to neither a source nor a prior output"
165
+ )
166
+
167
+ override_ref = step.get("override")
168
+ if override_ref is not None and str(override_ref) not in override_ids:
169
+ errors.append(f"step {step_id!r} override={override_ref!r} is not a declared override id")
170
+
171
+ out = step.get("output")
172
+ new_symbols: list[str] = []
173
+ if isinstance(out, str):
174
+ new_symbols = [name.strip() for name in out.split(",") if name.strip()]
175
+ elif isinstance(out, list):
176
+ new_symbols = [str(item) for item in out]
177
+ for symbol in new_symbols:
178
+ if symbol in produced or symbol in sources:
179
+ errors.append(f"step {step_id!r} produces duplicate symbol {symbol!r}")
180
+ produced.add(symbol)
181
+
182
+ duplicate_steps = [step_id for step_id in set(step_ids) if step_ids.count(step_id) > 1]
183
+ if duplicate_steps:
184
+ errors.append(f"duplicate step ids: {sorted(duplicate_steps)}")
185
+
186
+ for out_key, out_def in outputs.items():
187
+ gen_by = out_def.get("generated_by") if isinstance(out_def, dict) else None
188
+ if gen_by and gen_by not in step_ids:
189
+ errors.append(f"outputs.{out_key}.generated_by={gen_by!r} does not name a real step")
190
+
191
+ if errors:
192
+ return failed("; ".join(errors), errors=errors, code="graph_unresolved")
193
+ return passed(
194
+ f"graph resolves: {len(steps)} steps, {len(produced)} produced symbols, "
195
+ f"{len(outputs)} outputs all traced to real steps"
196
+ )
197
+
198
+
199
+ def one_canonical_pipeline(
200
+ workspace: Path, project_dir: str = ".", pipeline_path: str = "pipeline.py", wrapper_paths: list[str] | None = None
201
+ ) -> AssertionResult:
202
+ """Convenience/E2E entrypoints must wrap pipeline.py, not duplicate its logic.
203
+
204
+ Heuristic (static, no LLM): a wrapper file should be short and should
205
+ import from the pipeline module rather than redefining its own
206
+ top-level `run_pipeline`/`write_validation`/`write_qgis_project`-shaped
207
+ functions.
208
+ """
209
+ root = project_root(workspace, project_dir)
210
+ pipeline_file = root / pipeline_path
211
+ if not pipeline_file.exists():
212
+ return failed(f"{pipeline_path} does not exist", code="pipeline_missing")
213
+
214
+ wrapper_paths = wrapper_paths or []
215
+ duplicated: list[str] = []
216
+ for wrapper_rel in wrapper_paths:
217
+ wrapper_file = root / wrapper_rel
218
+ if not wrapper_file.exists():
219
+ continue
220
+ text = wrapper_file.read_text(encoding="utf-8", errors="ignore")
221
+ pipeline_module = Path(pipeline_path).stem
222
+ imports_pipeline = f"import {pipeline_module}" in text or f"from {pipeline_module}" in text
223
+ # crude duplication smell: wrapper defines its own "def main(" AND
224
+ # does not import the pipeline module at all.
225
+ if "def main(" in text and not imports_pipeline:
226
+ duplicated.append(wrapper_rel)
227
+
228
+ if duplicated:
229
+ return failed(
230
+ f"wrapper(s) {duplicated} define their own main() without importing {pipeline_path}",
231
+ code="duplicated_pipeline_logic",
232
+ )
233
+ return passed(f"{pipeline_path} is the canonical implementation; wrappers import it")
234
+
235
+
236
+ def declared_files_exist(workspace: Path, files: list[str], project_dir: str = ".") -> AssertionResult:
237
+ root = project_root(workspace, project_dir)
238
+ missing = [f for f in files if not (root / f).exists()]
239
+ if missing:
240
+ return failed(f"missing declared files: {missing}", missing=missing, code="declared_files_missing")
241
+ return passed(f"all {len(files)} declared files exist")
242
+
243
+
244
+ def assumptions_have_rationale(workspace: Path, project_dir: str = ".") -> AssertionResult:
245
+ proj = load_project_yaml(workspace, project_dir)
246
+ if proj is None:
247
+ return failed("project.yaml missing", code="manifest_missing")
248
+ assumptions = get_in(proj, "interpretation.assumptions", []) or []
249
+ if not assumptions:
250
+ return warning("no assumptions declared", code="no_assumptions_declared")
251
+ missing = [
252
+ a.get("id", "?")
253
+ for a in assumptions
254
+ if not a.get("statement") or not a.get("rationale")
255
+ ]
256
+ if missing:
257
+ return failed(
258
+ f"assumptions missing statement/rationale: {missing}",
259
+ code="assumption_missing_rationale",
260
+ )
261
+ return passed(f"all {len(assumptions)} assumptions have statement + rationale")
@@ -0,0 +1,133 @@
1
+ """Source provenance assertions.
2
+
3
+ See references/project-spec.md section 2.2.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from . import AssertionResult, failed, get_in, load_project_yaml, passed, warning
11
+
12
+
13
+ def every_source_has_provider_and_access(workspace: Path, project_dir: str = ".") -> AssertionResult:
14
+ proj = load_project_yaml(workspace, project_dir)
15
+ if proj is None:
16
+ return failed("project.yaml missing", code="manifest_missing")
17
+ sources = proj.get("sources") or {}
18
+ if not sources:
19
+ return failed("no sources declared", code="no_sources")
20
+ missing: list[str] = []
21
+ for key, src in sources.items():
22
+ if not src.get("provider"):
23
+ missing.append(f"{key}.provider")
24
+ if not get_in(src, "access.method"):
25
+ missing.append(f"{key}.access.method")
26
+ if not (get_in(src, "access.retrieved_at") or get_in(src, "access.downloaded_at")):
27
+ missing.append(f"{key}.access.retrieved_at")
28
+ if missing:
29
+ return failed(f"sources missing provider/access fields: {missing}", code="provider_access_missing")
30
+ return passed(f"all {len(sources)} sources declare provider + access method + retrieval timestamp")
31
+
32
+
33
+ def every_source_pinned(workspace: Path, project_dir: str = ".") -> AssertionResult:
34
+ """Pinning to 'latest' is not reproducible — version.identifier/published_at
35
+ must be present and not equal to the literal string 'latest'."""
36
+ proj = load_project_yaml(workspace, project_dir)
37
+ if proj is None:
38
+ return failed("project.yaml missing", code="manifest_missing")
39
+ sources = proj.get("sources") or {}
40
+ if not sources:
41
+ return failed("no sources declared", code="no_sources")
42
+ unpinned: list[str] = []
43
+ for key, src in sources.items():
44
+ identifier = get_in(src, "version.identifier")
45
+ published_at = get_in(src, "version.published_at")
46
+ if not identifier and not published_at:
47
+ unpinned.append(key)
48
+ elif str(identifier).strip().lower() == "latest":
49
+ unpinned.append(key)
50
+ if unpinned:
51
+ return failed(f"sources not pinned to a version/identifier: {unpinned}", code="source_unpinned")
52
+ return passed(f"all {len(sources)} sources are pinned")
53
+
54
+
55
+ def license_present_where_required(
56
+ workspace: Path, required_for: list[str] | None = None, project_dir: str = "."
57
+ ) -> AssertionResult:
58
+ proj = load_project_yaml(workspace, project_dir)
59
+ if proj is None:
60
+ return failed("project.yaml missing", code="manifest_missing")
61
+ sources = proj.get("sources") or {}
62
+ required_for = required_for or list(sources.keys())
63
+ missing = [k for k in required_for if k in sources and not get_in(sources[k], "license.name")]
64
+ if missing:
65
+ return failed(f"sources missing license.name: {missing}", code="license_missing")
66
+ return passed("license metadata present for required sources")
67
+
68
+
69
+ def rationale_present(workspace: Path, project_dir: str = ".") -> AssertionResult:
70
+ proj = load_project_yaml(workspace, project_dir)
71
+ if proj is None:
72
+ return failed("project.yaml missing", code="manifest_missing")
73
+ sources = proj.get("sources") or {}
74
+ missing = [k for k, s in sources.items() if not s.get("rationale")]
75
+ if missing:
76
+ return failed(f"sources missing selection rationale: {missing}", code="rationale_missing")
77
+ return passed(f"all {len(sources)} sources document selection rationale")
78
+
79
+
80
+ def bounded_api_completeness(
81
+ workspace: Path, source: str, project_dir: str = "."
82
+ ) -> AssertionResult:
83
+ """For bounded/paginated APIs, matched == returned must be recorded, or
84
+ an explicit reason for incompleteness must be present."""
85
+ proj = load_project_yaml(workspace, project_dir)
86
+ if proj is None:
87
+ return failed("project.yaml missing", code="manifest_missing")
88
+ src = get_in(proj, f"sources.{source}")
89
+ if src is None:
90
+ return failed(f"source {source!r} not declared", code="source_not_declared")
91
+ completeness = get_in(src, "selection.completeness") or get_in(src, "completeness")
92
+ if completeness is None:
93
+ return warning(
94
+ f"source {source!r} does not record completeness (matched/returned)",
95
+ code="completeness_undeclared",
96
+ )
97
+ matched = completeness.get("matched")
98
+ returned = completeness.get("returned")
99
+ if matched is None or returned is None:
100
+ return warning(
101
+ f"source {source!r} completeness block missing matched/returned",
102
+ code="completeness_incomplete_fields",
103
+ )
104
+ if matched != returned:
105
+ return failed(
106
+ f"source {source!r} incomplete: matched={matched} returned={returned} "
107
+ "(a response filled to the page limit is not proof of completeness)",
108
+ code="completeness_mismatch",
109
+ )
110
+ return passed(f"source {source!r} complete: matched == returned == {matched}")
111
+
112
+
113
+ def semantic_predicate_documented(
114
+ workspace: Path, source: str, project_dir: str = "."
115
+ ) -> AssertionResult:
116
+ """If a source's selection depends on a semantic predicate (ownership,
117
+ active status, etc.), the authoritative field/domain must be documented."""
118
+ proj = load_project_yaml(workspace, project_dir)
119
+ if proj is None:
120
+ return failed("project.yaml missing", code="manifest_missing")
121
+ src = get_in(proj, f"sources.{source}")
122
+ if src is None:
123
+ return failed(f"source {source!r} not declared", code="source_not_declared")
124
+ predicates = get_in(src, "selection.semantic_predicates")
125
+ if not predicates:
126
+ return warning(f"source {source!r} declares no semantic_predicates block", code="predicates_undeclared")
127
+ missing = [p for p in predicates if not p.get("field") or p.get("domain_value") in (None, "")]
128
+ if missing:
129
+ return failed(
130
+ f"source {source!r} has semantic_predicates missing field/domain_value",
131
+ code="predicate_fields_missing",
132
+ )
133
+ return passed(f"source {source!r} documents {len(predicates)} semantic predicate(s)")