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 CHANGED
@@ -3,4 +3,4 @@
3
3
  from .validation import Check, ValidationResult, validate_project
4
4
 
5
5
  __all__ = ["Check", "ValidationResult", "validate_project"]
6
- __version__ = "0.2.0"
6
+ __version__ = "0.3.0"
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)
@@ -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
- """Pinning to 'latest' is not reproducible — version.identifier/published_at
35
- must be present and not equal to the literal string 'latest'."""
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
- unpinned: list[str] = []
80
+ root = project_root(workspace, project_dir)
81
+ problems: list[str] = []
43
82
  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")
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(