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,284 @@
1
+ """Attested, project-specific expectations for user-data verification.
2
+
3
+ An expectation supplies the golden answer that a generic checker cannot know.
4
+ The pipeline being checked must not certify that answer itself, so execution is
5
+ licensed only by an attestation bound to both the check/arguments and the
6
+ project's current input hash.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import re
14
+ from collections.abc import Callable
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .checks import AssertionResult, failed, not_testable, passed, warning
19
+ from .checks import geodata as geodata_checks
20
+ from .integrity import normalize_digest, sha256_file
21
+ from .project import get_in, project_path
22
+
23
+ ExpectationCheck = Callable[..., AssertionResult]
24
+
25
+ EXPECTATION_CHECKS: dict[str, ExpectationCheck] = {
26
+ "geodata.row_count": geodata_checks.row_count,
27
+ "geodata.feature_present": geodata_checks.feature_present,
28
+ "geodata.feature_absent": geodata_checks.feature_absent,
29
+ "geodata.feature_field_equals": geodata_checks.feature_field_equals,
30
+ "geodata.field_range": geodata_checks.field_range,
31
+ }
32
+
33
+ _IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
34
+ _RESERVED_ARGS = {"workspace", "project_dir"}
35
+
36
+
37
+ def expectation_digest(check: str, args: dict[str, Any]) -> str:
38
+ """Bind an attestation to the exact check and arguments it reviewed."""
39
+ canonical = json.dumps(
40
+ {"check": check, "args": args},
41
+ sort_keys=True,
42
+ separators=(",", ":"),
43
+ ensure_ascii=False,
44
+ default=str,
45
+ ).encode("utf-8")
46
+ return f"sha256:{hashlib.sha256(canonical).hexdigest()}"
47
+
48
+
49
+ def _evidence(
50
+ evidence_class: str,
51
+ *,
52
+ attestation: dict[str, Any] | None,
53
+ expected_expectation_hash: str,
54
+ ) -> dict[str, Any]:
55
+ result: dict[str, Any] = {
56
+ "class": evidence_class,
57
+ "expected_expectation_sha256": expected_expectation_hash,
58
+ }
59
+ if isinstance(attestation, dict):
60
+ for key in ("verified_by", "verified_against", "verified_at", "evidence_sha256"):
61
+ value = attestation.get(key)
62
+ if value not in (None, ""):
63
+ result[key] = value
64
+ return result
65
+
66
+
67
+ def _validate_args(
68
+ root: Path,
69
+ check: str,
70
+ args: object,
71
+ ) -> tuple[dict[str, Any] | None, AssertionResult | None]:
72
+ if not isinstance(args, dict):
73
+ return None, failed("expectation args must be a mapping", code="expectation_args_invalid")
74
+ if _RESERVED_ARGS & set(args):
75
+ return None, failed(
76
+ f"expectation args contain reserved names: {sorted(_RESERVED_ARGS & set(args))}",
77
+ code="expectation_args_invalid",
78
+ )
79
+ rules: dict[str, tuple[set[str], set[str]]] = {
80
+ "geodata.row_count": (
81
+ {"path", "equals", "at_least", "at_most"},
82
+ {"path"},
83
+ ),
84
+ "geodata.feature_present": ({"path", "id_field", "id"}, {"path", "id_field", "id"}),
85
+ "geodata.feature_absent": ({"path", "id_field", "id"}, {"path", "id_field", "id"}),
86
+ "geodata.feature_field_equals": (
87
+ {"path", "id_field", "id", "field", "equals"},
88
+ {"path", "id_field", "id", "field", "equals"},
89
+ ),
90
+ "geodata.field_range": ({"path", "field", "min", "max"}, {"path", "field"}),
91
+ }
92
+ allowed, required = rules[check]
93
+ extra = set(args) - allowed
94
+ missing = required - set(args)
95
+ if extra or missing:
96
+ return None, failed(
97
+ f"expectation args have extra={sorted(extra)} missing={sorted(missing)}",
98
+ code="expectation_args_invalid",
99
+ )
100
+ if check == "geodata.row_count":
101
+ constraints = [args.get(key) for key in ("equals", "at_least", "at_most") if key in args]
102
+ if not constraints or any(isinstance(value, bool) or not isinstance(value, int) or value < 0 for value in constraints):
103
+ return None, failed(
104
+ "row_count requires at least one non-negative integer constraint",
105
+ code="expectation_args_invalid",
106
+ )
107
+ if check == "geodata.field_range":
108
+ bounds = [args.get(key) for key in ("min", "max") if key in args]
109
+ if not bounds or any(isinstance(value, bool) or not isinstance(value, (int, float)) for value in bounds):
110
+ return None, failed(
111
+ "field_range requires at least one numeric min/max bound",
112
+ code="expectation_args_invalid",
113
+ )
114
+ path = args.get("path")
115
+ if project_path(root, path) is None:
116
+ return None, failed(
117
+ "expectation path must be safe and project-relative",
118
+ code="expectation_path_unsafe",
119
+ )
120
+ for key in ("field", "id_field"):
121
+ value = args.get(key)
122
+ if value is not None and (not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None):
123
+ return None, failed(
124
+ f"expectation {key} must be a simple identifier",
125
+ code="expectation_args_invalid",
126
+ )
127
+ return dict(args), None
128
+
129
+
130
+ def evaluate_expectation(
131
+ root: Path,
132
+ manifest: dict[str, Any],
133
+ expectation: object,
134
+ ) -> tuple[AssertionResult, dict[str, Any]]:
135
+ """Validate an attestation, then execute its allowlisted checker.
136
+
137
+ Returns the assertion result plus structured evidence for the verification
138
+ report. Unverified, incomplete, or stale attestations are warnings and do
139
+ not execute model- or user-supplied expected values.
140
+ """
141
+ if not isinstance(expectation, dict):
142
+ return failed("expectation must be a mapping", code="expectation_invalid"), {
143
+ "class": "invalid"
144
+ }
145
+
146
+ check = expectation.get("check")
147
+ args = expectation.get("args")
148
+ expectation_id = expectation.get("id")
149
+ if not isinstance(expectation_id, str) or not expectation_id.strip():
150
+ return failed("expectation id is required", code="expectation_invalid"), {
151
+ "class": "invalid"
152
+ }
153
+ if not isinstance(check, str) or check not in EXPECTATION_CHECKS:
154
+ return failed(
155
+ f"expectation {expectation_id!r} uses an unsupported check {check!r}",
156
+ code="expectation_check_unsupported",
157
+ ), {"class": "invalid"}
158
+ if not isinstance(args, dict):
159
+ return failed(
160
+ f"expectation {expectation_id!r} args must be a mapping",
161
+ code="expectation_args_invalid",
162
+ ), {"class": "invalid"}
163
+
164
+ expected_hash = expectation_digest(check, args)
165
+ attestation = expectation.get("attestation")
166
+ if not isinstance(attestation, dict) or attestation.get("status") != "verified":
167
+ evidence = _evidence(
168
+ "unverified",
169
+ attestation=attestation if isinstance(attestation, dict) else None,
170
+ expected_expectation_hash=expected_hash,
171
+ )
172
+ return warning(
173
+ f"expectation {expectation_id!r} is unverified; independent review must bind "
174
+ f"expectation_sha256 to {expected_hash}",
175
+ code="expectation_unverified",
176
+ ), evidence
177
+
178
+ required_attestation = (
179
+ "verified_by",
180
+ "verified_against",
181
+ "verified_at",
182
+ "evidence_sha256",
183
+ "expectation_sha256",
184
+ "inputs_hash",
185
+ )
186
+ missing = [key for key in required_attestation if not attestation.get(key)]
187
+ if missing:
188
+ evidence = _evidence(
189
+ "unverified",
190
+ attestation=attestation,
191
+ expected_expectation_hash=expected_hash,
192
+ )
193
+ return warning(
194
+ f"expectation {expectation_id!r} has incomplete verification evidence: {missing}",
195
+ code="expectation_attestation_incomplete",
196
+ ), evidence
197
+
198
+ if normalize_digest(attestation.get("expectation_sha256")) != expected_hash:
199
+ evidence = _evidence(
200
+ "stale_attestation",
201
+ attestation=attestation,
202
+ expected_expectation_hash=expected_hash,
203
+ )
204
+ return warning(
205
+ f"expectation {expectation_id!r} changed after review; expected digest is {expected_hash}",
206
+ code="expectation_changed",
207
+ ), evidence
208
+
209
+ current_inputs_hash = normalize_digest(get_in(manifest, "runs", "latest", "inputs_hash"))
210
+ attested_inputs_hash = normalize_digest(attestation.get("inputs_hash"))
211
+ if current_inputs_hash is None or attested_inputs_hash != current_inputs_hash:
212
+ evidence = _evidence(
213
+ "stale_attestation",
214
+ attestation=attestation,
215
+ expected_expectation_hash=expected_hash,
216
+ )
217
+ evidence["current_inputs_hash"] = current_inputs_hash
218
+ return warning(
219
+ f"expectation {expectation_id!r} was not verified against the current project inputs",
220
+ code="expectation_inputs_changed",
221
+ ), evidence
222
+
223
+ normalized_evidence_hash = normalize_digest(attestation.get("evidence_sha256"))
224
+ if normalized_evidence_hash is None:
225
+ return warning(
226
+ f"expectation {expectation_id!r} has a malformed evidence digest",
227
+ code="expectation_attestation_incomplete",
228
+ ), _evidence(
229
+ "unverified",
230
+ attestation=attestation,
231
+ expected_expectation_hash=expected_hash,
232
+ )
233
+
234
+ evidence_path = attestation.get("evidence_path")
235
+ if evidence_path is not None:
236
+ resolved_evidence = project_path(root, evidence_path)
237
+ if resolved_evidence is None:
238
+ return failed(
239
+ f"expectation {expectation_id!r} evidence_path is unsafe",
240
+ code="expectation_evidence_path_unsafe",
241
+ ), {"class": "invalid", "expected_expectation_sha256": expected_hash}
242
+ if not resolved_evidence.is_file():
243
+ return warning(
244
+ f"expectation {expectation_id!r} evidence file is unavailable",
245
+ code="expectation_evidence_unavailable",
246
+ ), _evidence(
247
+ "stale_attestation",
248
+ attestation=attestation,
249
+ expected_expectation_hash=expected_hash,
250
+ )
251
+ if sha256_file(resolved_evidence) != normalized_evidence_hash:
252
+ return warning(
253
+ f"expectation {expectation_id!r} evidence file changed after review",
254
+ code="expectation_evidence_changed",
255
+ ), _evidence(
256
+ "stale_attestation",
257
+ attestation=attestation,
258
+ expected_expectation_hash=expected_hash,
259
+ )
260
+
261
+ checked_args, arg_error = _validate_args(root, check, args)
262
+ if arg_error is not None:
263
+ return arg_error, {
264
+ "class": "invalid",
265
+ "expected_expectation_sha256": expected_hash,
266
+ }
267
+ if checked_args is None: # defensive: _validate_args returns one or the other
268
+ return failed("expectation args are invalid", code="expectation_args_invalid"), {
269
+ "class": "invalid",
270
+ "expected_expectation_sha256": expected_hash,
271
+ }
272
+
273
+ try:
274
+ result = EXPECTATION_CHECKS[check](root, **checked_args)
275
+ except Exception as exc: # noqa: BLE001 - one expectation must not abort the report
276
+ result = not_testable(f"{type(exc).__name__}: {exc}", code="check_error")
277
+ evidence = _evidence(
278
+ "attested",
279
+ attestation=attestation,
280
+ expected_expectation_hash=expected_hash,
281
+ )
282
+ if result.status == "passed":
283
+ result = passed(f"attested expectation {expectation_id!r} satisfied: {result.detail}")
284
+ return result, evidence
@@ -0,0 +1,137 @@
1
+ """Canonical, path-aware hashing for OpenMapStack run evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import shlex
7
+ from pathlib import Path
8
+ from typing import Any, Iterable
9
+
10
+ from .project import get_in, project_path
11
+
12
+
13
+ def sha256_file(path: Path) -> str:
14
+ digest = hashlib.sha256()
15
+ with path.open("rb") as stream:
16
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
17
+ digest.update(chunk)
18
+ return f"sha256:{digest.hexdigest()}"
19
+
20
+
21
+ def normalize_digest(value: object) -> str | None:
22
+ if not isinstance(value, str) or not value.strip():
23
+ return None
24
+ normalized = value.strip().lower()
25
+ if not normalized.startswith("sha256:"):
26
+ normalized = f"sha256:{normalized}"
27
+ payload = normalized.removeprefix("sha256:")
28
+ if len(payload) != 64 or any(char not in "0123456789abcdef" for char in payload):
29
+ return None
30
+ return normalized
31
+
32
+
33
+ def canonical_file_set_hash(root: Path, paths: Iterable[str | Path]) -> str:
34
+ """Hash a sorted set of project-relative path names and file bytes.
35
+
36
+ Including the relative name prevents two differently named inventories
37
+ with identical concatenated contents from sharing a digest. Duplicate
38
+ paths are collapsed because an inventory represents a set of files.
39
+ """
40
+ digest = hashlib.sha256()
41
+ normalized = sorted({Path(path).as_posix() for path in paths})
42
+ for relative in normalized:
43
+ target = project_path(root, relative)
44
+ if target is None or not target.is_file():
45
+ raise ValueError(f"cannot hash missing or unsafe project file: {relative}")
46
+ encoded = relative.encode("utf-8")
47
+ digest.update(len(encoded).to_bytes(8, "big"))
48
+ digest.update(encoded)
49
+ with target.open("rb") as stream:
50
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
51
+ digest.update(chunk)
52
+ return f"sha256:{digest.hexdigest()}"
53
+
54
+
55
+ def file_inventory(root: Path, paths: Iterable[str | Path]) -> list[dict[str, str]]:
56
+ normalized = sorted({Path(path).as_posix() for path in paths})
57
+ inventory: list[dict[str, str]] = []
58
+ for relative in normalized:
59
+ target = project_path(root, relative)
60
+ if target is None or not target.is_file():
61
+ raise ValueError(f"cannot inventory missing or unsafe project file: {relative}")
62
+ inventory.append({"path": relative, "sha256": sha256_file(target)})
63
+ return inventory
64
+
65
+
66
+ def declared_output_paths(project: dict[str, Any]) -> list[str]:
67
+ outputs = project.get("outputs") or {}
68
+ if not isinstance(outputs, dict):
69
+ return []
70
+ return sorted(
71
+ {
72
+ Path(output["path"]).as_posix()
73
+ for output in outputs.values()
74
+ if isinstance(output, dict)
75
+ and isinstance(output.get("path"), str)
76
+ and output["path"].strip()
77
+ }
78
+ )
79
+
80
+
81
+ def declared_input_paths(root: Path, project: dict[str, Any]) -> list[str]:
82
+ """Return the immutable inputs and canonical implementation files.
83
+
84
+ The manifest itself is deliberately excluded because it contains the
85
+ resulting digest. Sources, overrides, pipeline/command-local files, and
86
+ declared dependencies are the clean-rerun inputs defined by the spec.
87
+ """
88
+ # project_path() returns resolved paths, so every path compared below must be
89
+ # measured against a resolved root. On macOS the temp and /var trees are
90
+ # symlinks, so an unresolved root differs from a resolved child by a
91
+ # /private prefix and relative_to() raises.
92
+ resolved_root = root.resolve()
93
+ paths: set[str] = set()
94
+ for relative_dir in ("data/source", "data/overrides"):
95
+ directory = project_path(root, relative_dir)
96
+ if directory is not None and directory.is_dir():
97
+ paths.update(
98
+ path.relative_to(resolved_root).as_posix()
99
+ for path in directory.rglob("*")
100
+ if path.is_file()
101
+ )
102
+
103
+ implementation = get_in(project, "runtime", "implementation", default={})
104
+ if not isinstance(implementation, dict):
105
+ return sorted(paths)
106
+ pipeline = implementation.get("pipeline")
107
+ if isinstance(pipeline, str) and pipeline.strip():
108
+ paths.add(Path(pipeline).as_posix())
109
+ command = implementation.get("command")
110
+ if isinstance(command, str):
111
+ try:
112
+ tokens = shlex.split(command)
113
+ except ValueError:
114
+ tokens = []
115
+ else:
116
+ tokens = command if isinstance(command, list) else []
117
+ for token in tokens:
118
+ if not isinstance(token, str) or token.startswith("-"):
119
+ continue
120
+ target = project_path(root, token)
121
+ if target is not None and target.is_file():
122
+ paths.add(Path(token).as_posix())
123
+ for dependency in implementation.get("dependencies") or []:
124
+ if not isinstance(dependency, str):
125
+ continue
126
+ target = project_path(root, dependency)
127
+ if target is None:
128
+ continue
129
+ if target.is_file():
130
+ paths.add(Path(dependency).as_posix())
131
+ elif target.is_dir():
132
+ paths.update(
133
+ path.relative_to(resolved_root).as_posix()
134
+ for path in target.rglob("*")
135
+ if path.is_file()
136
+ )
137
+ return sorted(paths)
@@ -0,0 +1,79 @@
1
+ """Project loading and path helpers for ``openmapstack-project/v1``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+
12
+ class ProjectError(Exception):
13
+ """A project manifest cannot be loaded safely."""
14
+
15
+
16
+ def resolve_project_file(value: str | Path) -> Path:
17
+ """Resolve a manifest argument, accepting either a file or project directory."""
18
+ path = Path(value).expanduser()
19
+ if path.is_dir():
20
+ path = path / "project.yaml"
21
+ return path.resolve()
22
+
23
+
24
+ def load_project(value: str | Path) -> tuple[Path, dict[str, Any]]:
25
+ path = resolve_project_file(value)
26
+ if not path.is_file():
27
+ raise ProjectError(f"project manifest does not exist: {path}")
28
+ try:
29
+ loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
30
+ except (OSError, UnicodeError, yaml.YAMLError) as exc:
31
+ raise ProjectError(f"cannot parse {path}: {exc}") from exc
32
+ if not isinstance(loaded, dict):
33
+ raise ProjectError(f"project manifest must contain a YAML mapping: {path}")
34
+ return path, loaded
35
+
36
+
37
+ def load_json(path: Path) -> dict[str, Any]:
38
+ try:
39
+ loaded = json.loads(path.read_text(encoding="utf-8"))
40
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
41
+ raise ProjectError(f"cannot parse JSON file {path}: {exc}") from exc
42
+ if not isinstance(loaded, dict):
43
+ raise ProjectError(f"JSON document must contain an object: {path}")
44
+ return loaded
45
+
46
+
47
+ def project_path(root: Path, value: object) -> Path | None:
48
+ """Resolve a project-relative path without allowing escape from the project."""
49
+ if not isinstance(value, str) or not value.strip():
50
+ return None
51
+ relative = Path(value)
52
+ if relative.is_absolute():
53
+ return None
54
+ target = (root / relative).resolve()
55
+ try:
56
+ target.relative_to(root.resolve())
57
+ except ValueError:
58
+ return None
59
+ return target
60
+
61
+
62
+ def get_in(value: object, *keys: str, default: Any = None) -> Any:
63
+ current = value
64
+ for key in keys:
65
+ if not isinstance(current, dict) or key not in current:
66
+ return default
67
+ current = current[key]
68
+ return current
69
+
70
+
71
+ def step_outputs(step: object) -> list[str]:
72
+ if not isinstance(step, dict):
73
+ return []
74
+ raw = step.get("output")
75
+ if isinstance(raw, str):
76
+ return [part.strip() for part in raw.split(",") if part.strip()]
77
+ if isinstance(raw, list):
78
+ return [str(item).strip() for item in raw if str(item).strip()]
79
+ return []