openmapstack 0.2.0__py3-none-any.whl → 0.3.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 +319 -0
- openmapstack/checks/metamorphic.py +74 -0
- openmapstack/checks/project.py +26 -0
- openmapstack/checks/provenance.py +53 -13
- openmapstack/cli.py +261 -0
- openmapstack/connectors/__init__.py +356 -0
- openmapstack/connectors/duckdb_local.py +240 -0
- openmapstack/connectors/postgis.py +296 -0
- openmapstack/metamorphic.py +661 -0
- openmapstack/parameters.py +190 -0
- openmapstack/rerun.py +66 -26
- openmapstack/schemas/check-result-v1.schema.json +30 -0
- openmapstack/schemas/project-v1.schema.json +158 -1
- openmapstack/schemas/verify-result-v1.schema.json +50 -0
- openmapstack/snapshot.py +200 -0
- openmapstack/sources.py +257 -0
- openmapstack/validation.py +19 -0
- openmapstack/verify.py +33 -4
- {openmapstack-0.2.0.dist-info → openmapstack-0.3.0.dist-info}/METADATA +34 -2
- openmapstack-0.3.0.dist-info/RECORD +40 -0
- openmapstack-0.2.0.dist-info/RECORD +0 -29
- {openmapstack-0.2.0.dist-info → openmapstack-0.3.0.dist-info}/WHEEL +0 -0
- {openmapstack-0.2.0.dist-info → openmapstack-0.3.0.dist-info}/entry_points.txt +0 -0
- {openmapstack-0.2.0.dist-info → openmapstack-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {openmapstack-0.2.0.dist-info → openmapstack-0.3.0.dist-info}/top_level.txt +0 -0
openmapstack/__init__.py
CHANGED
openmapstack/api.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""The versioned check API that external harnesses consume.
|
|
2
|
+
|
|
3
|
+
OpenMapBench (and any other benchmark or CI system) grades produced projects
|
|
4
|
+
with this package's checks. It must be able to do so without vendoring the
|
|
5
|
+
check implementations and without depending on module layout: the surface
|
|
6
|
+
it may rely on is exactly
|
|
7
|
+
|
|
8
|
+
- ``CHECK_API_VERSION`` and ``api_info()`` for negotiation;
|
|
9
|
+
- ``list_checks()`` for the catalogue of check names and their parameters;
|
|
10
|
+
- ``run_check()`` for one check, returning a record that validates against
|
|
11
|
+
``openmapstack-check-result/v1``;
|
|
12
|
+
- ``openmapstack verify --json``, returning ``openmapstack-verify-result/v1``;
|
|
13
|
+
- the JSON schemas packaged under ``openmapstack/schemas/``.
|
|
14
|
+
|
|
15
|
+
Everything else in ``openmapstack.checks`` is implementation.
|
|
16
|
+
|
|
17
|
+
Versioning: ``CHECK_API_VERSION`` follows ``<name>/v<major>``. A new check,
|
|
18
|
+
a new optional parameter, or a new result field is additive and does not
|
|
19
|
+
change the major. Renaming or removing a check, changing a parameter's
|
|
20
|
+
meaning, or changing the four-state status vocabulary does. A consumer
|
|
21
|
+
pins the major and the minimum package version it was tested against, and
|
|
22
|
+
``negotiate()`` answers whether the installed package satisfies both.
|
|
23
|
+
|
|
24
|
+
Result semantics that a consumer may rely on:
|
|
25
|
+
|
|
26
|
+
- ``status`` is one of ``passed | failed | warning | not_testable`` and a
|
|
27
|
+
check that could not establish its predicate is never ``passed``;
|
|
28
|
+
- ``code`` is a stable machine-readable identifier when the status is not
|
|
29
|
+
``passed``; consumers grade on ``status`` and, for mutation-style
|
|
30
|
+
expectations, ``code`` -- never on ``detail`` text;
|
|
31
|
+
- ``dimension`` is the reporting bucket the check belongs to; buckets are
|
|
32
|
+
reported separately and must not be collapsed into one score;
|
|
33
|
+
- ``oracle_free`` is ``false`` for the checks that need a known answer.
|
|
34
|
+
Those transfer to arbitrary data only through attested expectations.
|
|
35
|
+
|
|
36
|
+
Setup failures (a check that raises) are reported as ``not_testable`` with
|
|
37
|
+
``code: check_error`` by ``run_check`` so a broken environment cannot
|
|
38
|
+
produce either a pass or a graded failure; benchmark harnesses keep them
|
|
39
|
+
out of scored denominators.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import importlib
|
|
45
|
+
import inspect
|
|
46
|
+
import json
|
|
47
|
+
import re
|
|
48
|
+
from dataclasses import dataclass, field
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
from . import __version__
|
|
53
|
+
from .checks import STATUSES, AssertionResult, not_testable
|
|
54
|
+
from .schema import validation_errors
|
|
55
|
+
|
|
56
|
+
CHECK_API_VERSION = "openmapstack-check-api/v1"
|
|
57
|
+
CHECK_RESULT_SCHEMA = "openmapstack-check-result/v1"
|
|
58
|
+
API_INFO_SCHEMA = "openmapstack-api-info/v1"
|
|
59
|
+
VERIFY_RESULT_SCHEMA = "openmapstack-verify-result/v1"
|
|
60
|
+
PROJECT_SCHEMA = "openmapstack-project/v1"
|
|
61
|
+
|
|
62
|
+
CHECK_MODULES = (
|
|
63
|
+
"project",
|
|
64
|
+
"provenance",
|
|
65
|
+
"overrides",
|
|
66
|
+
"validation",
|
|
67
|
+
"geodata",
|
|
68
|
+
"presentation",
|
|
69
|
+
"qgis",
|
|
70
|
+
"visual",
|
|
71
|
+
"rerun",
|
|
72
|
+
"metamorphic",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Reporting buckets. Shared with evals/run.py, which asserts equality in
|
|
76
|
+
# tests so the two cannot drift apart.
|
|
77
|
+
DIMENSIONS = {
|
|
78
|
+
"project": "reproducibility_compliance",
|
|
79
|
+
"overrides": "override_handling",
|
|
80
|
+
"provenance": "provenance",
|
|
81
|
+
"geodata": "gis_correctness",
|
|
82
|
+
"validation": "validation_integrity",
|
|
83
|
+
"qgis": "presentation_contract",
|
|
84
|
+
"presentation": "presentation_contract",
|
|
85
|
+
"visual": "visual_judgement",
|
|
86
|
+
"rerun": "rerun_success",
|
|
87
|
+
"metamorphic": "metamorphic_evidence",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# The checks that need a known answer. They are reachable on user data
|
|
91
|
+
# only through validation.expectations[] attestations.
|
|
92
|
+
KNOWN_ANSWER_CHECKS = frozenset(
|
|
93
|
+
{
|
|
94
|
+
"geodata.row_count",
|
|
95
|
+
"geodata.feature_present",
|
|
96
|
+
"geodata.feature_absent",
|
|
97
|
+
"geodata.feature_field_equals",
|
|
98
|
+
"geodata.field_range",
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
_SCHEMA_DIR = Path(__file__).resolve().parent / "schemas"
|
|
103
|
+
_VERSION = re.compile(r"^(\d+)\.(\d+)\.(\d+)")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class CheckAPIError(ValueError):
|
|
107
|
+
"""The consumer asked for something the API does not provide."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class CheckParameter:
|
|
112
|
+
name: str
|
|
113
|
+
required: bool
|
|
114
|
+
default: Any = None
|
|
115
|
+
|
|
116
|
+
def to_dict(self) -> dict[str, Any]:
|
|
117
|
+
payload: dict[str, Any] = {"name": self.name, "required": self.required}
|
|
118
|
+
if not self.required:
|
|
119
|
+
payload["default"] = self.default
|
|
120
|
+
return payload
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class CheckDescriptor:
|
|
125
|
+
name: str
|
|
126
|
+
module: str
|
|
127
|
+
dimension: str
|
|
128
|
+
oracle_free: bool
|
|
129
|
+
summary: str
|
|
130
|
+
parameters: tuple[CheckParameter, ...] = field(default_factory=tuple)
|
|
131
|
+
|
|
132
|
+
def to_dict(self) -> dict[str, Any]:
|
|
133
|
+
return {
|
|
134
|
+
"name": self.name,
|
|
135
|
+
"module": self.module,
|
|
136
|
+
"dimension": self.dimension,
|
|
137
|
+
"oracle_free": self.oracle_free,
|
|
138
|
+
"summary": self.summary,
|
|
139
|
+
"parameters": [parameter.to_dict() for parameter in self.parameters],
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _load_schema(name: str) -> dict[str, Any]:
|
|
144
|
+
return json.loads((_SCHEMA_DIR / name).read_text(encoding="utf-8"))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _describe(module_name: str, function_name: str, function: Any) -> CheckDescriptor:
|
|
148
|
+
signature = inspect.signature(function)
|
|
149
|
+
parameters: list[CheckParameter] = []
|
|
150
|
+
for index, parameter in enumerate(signature.parameters.values()):
|
|
151
|
+
if index == 0: # workspace
|
|
152
|
+
continue
|
|
153
|
+
if parameter.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
154
|
+
continue
|
|
155
|
+
required = parameter.default is inspect.Parameter.empty
|
|
156
|
+
default = None if required else parameter.default
|
|
157
|
+
if isinstance(default, tuple):
|
|
158
|
+
default = list(default)
|
|
159
|
+
parameters.append(CheckParameter(parameter.name, required, default))
|
|
160
|
+
doc = inspect.getdoc(function) or ""
|
|
161
|
+
summary = doc.strip().splitlines()[0].strip() if doc.strip() else ""
|
|
162
|
+
name = f"{module_name}.{function_name}"
|
|
163
|
+
return CheckDescriptor(
|
|
164
|
+
name=name,
|
|
165
|
+
module=module_name,
|
|
166
|
+
dimension=DIMENSIONS.get(module_name, "other"),
|
|
167
|
+
oracle_free=name not in KNOWN_ANSWER_CHECKS,
|
|
168
|
+
summary=summary,
|
|
169
|
+
parameters=tuple(parameters),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _is_check(function: Any) -> bool:
|
|
174
|
+
if not inspect.isfunction(function) or function.__name__.startswith("_"):
|
|
175
|
+
return False
|
|
176
|
+
try:
|
|
177
|
+
parameters = list(inspect.signature(function).parameters.values())
|
|
178
|
+
except (TypeError, ValueError):
|
|
179
|
+
return False
|
|
180
|
+
return bool(parameters) and parameters[0].name == "workspace"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def list_checks() -> list[CheckDescriptor]:
|
|
184
|
+
"""Every public check, discovered from the shipped modules."""
|
|
185
|
+
descriptors: list[CheckDescriptor] = []
|
|
186
|
+
for module_name in CHECK_MODULES:
|
|
187
|
+
module = importlib.import_module(f"openmapstack.checks.{module_name}")
|
|
188
|
+
for function_name, function in sorted(vars(module).items()):
|
|
189
|
+
if getattr(function, "__module__", None) != module.__name__:
|
|
190
|
+
continue
|
|
191
|
+
if _is_check(function):
|
|
192
|
+
descriptors.append(_describe(module_name, function_name, function))
|
|
193
|
+
return descriptors
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def describe_check(name: str) -> CheckDescriptor:
|
|
197
|
+
module_name, _, function_name = name.partition(".")
|
|
198
|
+
function = _resolve(name)
|
|
199
|
+
if getattr(function, "__module__", None) != f"openmapstack.checks.{module_name}":
|
|
200
|
+
raise CheckAPIError(f"unknown check {name!r}; see list_checks()")
|
|
201
|
+
return _describe(module_name, function_name, function)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _resolve(name: str) -> Any:
|
|
205
|
+
module_name, _, function_name = name.partition(".")
|
|
206
|
+
if module_name not in CHECK_MODULES or not function_name:
|
|
207
|
+
raise CheckAPIError(f"unknown check {name!r}; see list_checks()")
|
|
208
|
+
module = importlib.import_module(f"openmapstack.checks.{module_name}")
|
|
209
|
+
function = getattr(module, function_name, None)
|
|
210
|
+
if function is None or not _is_check(function):
|
|
211
|
+
raise CheckAPIError(f"unknown check {name!r}; see list_checks()")
|
|
212
|
+
return function
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def run_check(name: str, workspace: str | Path, args: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
216
|
+
"""Execute one check and return an ``openmapstack-check-result/v1`` record.
|
|
217
|
+
|
|
218
|
+
Unknown check names and malformed arguments raise ``CheckAPIError``
|
|
219
|
+
(a consumer configuration error). A check that raises while running is
|
|
220
|
+
reported as ``not_testable`` with ``code: check_error`` -- never as a
|
|
221
|
+
pass, and never as a graded failure.
|
|
222
|
+
"""
|
|
223
|
+
descriptor = describe_check(name)
|
|
224
|
+
function = _resolve(name)
|
|
225
|
+
args = dict(args or {})
|
|
226
|
+
declared = {parameter.name for parameter in descriptor.parameters}
|
|
227
|
+
unknown = sorted(set(args) - declared)
|
|
228
|
+
missing = sorted(parameter.name for parameter in descriptor.parameters if parameter.required and parameter.name not in args)
|
|
229
|
+
if unknown or missing:
|
|
230
|
+
raise CheckAPIError(f"{name}: unknown args {unknown}, missing required args {missing}")
|
|
231
|
+
try:
|
|
232
|
+
result = function(Path(workspace), **args)
|
|
233
|
+
except Exception as exc: # noqa: BLE001 - a check must never take a harness down
|
|
234
|
+
result = not_testable(f"{type(exc).__name__}: {exc}", code="check_error")
|
|
235
|
+
if not isinstance(result, AssertionResult) or result.status not in STATUSES:
|
|
236
|
+
result = not_testable("check returned a malformed result", code="check_error")
|
|
237
|
+
data = {key: value for key, value in result.data.items() if key != "code"}
|
|
238
|
+
record: dict[str, Any] = {
|
|
239
|
+
"schema": CHECK_RESULT_SCHEMA,
|
|
240
|
+
"api_version": CHECK_API_VERSION,
|
|
241
|
+
"package_version": __version__,
|
|
242
|
+
"check": name,
|
|
243
|
+
"dimension": descriptor.dimension,
|
|
244
|
+
"oracle_free": descriptor.oracle_free,
|
|
245
|
+
"args": args,
|
|
246
|
+
"status": result.status,
|
|
247
|
+
"code": result.data.get("code"),
|
|
248
|
+
"detail": result.detail,
|
|
249
|
+
"data": json.loads(json.dumps(data, default=str)),
|
|
250
|
+
}
|
|
251
|
+
errors = validation_errors(record, _load_schema("check-result-v1.schema.json"))
|
|
252
|
+
if errors: # pragma: no cover - the record is built here; a failure is a bug
|
|
253
|
+
raise CheckAPIError(f"internal: check result does not validate: {errors}")
|
|
254
|
+
return record
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def api_info() -> dict[str, Any]:
|
|
258
|
+
"""What this installation offers, for a consumer to negotiate against."""
|
|
259
|
+
checks = list_checks()
|
|
260
|
+
return {
|
|
261
|
+
"schema": API_INFO_SCHEMA,
|
|
262
|
+
"package": "openmapstack",
|
|
263
|
+
"package_version": __version__,
|
|
264
|
+
"check_api_version": CHECK_API_VERSION,
|
|
265
|
+
"project_schema": PROJECT_SCHEMA,
|
|
266
|
+
"result_schemas": {
|
|
267
|
+
"check": CHECK_RESULT_SCHEMA,
|
|
268
|
+
"verify": VERIFY_RESULT_SCHEMA,
|
|
269
|
+
},
|
|
270
|
+
"statuses": list(STATUSES),
|
|
271
|
+
"dimensions": sorted(set(DIMENSIONS.values())),
|
|
272
|
+
"checks": len(checks),
|
|
273
|
+
"oracle_free_checks": sum(descriptor.oracle_free for descriptor in checks),
|
|
274
|
+
"known_answer_checks": sorted(KNOWN_ANSWER_CHECKS),
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _parse_version(text: str) -> tuple[int, int, int]:
|
|
279
|
+
match = _VERSION.match(text or "")
|
|
280
|
+
if match is None:
|
|
281
|
+
raise CheckAPIError(f"not a semantic version: {text!r}")
|
|
282
|
+
return tuple(int(part) for part in match.groups()) # type: ignore[return-value]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def negotiate(
|
|
286
|
+
*,
|
|
287
|
+
required_api: str = CHECK_API_VERSION,
|
|
288
|
+
min_package_version: str | None = None,
|
|
289
|
+
required_checks: list[str] | None = None,
|
|
290
|
+
) -> dict[str, Any]:
|
|
291
|
+
"""Answer whether this installation satisfies a consumer's requirements.
|
|
292
|
+
|
|
293
|
+
A consumer states the API major it was built for, the oldest package
|
|
294
|
+
version it was tested against, and the checks it needs. The answer
|
|
295
|
+
lists every unmet requirement so a harness can report *why* it is
|
|
296
|
+
refusing to grade rather than grading with a checker it does not
|
|
297
|
+
understand.
|
|
298
|
+
"""
|
|
299
|
+
problems: list[str] = []
|
|
300
|
+
if required_api != CHECK_API_VERSION:
|
|
301
|
+
problems.append(f"check API {required_api!r} is not provided; this package offers {CHECK_API_VERSION!r}")
|
|
302
|
+
if min_package_version is not None and _parse_version(__version__) < _parse_version(min_package_version):
|
|
303
|
+
problems.append(f"package version {__version__} is older than the required {min_package_version}")
|
|
304
|
+
available = {descriptor.name for descriptor in list_checks()}
|
|
305
|
+
missing = sorted(name for name in (required_checks or []) if name not in available)
|
|
306
|
+
if missing:
|
|
307
|
+
problems.append(f"checks not provided: {missing}")
|
|
308
|
+
return {
|
|
309
|
+
"schema": "openmapstack-api-negotiation/v1",
|
|
310
|
+
"compatible": not problems,
|
|
311
|
+
"package_version": __version__,
|
|
312
|
+
"check_api_version": CHECK_API_VERSION,
|
|
313
|
+
"problems": problems,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def validate_verify_result(payload: dict[str, Any]) -> list[str]:
|
|
318
|
+
"""Schema-validate an ``openmapstack verify --json`` document."""
|
|
319
|
+
return validation_errors(payload, _load_schema("verify-result-v1.schema.json"))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Metamorphic-relation assertions.
|
|
2
|
+
|
|
3
|
+
Thin check-library entry points over ``openmapstack.metamorphic`` so an eval
|
|
4
|
+
case (``assert: metamorphic.relation_holds``) and ``openmapstack verify
|
|
5
|
+
--metamorphic`` grade the same thing. See that module for the relations,
|
|
6
|
+
their preconditions, and the result vocabulary.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from . import AssertionResult, failed, load_project_yaml, not_testable, passed, project_root
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def declarations_valid(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
18
|
+
"""Every ``validation.metamorphic[]`` entry parses, names a known relation,
|
|
19
|
+
and addresses declared outputs. Structural only: nothing is executed."""
|
|
20
|
+
from ..metamorphic import DeclarationError, declared_relations, parse_declaration
|
|
21
|
+
|
|
22
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
23
|
+
if proj is None:
|
|
24
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
25
|
+
try:
|
|
26
|
+
raw_declarations = declared_relations(proj)
|
|
27
|
+
except DeclarationError as exc:
|
|
28
|
+
return failed(str(exc), code="metamorphic_declaration_invalid")
|
|
29
|
+
if not raw_declarations:
|
|
30
|
+
return not_testable("no metamorphic relations are declared", code="metamorphic_undeclared")
|
|
31
|
+
errors: list[str] = []
|
|
32
|
+
seen: set[str] = set()
|
|
33
|
+
outputs = proj.get("outputs") if isinstance(proj.get("outputs"), dict) else {}
|
|
34
|
+
for raw in raw_declarations:
|
|
35
|
+
try:
|
|
36
|
+
declaration = parse_declaration(raw)
|
|
37
|
+
except DeclarationError as exc:
|
|
38
|
+
errors.append(str(exc))
|
|
39
|
+
continue
|
|
40
|
+
if declaration.id in seen:
|
|
41
|
+
errors.append(f"{declaration.id}: duplicate relation id")
|
|
42
|
+
seen.add(declaration.id)
|
|
43
|
+
missing = [key for key in declaration.outputs if key not in outputs]
|
|
44
|
+
if missing:
|
|
45
|
+
errors.append(f"{declaration.id}: outputs {missing} are not declared outputs")
|
|
46
|
+
if errors:
|
|
47
|
+
return failed("; ".join(errors), code="metamorphic_declaration_invalid")
|
|
48
|
+
return passed(f"{len(raw_declarations)} metamorphic relation(s) are well-formed")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def relation_holds(
|
|
52
|
+
workspace: Path,
|
|
53
|
+
id: str,
|
|
54
|
+
project_dir: str = ".",
|
|
55
|
+
forbidden_fragments: list[str] | None = None,
|
|
56
|
+
) -> AssertionResult:
|
|
57
|
+
"""Execute the declared relation ``id`` in an isolated variant workspace."""
|
|
58
|
+
from ..metamorphic import DeclarationError, declared_relations, run_relation
|
|
59
|
+
|
|
60
|
+
root = project_root(workspace, project_dir)
|
|
61
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
62
|
+
if proj is None:
|
|
63
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
64
|
+
try:
|
|
65
|
+
raw_declarations = declared_relations(proj)
|
|
66
|
+
except DeclarationError as exc:
|
|
67
|
+
return failed(str(exc), code="metamorphic_declaration_invalid")
|
|
68
|
+
matches = [raw for raw in raw_declarations if isinstance(raw, dict) and raw.get("id") == id]
|
|
69
|
+
if not matches:
|
|
70
|
+
return failed(f"no metamorphic relation with id {id!r} is declared", code="metamorphic_relation_undeclared")
|
|
71
|
+
result, evidence = run_relation(root, proj, matches[0], forbidden_fragments=tuple(forbidden_fragments or ()))
|
|
72
|
+
data: dict[str, Any] = dict(result.data)
|
|
73
|
+
data["evidence"] = evidence
|
|
74
|
+
return AssertionResult(result.status, result.detail, data)
|
openmapstack/checks/project.py
CHANGED
|
@@ -259,3 +259,29 @@ def assumptions_have_rationale(workspace: Path, project_dir: str = ".") -> Asser
|
|
|
259
259
|
code="assumption_missing_rationale",
|
|
260
260
|
)
|
|
261
261
|
return passed(f"all {len(assumptions)} assumptions have statement + rationale")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def parameters_match_steps(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
265
|
+
"""``runtime.implementation.parameters`` is well-formed and each parameter
|
|
266
|
+
bound to a processing step agrees with that step's declared value.
|
|
267
|
+
|
|
268
|
+
A manifest that advertises one threshold under ``parameters`` while the
|
|
269
|
+
step declares another is drift of the same kind as a presentation
|
|
270
|
+
control that disagrees with the pipeline: the whole view becomes a
|
|
271
|
+
confident lie. See ``openmapstack.parameters``.
|
|
272
|
+
"""
|
|
273
|
+
from ..parameters import ParameterError, declared_parameters
|
|
274
|
+
|
|
275
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
276
|
+
if proj is None:
|
|
277
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
278
|
+
try:
|
|
279
|
+
parameters = declared_parameters(proj)
|
|
280
|
+
except ParameterError as exc:
|
|
281
|
+
return failed(str(exc), code="parameters_invalid")
|
|
282
|
+
if not parameters:
|
|
283
|
+
return not_testable("no runtime parameters are declared", code="parameters_undeclared")
|
|
284
|
+
bound = sum(1 for parameter in parameters if parameter.step)
|
|
285
|
+
return passed(
|
|
286
|
+
f"{len(parameters)} runtime parameter(s) declared; {bound} bound to a processing step agree with it"
|
|
287
|
+
)
|
|
@@ -7,7 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
from . import AssertionResult, failed, get_in, load_project_yaml, passed, warning
|
|
10
|
+
from . import AssertionResult, failed, get_in, load_project_yaml, passed, project_root, warning
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def every_source_has_provider_and_access(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
@@ -31,25 +31,65 @@ def every_source_has_provider_and_access(workspace: Path, project_dir: str = "."
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def every_source_pinned(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
34
|
-
"""
|
|
35
|
-
|
|
34
|
+
"""Every source is reproducibly pinned.
|
|
35
|
+
|
|
36
|
+
A source without a ``pin`` block must carry a ``version.identifier`` or
|
|
37
|
+
``published_at`` that is not a mutable alias such as ``latest``. A source
|
|
38
|
+
with a pin block is held to its pin class (``openmapstack.sources``): a
|
|
39
|
+
local snapshot must exist and match its hash, and a backend snapshot must
|
|
40
|
+
be identified, unexpired, and not known to be inaccessible. A pin that
|
|
41
|
+
cannot deliver the bytes again is ``not_reproducible`` -- a timestamp
|
|
42
|
+
string alone does not make a warehouse table pinned.
|
|
43
|
+
"""
|
|
44
|
+
from ..sources import source_pin_summary
|
|
45
|
+
|
|
46
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
47
|
+
if proj is None:
|
|
48
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
49
|
+
sources = proj.get("sources") or {}
|
|
50
|
+
if not sources:
|
|
51
|
+
return failed("no sources declared", code="no_sources")
|
|
52
|
+
assessments = source_pin_summary(project_root(workspace, project_dir), sources)
|
|
53
|
+
by_status: dict[str, list[str]] = {}
|
|
54
|
+
for key, assessment in assessments.items():
|
|
55
|
+
by_status.setdefault(assessment.status, []).append(f"{key}: {assessment.reason}")
|
|
56
|
+
if by_status.get("invalid"):
|
|
57
|
+
return failed(f"sources with malformed pins: {by_status['invalid']}", code="pin_invalid")
|
|
58
|
+
if by_status.get("not_reproducible"):
|
|
59
|
+
return failed(
|
|
60
|
+
f"sources whose pinned snapshot cannot be obtained again: {by_status['not_reproducible']}",
|
|
61
|
+
code="not_reproducible",
|
|
62
|
+
causes={key: assessment.details.get("cause") for key, assessment in assessments.items() if assessment.status == "not_reproducible"},
|
|
63
|
+
)
|
|
64
|
+
if by_status.get("unpinned"):
|
|
65
|
+
return failed(f"sources not pinned to a version/identifier: {by_status['unpinned']}", code="source_unpinned")
|
|
66
|
+
classes = sorted({assessment.pin_class for assessment in assessments.values()})
|
|
67
|
+
return passed(f"all {len(sources)} sources are pinned ({', '.join(classes)})", pin_classes=classes)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def no_inline_credentials(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
71
|
+
"""No source embeds a secret, and warehouse connections are by reference."""
|
|
72
|
+
from ..sources import connection_reference_error, find_inline_credentials
|
|
73
|
+
|
|
36
74
|
proj = load_project_yaml(workspace, project_dir)
|
|
37
75
|
if proj is None:
|
|
38
76
|
return failed("project.yaml missing", code="manifest_missing")
|
|
39
77
|
sources = proj.get("sources") or {}
|
|
40
78
|
if not sources:
|
|
41
79
|
return failed("no sources declared", code="no_sources")
|
|
42
|
-
|
|
80
|
+
root = project_root(workspace, project_dir)
|
|
81
|
+
problems: list[str] = []
|
|
43
82
|
for key, src in sources.items():
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
83
|
+
if not isinstance(src, dict):
|
|
84
|
+
continue
|
|
85
|
+
for finding in find_inline_credentials(src, f"sources.{key}"):
|
|
86
|
+
problems.append(f"{finding['path']} ({finding['pattern']})")
|
|
87
|
+
error = connection_reference_error(root, get_in(src, "access.connection"))
|
|
88
|
+
if error:
|
|
89
|
+
problems.append(f"sources.{key}.access.connection: {error}")
|
|
90
|
+
if problems:
|
|
91
|
+
return failed(f"credentials or connection strings embedded in the manifest: {problems}", code="inline_credentials")
|
|
92
|
+
return passed(f"no inline credentials in {len(sources)} sources; connections are by reference")
|
|
53
93
|
|
|
54
94
|
|
|
55
95
|
def license_present_where_required(
|