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.
- openmapstack/__init__.py +6 -0
- openmapstack/__main__.py +3 -0
- openmapstack/checks/__init__.py +112 -0
- openmapstack/checks/geodata.py +318 -0
- openmapstack/checks/overrides.py +263 -0
- openmapstack/checks/presentation.py +168 -0
- openmapstack/checks/project.py +261 -0
- openmapstack/checks/provenance.py +133 -0
- openmapstack/checks/qgis.py +832 -0
- openmapstack/checks/rerun.py +340 -0
- openmapstack/checks/spatial.py +65 -0
- openmapstack/checks/validation.py +288 -0
- openmapstack/checks/visual.py +642 -0
- openmapstack/cli.py +431 -0
- openmapstack/expectations.py +284 -0
- openmapstack/integrity.py +137 -0
- openmapstack/project.py +79 -0
- openmapstack/rerun.py +332 -0
- openmapstack/schema.py +39 -0
- openmapstack/schemas/__init__.py +1 -0
- openmapstack/schemas/project-v1.schema.json +264 -0
- openmapstack/validation.py +1019 -0
- openmapstack/verify.py +386 -0
- openmapstack-0.2.0.dist-info/METADATA +268 -0
- openmapstack-0.2.0.dist-info/RECORD +29 -0
- openmapstack-0.2.0.dist-info/WHEEL +5 -0
- openmapstack-0.2.0.dist-info/entry_points.txt +2 -0
- openmapstack-0.2.0.dist-info/licenses/LICENSE +21 -0
- openmapstack-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|
openmapstack/project.py
ADDED
|
@@ -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 []
|