portlearn 0.0.1.dev0__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.
- portlearn/__init__.py +18 -0
- portlearn/interfaces.py +539 -0
- portlearn/leakage.py +290 -0
- portlearn/manifest.py +298 -0
- portlearn/observations.py +288 -0
- portlearn/py.typed +0 -0
- portlearn/timing.py +328 -0
- portlearn-0.0.1.dev0.dist-info/METADATA +70 -0
- portlearn-0.0.1.dev0.dist-info/RECORD +11 -0
- portlearn-0.0.1.dev0.dist-info/WHEEL +4 -0
- portlearn-0.0.1.dev0.dist-info/licenses/LICENSE +202 -0
portlearn/leakage.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Leakage battery for the contract error classes.
|
|
2
|
+
|
|
3
|
+
This module implements the invariant battery layer of the public
|
|
4
|
+
contract: a battery is a list of :class:`LeakageCase` scenarios — each a
|
|
5
|
+
named finance-language leak attempt paired with the single
|
|
6
|
+
contract error class expected to block it — executed totally by
|
|
7
|
+
:func:`run_leakage_cases`, which reports every outcome as a
|
|
8
|
+
:class:`LeakageFinding` and never lets an attempted leak fail silently.
|
|
9
|
+
|
|
10
|
+
Design laws honoured here:
|
|
11
|
+
|
|
12
|
+
* ``FROZEN_CONTRACT_ERRORS`` is exactly the six module-qualified
|
|
13
|
+
contract error classes — a battery can never expect a foreign error
|
|
14
|
+
class, and every expected error must be one of these six.
|
|
15
|
+
* Admission is fail-closed: a case with a blank or non-string name, a
|
|
16
|
+
non-callable attempt, or an expected error outside the supported
|
|
17
|
+
contract error classes is rejected before anything runs.
|
|
18
|
+
* Totality: every admitted case produces exactly one finding, whatever
|
|
19
|
+
it does — a wrong error class reports ``blocked is False`` with
|
|
20
|
+
``detail == "wrong error class"``, and a leak that raises nothing at
|
|
21
|
+
all reports ``error_type is None`` with
|
|
22
|
+
``detail == "leak was NOT blocked"``.
|
|
23
|
+
* The summary derives only from the findings:
|
|
24
|
+
``"{blocked}/{total} attempted leaks blocked"``.
|
|
25
|
+
|
|
26
|
+
The module imports stdlib only at module level plus the contract error
|
|
27
|
+
classes by import (aware-instant validation remains implemented solely
|
|
28
|
+
in ``portlearn.timing``; nothing here re-implements it). Importing this
|
|
29
|
+
module performs no I/O and executes no case.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from collections.abc import Callable, Sequence
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from portlearn.observations import (
|
|
38
|
+
AmbiguousObservationError,
|
|
39
|
+
FeatureLineageError,
|
|
40
|
+
)
|
|
41
|
+
from portlearn.timing import (
|
|
42
|
+
FutureInformationError,
|
|
43
|
+
InvalidChronologyError,
|
|
44
|
+
MissingAvailabilityError,
|
|
45
|
+
NaiveTimestampError,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [ # noqa: RUF022 — frozen order (types, function, constant)
|
|
49
|
+
"LeakageCase",
|
|
50
|
+
"LeakageFinding",
|
|
51
|
+
"LeakageReport",
|
|
52
|
+
"run_leakage_cases",
|
|
53
|
+
"FROZEN_CONTRACT_ERRORS",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
#: The exact set of contract error classes a battery case may
|
|
58
|
+
#: expect — the six module-qualified contract error classes. A tuple,
|
|
59
|
+
#: so the order is itself fixed and import-stable.
|
|
60
|
+
FROZEN_CONTRACT_ERRORS: tuple[type[Exception], ...] = (
|
|
61
|
+
FutureInformationError,
|
|
62
|
+
InvalidChronologyError,
|
|
63
|
+
MissingAvailabilityError,
|
|
64
|
+
NaiveTimestampError,
|
|
65
|
+
AmbiguousObservationError,
|
|
66
|
+
FeatureLineageError,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
_FROZEN_ERROR_SET: frozenset[type[Exception]] = frozenset(FROZEN_CONTRACT_ERRORS)
|
|
70
|
+
|
|
71
|
+
#: Detail pinned for a case whose attempt raised an error of the wrong
|
|
72
|
+
#: class — the leak was blocked, but by the wrong law.
|
|
73
|
+
_WRONG_ERROR_CLASS_DETAIL = "wrong error class"
|
|
74
|
+
|
|
75
|
+
#: Detail pinned for a case whose attempt raised nothing at all — the
|
|
76
|
+
#: attempted leak was not blocked by any contract.
|
|
77
|
+
_UNBLOCKED_LEAK_DETAIL = "leak was NOT blocked"
|
|
78
|
+
|
|
79
|
+
#: Detail pinned for a case blocked by exactly its expected error.
|
|
80
|
+
_BLOCKED_DETAIL = "blocked"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LeakageCase:
|
|
84
|
+
"""One named leak attempt paired with its expected contract error.
|
|
85
|
+
|
|
86
|
+
The attempt is a zero-argument callable: running it is the leak
|
|
87
|
+
scenario (for example, admitting a forecast whose feature vintage
|
|
88
|
+
postdates the decision instant). ``expected_error`` must be exactly
|
|
89
|
+
one of :data:`FROZEN_CONTRACT_ERRORS` — never a foreign class — and
|
|
90
|
+
admission is fail-closed with ``ValueError`` for a blank or
|
|
91
|
+
non-string ``name``, a non-callable ``attempt``, or an expected
|
|
92
|
+
error outside the supported contract error classes (including
|
|
93
|
+
non-class inputs).
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
__slots__ = ("attempt", "expected_error", "name")
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
name: str,
|
|
101
|
+
attempt: Callable[[], Any],
|
|
102
|
+
expected_error: type[Exception],
|
|
103
|
+
) -> None:
|
|
104
|
+
if not isinstance(name, str) or not name.strip():
|
|
105
|
+
raise ValueError(
|
|
106
|
+
"LeakageCase.name must be a non-blank string identifying "
|
|
107
|
+
f"the leak scenario; got {name!r}."
|
|
108
|
+
)
|
|
109
|
+
if not callable(attempt):
|
|
110
|
+
raise ValueError( # noqa: TRY004 — admission rejects with ValueError
|
|
111
|
+
"LeakageCase.attempt must be callable — the zero-argument "
|
|
112
|
+
"leak scenario to execute; got "
|
|
113
|
+
f"{type(attempt).__name__}: {attempt!r}."
|
|
114
|
+
)
|
|
115
|
+
if not isinstance(expected_error, type) or not issubclass(
|
|
116
|
+
expected_error, Exception
|
|
117
|
+
):
|
|
118
|
+
raise ValueError( # noqa: TRY004 — admission rejects with ValueError
|
|
119
|
+
"expected_error must be one of PortLearn's supported contract "
|
|
120
|
+
"error types (portlearn.leakage.FROZEN_CONTRACT_ERRORS); "
|
|
121
|
+
f"got {expected_error!r}."
|
|
122
|
+
)
|
|
123
|
+
if expected_error not in _FROZEN_ERROR_SET:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
"expected_error must be one of PortLearn's supported contract "
|
|
126
|
+
"error types (portlearn.leakage.FROZEN_CONTRACT_ERRORS); "
|
|
127
|
+
f"{expected_error.__module__}.{expected_error.__qualname__} "
|
|
128
|
+
"is not one of them."
|
|
129
|
+
)
|
|
130
|
+
object.__setattr__(self, "name", name)
|
|
131
|
+
object.__setattr__(self, "attempt", attempt)
|
|
132
|
+
object.__setattr__(self, "expected_error", expected_error)
|
|
133
|
+
|
|
134
|
+
def __setattr__(self, key: str, value: Any) -> None:
|
|
135
|
+
raise AttributeError(
|
|
136
|
+
f"LeakageCase is immutable: field {key!r} cannot be reassigned."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def __delattr__(self, key: str) -> None:
|
|
140
|
+
raise AttributeError(
|
|
141
|
+
f"LeakageCase is immutable: field {key!r} cannot be deleted."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def __repr__(self) -> str:
|
|
145
|
+
return (
|
|
146
|
+
f"LeakageCase(name={self.name!r}, "
|
|
147
|
+
f"expected_error={self.expected_error.__qualname__})"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class LeakageFinding:
|
|
152
|
+
"""The total outcome of executing one :class:`LeakageCase`.
|
|
153
|
+
|
|
154
|
+
``blocked`` is true only when the attempt raised exactly the case's
|
|
155
|
+
expected error class; ``error_type`` is the class that was actually
|
|
156
|
+
raised (``None`` when nothing was raised); ``detail`` is one of the
|
|
157
|
+
three pinned outcome phrases, so reports carry laws, never message
|
|
158
|
+
tails.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
__slots__ = ("blocked", "case", "detail", "error_type")
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
case: LeakageCase,
|
|
166
|
+
blocked: bool,
|
|
167
|
+
error_type: type[Exception] | None,
|
|
168
|
+
detail: str,
|
|
169
|
+
) -> None:
|
|
170
|
+
object.__setattr__(self, "case", case)
|
|
171
|
+
object.__setattr__(self, "blocked", blocked)
|
|
172
|
+
object.__setattr__(self, "error_type", error_type)
|
|
173
|
+
object.__setattr__(self, "detail", detail)
|
|
174
|
+
|
|
175
|
+
def __setattr__(self, key: str, value: Any) -> None:
|
|
176
|
+
raise AttributeError(
|
|
177
|
+
f"LeakageFinding is immutable: field {key!r} cannot be reassigned."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def __delattr__(self, key: str) -> None:
|
|
181
|
+
raise AttributeError(
|
|
182
|
+
f"LeakageFinding is immutable: field {key!r} cannot be deleted."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def __repr__(self) -> str:
|
|
186
|
+
error_name = (
|
|
187
|
+
self.error_type.__qualname__
|
|
188
|
+
if self.error_type is not None
|
|
189
|
+
else "None"
|
|
190
|
+
)
|
|
191
|
+
return (
|
|
192
|
+
f"LeakageFinding(case={self.case.name!r}, "
|
|
193
|
+
f"blocked={self.blocked!r}, error_type={error_name}, "
|
|
194
|
+
f"detail={self.detail!r})"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class LeakageReport:
|
|
199
|
+
"""Findings from one total battery run, in admission order.
|
|
200
|
+
|
|
201
|
+
``all_blocked`` and ``summary`` derive only from ``findings``; the
|
|
202
|
+
empty report is admitted and summarises as ``"0/0 attempted leaks
|
|
203
|
+
blocked"`` with ``all_blocked is True`` (vacuous truth: nothing
|
|
204
|
+
leaked).
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
__slots__ = ("findings",)
|
|
208
|
+
|
|
209
|
+
def __init__(self, findings: Sequence[LeakageFinding]) -> None:
|
|
210
|
+
object.__setattr__(self, "findings", tuple(findings))
|
|
211
|
+
|
|
212
|
+
def __setattr__(self, key: str, value: Any) -> None:
|
|
213
|
+
raise AttributeError(
|
|
214
|
+
f"LeakageReport is immutable: field {key!r} cannot be reassigned."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def __delattr__(self, key: str) -> None:
|
|
218
|
+
raise AttributeError(
|
|
219
|
+
f"LeakageReport is immutable: field {key!r} cannot be deleted."
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def all_blocked(self) -> bool:
|
|
224
|
+
"""True when every finding reports its leak as blocked."""
|
|
225
|
+
return all(finding.blocked for finding in self.findings)
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def summary(self) -> str:
|
|
229
|
+
"""``"{blocked}/{total} attempted leaks blocked"``."""
|
|
230
|
+
blocked = sum(1 for finding in self.findings if finding.blocked)
|
|
231
|
+
return f"{blocked}/{len(self.findings)} attempted leaks blocked"
|
|
232
|
+
|
|
233
|
+
def __repr__(self) -> str:
|
|
234
|
+
return f"LeakageReport(summary={self.summary!r})"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def run_leakage_cases(cases: Sequence[LeakageCase]) -> LeakageReport:
|
|
238
|
+
"""Execute ``cases`` totally, reporting every outcome.
|
|
239
|
+
|
|
240
|
+
The battery is fail-closed on admission — it must be a non-empty
|
|
241
|
+
sequence of :class:`LeakageCase` (an empty battery proves nothing
|
|
242
|
+
and is rejected with ``ValueError``). Every admitted case then
|
|
243
|
+
produces exactly one finding:
|
|
244
|
+
|
|
245
|
+
* the attempt raises the case's expected error class → blocked,
|
|
246
|
+
``error_type`` = the expected class, ``detail == "blocked"``;
|
|
247
|
+
* the attempt raises a different exception class → not blocked,
|
|
248
|
+
``error_type`` = the actually-raised class,
|
|
249
|
+
``detail == "wrong error class"``;
|
|
250
|
+
* the attempt raises nothing → not blocked, ``error_type is None``,
|
|
251
|
+
``detail == "leak was NOT blocked"``.
|
|
252
|
+
|
|
253
|
+
A raised exception is never allowed to abort the run: outcomes are
|
|
254
|
+
converted to findings, so an attempted leak can never disappear
|
|
255
|
+
silently.
|
|
256
|
+
"""
|
|
257
|
+
if not cases:
|
|
258
|
+
raise ValueError(
|
|
259
|
+
"run_leakage_cases requires a non-empty sequence of LeakageCase "
|
|
260
|
+
"scenarios — an empty battery executes nothing and proves "
|
|
261
|
+
"nothing."
|
|
262
|
+
)
|
|
263
|
+
findings: list[LeakageFinding] = []
|
|
264
|
+
for case in cases:
|
|
265
|
+
if not isinstance(case, LeakageCase):
|
|
266
|
+
raise ValueError( # noqa: TRY004 — admission is uniformly ValueError
|
|
267
|
+
"run_leakage_cases requires LeakageCase scenarios; got "
|
|
268
|
+
f"{type(case).__name__}: {case!r}."
|
|
269
|
+
)
|
|
270
|
+
try:
|
|
271
|
+
case.attempt()
|
|
272
|
+
except Exception as raised: # noqa: BLE001 — totality is the law
|
|
273
|
+
actual = type(raised)
|
|
274
|
+
if actual is case.expected_error:
|
|
275
|
+
findings.append(
|
|
276
|
+
LeakageFinding(
|
|
277
|
+
case, True, actual, _BLOCKED_DETAIL
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
else:
|
|
281
|
+
findings.append(
|
|
282
|
+
LeakageFinding(
|
|
283
|
+
case, False, actual, _WRONG_ERROR_CLASS_DETAIL
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
else:
|
|
287
|
+
findings.append(
|
|
288
|
+
LeakageFinding(case, False, None, _UNBLOCKED_LEAK_DETAIL)
|
|
289
|
+
)
|
|
290
|
+
return LeakageReport(findings)
|
portlearn/manifest.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Run manifest: minimal, aware-only, canonical-JSON run identity.
|
|
2
|
+
|
|
3
|
+
This module implements the run-manifest layer of the public
|
|
4
|
+
contract: every synthetic wiring run records one :class:`RunManifest`
|
|
5
|
+
carrying exactly six fields — ``run_id``, ``created_at``,
|
|
6
|
+
``python_version``, ``package_version``, ``dependency_pins``,
|
|
7
|
+
``commands`` — and nothing else. The schema is deliberately minimal:
|
|
8
|
+
**no ``seed`` field exists** (seeds live in run parameters,
|
|
9
|
+
not in run identity), so a manifest can never silently assert
|
|
10
|
+
randomness control it does not provide.
|
|
11
|
+
|
|
12
|
+
Design laws honoured here:
|
|
13
|
+
|
|
14
|
+
* **Identity is fail-closed** — blank ``run_id`` /
|
|
15
|
+
``python_version`` / ``package_version``, an empty or blank-valued
|
|
16
|
+
``dependency_pins`` mapping, or an empty ``commands`` sequence
|
|
17
|
+
rejects with ``ValueError``.
|
|
18
|
+
* **Aware-ness law** — ``created_at`` must be a timezone-aware
|
|
19
|
+
instant; naive datetimes and calendar dates reject with
|
|
20
|
+
``NaiveTimestampError`` (reused by import from ``portlearn.timing``,
|
|
21
|
+
where it is solely implemented).
|
|
22
|
+
* **Canonical serialization** — ``to_json()`` emits deterministic
|
|
23
|
+
canonical JSON: sorted keys, the compact separators ``","`` and
|
|
24
|
+
``":"``, and ISO-8601 datetime text carrying the original offset.
|
|
25
|
+
``from_json`` inverts it exactly and rejects offset-free serialized
|
|
26
|
+
instants with ``NaiveTimestampError``.
|
|
27
|
+
* **Offset preservation** — the round trip preserves the original
|
|
28
|
+
UTC offset exactly (a ``+10:00`` instant stays ``+10:00``), because
|
|
29
|
+
the offset is part of the recorded fact.
|
|
30
|
+
* **Equality** — two manifests are equal iff their canonical JSON
|
|
31
|
+
bytes are equal, so manifest equality is byte-stable identity.
|
|
32
|
+
|
|
33
|
+
The module imports the standard library plus ``NaiveTimestampError``
|
|
34
|
+
and the aware-instant validator from ``portlearn.timing`` by import;
|
|
35
|
+
importing it performs no I/O and writes nothing.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
from collections.abc import Mapping, Sequence
|
|
42
|
+
from datetime import datetime
|
|
43
|
+
from types import MappingProxyType
|
|
44
|
+
from typing import Any, ClassVar
|
|
45
|
+
|
|
46
|
+
from portlearn.timing import NaiveTimestampError, _require_aware_instant
|
|
47
|
+
|
|
48
|
+
__all__ = ["RunManifest"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
52
|
+
"""``object_pairs_hook`` that fails closed on duplicate object keys.
|
|
53
|
+
|
|
54
|
+
``json.loads`` silently keeps the last value of a duplicated key;
|
|
55
|
+
a manifest must never accept an ambiguous payload, so any
|
|
56
|
+
duplicate — at the top level or in any nested object — rejects.
|
|
57
|
+
Raises ``_DuplicateKeyError`` (a ``ValueError``) naming the key.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
result: dict[str, Any] = {}
|
|
61
|
+
for key, value in pairs:
|
|
62
|
+
if key in result:
|
|
63
|
+
raise _DuplicateKeyError(
|
|
64
|
+
f"duplicate JSON object key {key!r} — the payload is "
|
|
65
|
+
"ambiguous and no last-write-wins reading is allowed"
|
|
66
|
+
)
|
|
67
|
+
result[key] = value
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _DuplicateKeyError(ValueError):
|
|
72
|
+
"""Internal marker: the JSON text contained a duplicate object key."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RunManifest:
|
|
76
|
+
"""Minimal six-field run identity with canonical JSON serialization.
|
|
77
|
+
|
|
78
|
+
The public schema is exactly the six fields — there is no ``seed``
|
|
79
|
+
field: seeds live in run parameters, not run identity.
|
|
80
|
+
|
|
81
|
+
``created_at`` keeps its original UTC offset through every
|
|
82
|
+
serialization round trip; equality is canonical-JSON byte equality.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
__slots__ = (
|
|
86
|
+
"commands",
|
|
87
|
+
"created_at",
|
|
88
|
+
"dependency_pins",
|
|
89
|
+
"package_version",
|
|
90
|
+
"python_version",
|
|
91
|
+
"run_id",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
#: The exact public schema keys, in canonical (sorted) order.
|
|
95
|
+
_SCHEMA_KEYS: ClassVar[tuple[str, ...]] = (
|
|
96
|
+
"commands",
|
|
97
|
+
"created_at",
|
|
98
|
+
"dependency_pins",
|
|
99
|
+
"package_version",
|
|
100
|
+
"python_version",
|
|
101
|
+
"run_id",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
run_id: str,
|
|
107
|
+
created_at: datetime,
|
|
108
|
+
python_version: str,
|
|
109
|
+
package_version: str,
|
|
110
|
+
dependency_pins: Mapping[str, str],
|
|
111
|
+
commands: Sequence[str],
|
|
112
|
+
) -> None:
|
|
113
|
+
if not isinstance(run_id, str) or not run_id.strip():
|
|
114
|
+
raise ValueError(
|
|
115
|
+
"RunManifest.run_id must be a non-blank string identifying "
|
|
116
|
+
f"the run; got {run_id!r}."
|
|
117
|
+
)
|
|
118
|
+
if not isinstance(python_version, str) or not python_version.strip():
|
|
119
|
+
raise ValueError(
|
|
120
|
+
"RunManifest.python_version must be a non-blank string; "
|
|
121
|
+
f"got {python_version!r}."
|
|
122
|
+
)
|
|
123
|
+
if not isinstance(package_version, str) or not package_version.strip():
|
|
124
|
+
raise ValueError(
|
|
125
|
+
"RunManifest.package_version must be a non-blank string; "
|
|
126
|
+
f"got {package_version!r}."
|
|
127
|
+
)
|
|
128
|
+
# Aware-ness law: reuse the single timing-owned validator by
|
|
129
|
+
# import — never re-implement it here.
|
|
130
|
+
aware_created_at = _require_aware_instant(
|
|
131
|
+
created_at, "RunManifest.created_at"
|
|
132
|
+
)
|
|
133
|
+
if not isinstance(dependency_pins, Mapping):
|
|
134
|
+
raise ValueError( # noqa: TRY004 — admission rejects with ValueError
|
|
135
|
+
"RunManifest.dependency_pins must be a mapping of "
|
|
136
|
+
"distribution name to version pin; got "
|
|
137
|
+
f"{type(dependency_pins).__name__}: {dependency_pins!r}."
|
|
138
|
+
)
|
|
139
|
+
if not dependency_pins:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
"RunManifest.dependency_pins must contain at least one "
|
|
142
|
+
"(distribution, pin) pair — a manifest with no dependency "
|
|
143
|
+
"provenance is not fail-closed."
|
|
144
|
+
)
|
|
145
|
+
for distribution, pin in dependency_pins.items():
|
|
146
|
+
if not isinstance(distribution, str) or not distribution.strip():
|
|
147
|
+
raise ValueError(
|
|
148
|
+
"RunManifest.dependency_pins keys must be non-blank "
|
|
149
|
+
f"distribution names; got {distribution!r}."
|
|
150
|
+
)
|
|
151
|
+
if not isinstance(pin, str) or not pin.strip():
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"RunManifest.dependency_pins values must be non-blank "
|
|
154
|
+
f"version pins; got {pin!r} for {distribution!r}."
|
|
155
|
+
)
|
|
156
|
+
if isinstance(commands, (str, bytes)) or not isinstance(
|
|
157
|
+
commands, Sequence
|
|
158
|
+
):
|
|
159
|
+
raise ValueError( # noqa: TRY004 — admission rejects with ValueError
|
|
160
|
+
"RunManifest.commands must be a sequence of command "
|
|
161
|
+
"strings; got "
|
|
162
|
+
f"{type(commands).__name__}: {commands!r}."
|
|
163
|
+
)
|
|
164
|
+
commands_tuple = tuple(commands)
|
|
165
|
+
if not commands_tuple:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
"RunManifest.commands must contain at least one command."
|
|
168
|
+
)
|
|
169
|
+
for command in commands_tuple:
|
|
170
|
+
if not isinstance(command, str) or not command.strip():
|
|
171
|
+
raise ValueError(
|
|
172
|
+
"RunManifest.commands entries must be non-blank "
|
|
173
|
+
f"strings; got {command!r}."
|
|
174
|
+
)
|
|
175
|
+
object.__setattr__(self, "run_id", run_id)
|
|
176
|
+
object.__setattr__(self, "created_at", aware_created_at)
|
|
177
|
+
object.__setattr__(self, "python_version", python_version)
|
|
178
|
+
object.__setattr__(self, "package_version", package_version)
|
|
179
|
+
# Genuine immutability: the pins are stored as a read-only
|
|
180
|
+
# mapping over a private copied dict — never the caller's
|
|
181
|
+
# mapping and never a mutable dict — so no in-place mutation
|
|
182
|
+
# can change the manifest, its hash, or its serialization.
|
|
183
|
+
object.__setattr__(
|
|
184
|
+
self, "dependency_pins", MappingProxyType(dict(dependency_pins))
|
|
185
|
+
)
|
|
186
|
+
object.__setattr__(self, "commands", commands_tuple)
|
|
187
|
+
|
|
188
|
+
def __setattr__(self, key: str, value: Any) -> None:
|
|
189
|
+
raise AttributeError(
|
|
190
|
+
f"RunManifest is immutable: field {key!r} cannot be reassigned."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def __delattr__(self, key: str) -> None:
|
|
194
|
+
raise AttributeError(
|
|
195
|
+
f"RunManifest is immutable: field {key!r} cannot be deleted."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def to_json(self) -> str:
|
|
199
|
+
"""Canonical JSON text: sorted keys, compact separators, the
|
|
200
|
+
original UTC offset preserved in the ISO-8601 instant."""
|
|
201
|
+
payload: dict[str, Any] = {
|
|
202
|
+
"run_id": self.run_id,
|
|
203
|
+
"created_at": self.created_at.isoformat(),
|
|
204
|
+
"python_version": self.python_version,
|
|
205
|
+
"package_version": self.package_version,
|
|
206
|
+
"dependency_pins": dict(self.dependency_pins),
|
|
207
|
+
"commands": list(self.commands),
|
|
208
|
+
}
|
|
209
|
+
return json.dumps(
|
|
210
|
+
payload,
|
|
211
|
+
sort_keys=True,
|
|
212
|
+
separators=(",", ":"),
|
|
213
|
+
ensure_ascii=True,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def from_json(cls, text: str) -> RunManifest:
|
|
218
|
+
"""Reconstruct a manifest from canonical JSON text.
|
|
219
|
+
|
|
220
|
+
Malformed JSON, a non-object payload, a payload whose keys
|
|
221
|
+
are not exactly the six schema keys, or any duplicated object
|
|
222
|
+
key (top-level or nested) rejects with ``ValueError`` —
|
|
223
|
+
duplicate keys never resolve last-write-wins; a serialized
|
|
224
|
+
``created_at`` that carries no UTC offset rejects with
|
|
225
|
+
``NaiveTimestampError`` — never is a default timezone
|
|
226
|
+
silently assumed.
|
|
227
|
+
"""
|
|
228
|
+
if not isinstance(text, str):
|
|
229
|
+
raise ValueError( # noqa: TRY004 — parsing rejects with ValueError
|
|
230
|
+
"RunManifest.from_json requires JSON text; got "
|
|
231
|
+
f"{type(text).__name__}: {text!r}."
|
|
232
|
+
)
|
|
233
|
+
try:
|
|
234
|
+
payload = json.loads(text, object_pairs_hook=_reject_duplicate_keys)
|
|
235
|
+
except _DuplicateKeyError as duplicate:
|
|
236
|
+
raise ValueError(
|
|
237
|
+
"RunManifest.from_json rejects JSON text with duplicate "
|
|
238
|
+
f"object keys — fail-closed: {duplicate}."
|
|
239
|
+
) from None
|
|
240
|
+
except ValueError as malformed:
|
|
241
|
+
raise ValueError(
|
|
242
|
+
"RunManifest.from_json requires well-formed JSON text; "
|
|
243
|
+
f"parsing failed: {malformed!r}."
|
|
244
|
+
) from None
|
|
245
|
+
if not isinstance(payload, dict):
|
|
246
|
+
raise ValueError( # noqa: TRY004 — parsing rejects with ValueError
|
|
247
|
+
"RunManifest.from_json requires a JSON object with exactly "
|
|
248
|
+
f"the six schema keys; got {type(payload).__name__}."
|
|
249
|
+
)
|
|
250
|
+
if set(payload) != set(cls._SCHEMA_KEYS):
|
|
251
|
+
raise ValueError(
|
|
252
|
+
"RunManifest.from_json requires exactly the six schema "
|
|
253
|
+
f"keys {list(cls._SCHEMA_KEYS)}; got {sorted(payload)}."
|
|
254
|
+
)
|
|
255
|
+
raw_created_at = payload["created_at"]
|
|
256
|
+
if not isinstance(raw_created_at, str):
|
|
257
|
+
raise ValueError( # noqa: TRY004 — parsing rejects with ValueError
|
|
258
|
+
"RunManifest.from_json requires created_at as ISO-8601 "
|
|
259
|
+
f"text; got {raw_created_at!r}."
|
|
260
|
+
)
|
|
261
|
+
try:
|
|
262
|
+
parsed_created_at = datetime.fromisoformat(raw_created_at)
|
|
263
|
+
except ValueError as unparsable:
|
|
264
|
+
raise ValueError(
|
|
265
|
+
"RunManifest.from_json requires created_at as ISO-8601 "
|
|
266
|
+
f"text; got {raw_created_at!r} ({unparsable!r})."
|
|
267
|
+
) from None
|
|
268
|
+
if parsed_created_at.tzinfo is None or (
|
|
269
|
+
parsed_created_at.utcoffset() is None
|
|
270
|
+
):
|
|
271
|
+
raise NaiveTimestampError(
|
|
272
|
+
"RunManifest.from_json: created_at is a naive timestamp — "
|
|
273
|
+
"it carries no explicit UTC offset, so it cannot be placed "
|
|
274
|
+
f"on the single timeline; got {raw_created_at!r}. Supply "
|
|
275
|
+
"an offset-carrying ISO-8601 instant instead."
|
|
276
|
+
)
|
|
277
|
+
return cls(
|
|
278
|
+
run_id=payload["run_id"],
|
|
279
|
+
created_at=parsed_created_at,
|
|
280
|
+
python_version=payload["python_version"],
|
|
281
|
+
package_version=payload["package_version"],
|
|
282
|
+
dependency_pins=payload["dependency_pins"],
|
|
283
|
+
commands=payload["commands"],
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def __eq__(self, other: object) -> bool:
|
|
287
|
+
if not isinstance(other, RunManifest):
|
|
288
|
+
return NotImplemented
|
|
289
|
+
return self.to_json() == other.to_json()
|
|
290
|
+
|
|
291
|
+
def __hash__(self) -> int:
|
|
292
|
+
return hash(self.to_json())
|
|
293
|
+
|
|
294
|
+
def __repr__(self) -> str:
|
|
295
|
+
created = self.created_at.isoformat()
|
|
296
|
+
return (
|
|
297
|
+
f"RunManifest(run_id={self.run_id!r}, created_at={created!r})"
|
|
298
|
+
)
|