scientific-workflow 0.4.4__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.
- scientific_workflow/__init__.py +39 -0
- scientific_workflow/_control.py +59 -0
- scientific_workflow/api.md +221 -0
- scientific_workflow/dependencies.py +198 -0
- scientific_workflow/errors.py +29 -0
- scientific_workflow/npy.py +1449 -0
- scientific_workflow/project.py +56 -0
- scientific_workflow/py.typed +1 -0
- scientific_workflow/reader.py +580 -0
- scientific_workflow/reporting.py +61 -0
- scientific_workflow/state.py +55 -0
- scientific_workflow-0.4.4.dist-info/METADATA +227 -0
- scientific_workflow-0.4.4.dist-info/RECORD +17 -0
- scientific_workflow-0.4.4.dist-info/WHEEL +5 -0
- scientific_workflow-0.4.4.dist-info/entry_points.txt +2 -0
- scientific_workflow-0.4.4.dist-info/licenses/LICENSE +21 -0
- scientific_workflow-0.4.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Official verified Python reader for Scientific Workflow recordings."""
|
|
2
|
+
|
|
3
|
+
from .errors import (
|
|
4
|
+
DecoderError,
|
|
5
|
+
IntegrityError,
|
|
6
|
+
MetadataError,
|
|
7
|
+
RecordError,
|
|
8
|
+
RecordingError,
|
|
9
|
+
RecordingNotCompleteError,
|
|
10
|
+
UnknownStreamError,
|
|
11
|
+
)
|
|
12
|
+
from .state import StateField, StateRecord, StateSeries
|
|
13
|
+
from .reader import (
|
|
14
|
+
FORMAT_NAME,
|
|
15
|
+
FORMAT_VERSION,
|
|
16
|
+
Decoder,
|
|
17
|
+
RecordingReader,
|
|
18
|
+
open_completed_recording,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Decoder",
|
|
23
|
+
"DecoderError",
|
|
24
|
+
"FORMAT_NAME",
|
|
25
|
+
"FORMAT_VERSION",
|
|
26
|
+
"IntegrityError",
|
|
27
|
+
"MetadataError",
|
|
28
|
+
"RecordError",
|
|
29
|
+
"RecordingError",
|
|
30
|
+
"RecordingNotCompleteError",
|
|
31
|
+
"RecordingReader",
|
|
32
|
+
"StateField",
|
|
33
|
+
"StateRecord",
|
|
34
|
+
"StateSeries",
|
|
35
|
+
"UnknownStreamError",
|
|
36
|
+
"open_completed_recording",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
__version__ = "0.4.4"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Private cooperative control for the coordinated standard converter."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
_TOKEN = "parent"
|
|
8
|
+
_LAST_CHECK = 0.0
|
|
9
|
+
_PAUSE_STARTED = None
|
|
10
|
+
_PAUSE_TOTAL = 0.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def state() -> tuple[Path | None, bool, bool]:
|
|
14
|
+
value = os.environ.get("WORKFLOW_CONTROL_PATH")
|
|
15
|
+
if not value:
|
|
16
|
+
return None, False, False
|
|
17
|
+
path = Path(value)
|
|
18
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
19
|
+
global _PAUSE_STARTED, _PAUSE_TOTAL
|
|
20
|
+
now = time.monotonic()
|
|
21
|
+
if document["paused"] and _PAUSE_STARTED is None:
|
|
22
|
+
_PAUSE_STARTED = now
|
|
23
|
+
elif not document["paused"] and _PAUSE_STARTED is not None:
|
|
24
|
+
_PAUSE_TOTAL += now - _PAUSE_STARTED
|
|
25
|
+
_PAUSE_STARTED = None
|
|
26
|
+
return path, document["paused"], document["cancelled"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def acknowledgement(path: Path, token: str) -> Path:
|
|
30
|
+
return path.with_name(path.name + f".{token}.paused")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def checkpoint(*, force: bool = False) -> None:
|
|
34
|
+
global _LAST_CHECK
|
|
35
|
+
now = time.monotonic()
|
|
36
|
+
if not force and now - _LAST_CHECK < 0.02:
|
|
37
|
+
return
|
|
38
|
+
_LAST_CHECK = now
|
|
39
|
+
path, paused, cancelled = state()
|
|
40
|
+
if cancelled:
|
|
41
|
+
raise InterruptedError("Workflow conversion cancelled")
|
|
42
|
+
if not paused or path is None:
|
|
43
|
+
return
|
|
44
|
+
ack = acknowledgement(path, _TOKEN)
|
|
45
|
+
ack.touch()
|
|
46
|
+
try:
|
|
47
|
+
while paused:
|
|
48
|
+
time.sleep(0.01)
|
|
49
|
+
_, paused, cancelled = state()
|
|
50
|
+
if cancelled:
|
|
51
|
+
raise InterruptedError("Workflow conversion cancelled")
|
|
52
|
+
finally:
|
|
53
|
+
ack.unlink(missing_ok=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def active_time() -> float:
|
|
57
|
+
"""Converter diagnostic clock; Runtime remains authoritative for task budgets."""
|
|
58
|
+
state()
|
|
59
|
+
return (_PAUSE_STARTED if _PAUSE_STARTED is not None else time.monotonic()) - _PAUSE_TOTAL
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# Python companion API
|
|
2
|
+
|
|
3
|
+
Python distribution/import: `scientific-workflow` / `scientific_workflow`, version
|
|
4
|
+
0.4.4. Python 3.14+; Linux is the supported execution platform. The base package
|
|
5
|
+
has no runtime dependencies; `[npy]` installs NumPy. Imports have no environment,
|
|
6
|
+
logging, working-directory, subprocess, or output-creation side effects.
|
|
7
|
+
|
|
8
|
+
## Basic API
|
|
9
|
+
|
|
10
|
+
### Recording reader
|
|
11
|
+
|
|
12
|
+
`scientific_workflow.reader.open_completed_recording(directory, decoders=None)`
|
|
13
|
+
returns a `RecordingReader` after metadata validation and successful-lifecycle
|
|
14
|
+
validation. Directory parameters accept `str | pathlib.Path`. Root reexports
|
|
15
|
+
remain available for the reader, state containers, and recording errors; the old
|
|
16
|
+
`scientific_workflow_reader` import package is not provided.
|
|
17
|
+
|
|
18
|
+
`RecordingReader(directory, decoders=None)` validates metadata but permits
|
|
19
|
+
inspection of incomplete recordings. Properties: `directory`, `format_version`
|
|
20
|
+
(the file's actual 7 or 8), `stream_names` (ordered tuple), `user_metadata`,
|
|
21
|
+
`terminal_metadata`, `timing`. Methods: `stream_record_count(stream)`,
|
|
22
|
+
`stream_encoded_bytes(stream)`, `read_stream(stream)`, `read_all_streams()`,
|
|
23
|
+
`read_latest(stream)`, `iter_verified_records(stream)`. Complete reads are
|
|
24
|
+
transactional; the iterator verifies a bounded chunk before yielding and may
|
|
25
|
+
fail later after prior chunks were yielded. Decoders are mappings from field
|
|
26
|
+
name to callable; ordinary JSON values are the default. See the Python README
|
|
27
|
+
for integrity/lifecycle details and custom-decoder examples.
|
|
28
|
+
|
|
29
|
+
`FORMAT_NAME` identifies scientific-workflow-jsonl; `FORMAT_VERSION` is the
|
|
30
|
+
highest supported version, 8. Writers remain Rust-owned. `Decoder` is the decoder
|
|
31
|
+
type alias. `StateField`, `StateRecord`, `StateSeries` are frozen containers;
|
|
32
|
+
application-supplied payloads retain their own mutability. All recording error
|
|
33
|
+
classes are exported at package root: `RecordingError`, `MetadataError`,
|
|
34
|
+
`IntegrityError`, `RecordError`, `DecoderError`, `RecordingNotCompleteError`,
|
|
35
|
+
`UnknownStreamError`. They preserve contextual stream/file information; no
|
|
36
|
+
scientific partial series is returned by complete reads.
|
|
37
|
+
|
|
38
|
+
### Dependencies
|
|
39
|
+
|
|
40
|
+
`scientific_workflow.dependencies.Dependencies(snapshot)` validates an owned
|
|
41
|
+
copy of a dependency JSON array. `load(path)` reads an explicit snapshot;
|
|
42
|
+
`from_env()` requires WORKFLOW_DEPENDENCIES_PATH. Both are class methods.
|
|
43
|
+
`recordings()`, `programs()`, `npy_batches()` return typed `Selection` objects.
|
|
44
|
+
`raw_json()` returns an independent mutable JSON copy including unknown kinds.
|
|
45
|
+
|
|
46
|
+
Result dataclasses are frozen and contain paths as `Path`:
|
|
47
|
+
|
|
48
|
+
| Type | Public attributes |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `RecordingDependency` | phase, task, execution_unit, member, final_iteration, directory |
|
|
51
|
+
| `ProgramDependency` | phase, task, directory, executable, python_script (Path or None) |
|
|
52
|
+
| `NpyDependency` | phase, task, directory |
|
|
53
|
+
|
|
54
|
+
Acquire results from Dependencies; direct dataclass construction does not validate
|
|
55
|
+
an external snapshot. Program directory means `<task>/artifacts`; recording
|
|
56
|
+
means member root; NPY means aggregate batch root. Runtime already selected the
|
|
57
|
+
replicate/configuration scope. NPY batches may contain several global configurations.
|
|
58
|
+
|
|
59
|
+
`Selection.in_phase(key)` and `.task(identity)` return new intersections.
|
|
60
|
+
`.execution_unit(key)` and `.member(identity)` filter recordings; they match
|
|
61
|
+
nothing on other result types. `.one()` requires exactly one result;
|
|
62
|
+
`.optional()` allows zero or one but rejects ambiguity. `.iter()` and Python
|
|
63
|
+
iteration enumerate all matches in deterministic snapshot order. All lookups are
|
|
64
|
+
pure and perform no scientific I/O. Selection references keep results alive.
|
|
65
|
+
|
|
66
|
+
`DependencyError(ValueError)` covers malformed snapshots and read/environment
|
|
67
|
+
failures. `MissingDependencyError` means zero matches for one().
|
|
68
|
+
`AmbiguousDependencyError` exposes `selection` and `matches` and identifies all
|
|
69
|
+
matching phase/task/member sources. Known kinds require valid fields, unique
|
|
70
|
+
identifiers, absolute paths and u64 iterations; unknown extension keys/kinds are
|
|
71
|
+
preserved. File existence and scientific correctness are checked by the reader.
|
|
72
|
+
|
|
73
|
+
### Standard project accessors
|
|
74
|
+
|
|
75
|
+
**REQUIRED LAYOUT:** declarations remain at `<study>/wf_configs/study.json` and
|
|
76
|
+
`parameters.json`. Runtime creates per-program `workflow-config.json` and
|
|
77
|
+
`workflow-dependencies.json` beside `artifacts/`, `stdout.log`, and `stderr.log`.
|
|
78
|
+
**Do not rename or relocate required files.** There is no heuristic discovery.
|
|
79
|
+
|
|
80
|
+
`scientific_workflow.project` exports:
|
|
81
|
+
|
|
82
|
+
- `project_root() -> Path`: verify WORKFLOW_PROJECT_ROOT is an absolute directory.
|
|
83
|
+
- `output_directory() -> Path`: verify WORKFLOW_TASK_OUTPUT, the artifacts directory.
|
|
84
|
+
- `study_path(root) -> Path`: require `<root>/wf_configs/study.json`; no parsing.
|
|
85
|
+
- `parameters(section=None, *, snapshot=None) -> object`: load resolved parameters
|
|
86
|
+
from WORKFLOW_CONFIG_PATH or an explicit runtime snapshot. Return all parameters
|
|
87
|
+
or one exact top-level section. Do not reread unresolved source declarations.
|
|
88
|
+
- `ProjectLayoutError(ValueError)`: identifies the required variable/file/layout
|
|
89
|
+
and chains underlying read/parse failures.
|
|
90
|
+
|
|
91
|
+
Accessors synchronously read files/environment. They do not create files, mutate
|
|
92
|
+
cwd, configure logging, activate environments or implement a second resolver.
|
|
93
|
+
Use explicit paths outside a Workflow program; from-env calls require the launch
|
|
94
|
+
contract. There is no ProgramContext.
|
|
95
|
+
|
|
96
|
+
### NPY readers and whole-series views
|
|
97
|
+
|
|
98
|
+
`scientific_workflow.npy` requires the `[npy]` extra.
|
|
99
|
+
`open_npy_batch(directory) -> NpyBatch` verifies the batch and every member.
|
|
100
|
+
`open_npy_conversion(directory) -> NpyConversion` verifies one member. Acquire
|
|
101
|
+
objects through these functions; direct constructors do not establish integrity.
|
|
102
|
+
Both require the standard manifest directory, not an individual `.npy` path.
|
|
103
|
+
|
|
104
|
+
`NpyBatch` attributes: `directory`, `manifest`, `members` (ordered tuple).
|
|
105
|
+
`NpyConversion` attributes: `directory`, `manifest`, `stream_names` (tuple),
|
|
106
|
+
`execution_unit` (provenance key or None). Methods:
|
|
107
|
+
|
|
108
|
+
| Method | Result |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `array(relative_path)` | Cached read-only memory map of a declared component |
|
|
111
|
+
| `field(stream, field)` | Field representation metadata |
|
|
112
|
+
| `reconstruct(stream, field, record)` | One exact numeric or JSON fallback record |
|
|
113
|
+
| `projection(stream, field, logical_path, record)` | One structured numeric projection record |
|
|
114
|
+
| `coordinates(stream)` | Tuple (iterations, physical_times); absent physical times are None |
|
|
115
|
+
| `series(stream, field, logical_path=None)` | Cached FixedSeries or RaggedSeries |
|
|
116
|
+
|
|
117
|
+
Omit logical_path for wholly numeric fields. Structured fields require an exact
|
|
118
|
+
projection path, including JSON-pointer escaping. Missing or ambiguous projections
|
|
119
|
+
raise `NpyConversionError`. Opening verifies checksums/layout up front; series
|
|
120
|
+
access does not repeat that complete validation or reconstruct every JSON record.
|
|
121
|
+
Callers must not mutate metadata dictionaries or source files after opening.
|
|
122
|
+
|
|
123
|
+
`NumericSeries = FixedSeries | RaggedSeries`. Both frozen dataclasses expose
|
|
124
|
+
`iterations`, optional `physical_times` (None when absent), `len(series)`, and `record(index)`.
|
|
125
|
+
`FixedSeries.values` is a read-only array with a leading record axis.
|
|
126
|
+
`RaggedSeries.data`, `.offsets`, `.shapes` are read-only components; record()
|
|
127
|
+
slices and reshapes one record in C order, including empty shapes. Indices must
|
|
128
|
+
be Python integers in [0, len); booleans, negative and out-of-range indices raise
|
|
129
|
+
IndexError. Views retain references to maps and remain usable while retained;
|
|
130
|
+
there is no explicit close API. Reading pages may incur filesystem I/O. Access
|
|
131
|
+
is read-only and has no cancellation or publication effects.
|
|
132
|
+
|
|
133
|
+
## Advanced API
|
|
134
|
+
|
|
135
|
+
`convert_recording(recording_directory, output_directory=None)` verifies and
|
|
136
|
+
converts a completed recording, returning its manifest. Default output is the
|
|
137
|
+
recording sibling suffixed `-npy`. Conflicts fail; verified matching output is
|
|
138
|
+
reused. Successful publication is atomic after complete validation. Source files
|
|
139
|
+
must remain immutable. NPY_FORMAT and NPY_BATCH_FORMAT remain v2;
|
|
140
|
+
MANIFEST_FILE is `manifest.json`. NpyConversionError(ValueError) reports storage
|
|
141
|
+
contract failures; recording errors and I/O failures retain their own types.
|
|
142
|
+
|
|
143
|
+
`convert_workflow_dependencies(dependencies_path, output_directory)` converts
|
|
144
|
+
completed prerequisite recordings and publishes a batch in stable source order.
|
|
145
|
+
The installed CLI `scientific-workflow-to-npy` and `python -m
|
|
146
|
+
scientific_workflow.npy` call `main()`. Consult `--help` for CLI flags; public
|
|
147
|
+
conversion call shapes remain unchanged. No supported Python recording writer
|
|
148
|
+
or runtime scheduler/control handle is exposed.
|
|
149
|
+
|
|
150
|
+
Optional dependencies are imported only by their owning module. OF/Dispatcher
|
|
151
|
+
retain domain adapters, scientific validation, statistics and plotting. This
|
|
152
|
+
package owns generic wire/layout mechanics.
|
|
153
|
+
|
|
154
|
+
## Example
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from scientific_workflow.dependencies import Dependencies
|
|
158
|
+
from scientific_workflow.npy import open_npy_batch
|
|
159
|
+
from scientific_workflow.project import parameters
|
|
160
|
+
|
|
161
|
+
settings = parameters("analysis")
|
|
162
|
+
batch_path = Dependencies.from_env().npy_batches().one().directory
|
|
163
|
+
batch = open_npy_batch(batch_path)
|
|
164
|
+
for member in batch.members:
|
|
165
|
+
if member.execution_unit == "simulation":
|
|
166
|
+
signal = member.series("statistics", "stats", "/energy")
|
|
167
|
+
print(signal.iterations, signal.record(0))
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The example runs in a standard Workflow-launched analysis task. Outside Workflow,
|
|
171
|
+
use Dependencies.load(explicit_snapshot) and parameters(snapshot=explicit_path).
|
|
172
|
+
|
|
173
|
+
## Not API
|
|
174
|
+
|
|
175
|
+
Underscore-prefixed validators, planners, writers, framing helpers, cache layout,
|
|
176
|
+
worker orchestration and temporary naming are internal. Recording and NPY wire
|
|
177
|
+
schemas are separately versioned contracts; implementation internals are not.
|
|
178
|
+
|
|
179
|
+
## Reporting reference
|
|
180
|
+
|
|
181
|
+
`scientific_workflow.reporting.log(message: str, *, level="info")` emits one
|
|
182
|
+
flushed prefixed event to stderr. Levels: debug/info/warning/error/success.
|
|
183
|
+
`progress(stage: str, completed: int, total: int | None = None, *, unit="records")`
|
|
184
|
+
emits counts; nonempty stage/unit and u64 bounds are required. Invalid input or
|
|
185
|
+
frames over 16 KiB raise ValueError before output; stderr write/flush errors
|
|
186
|
+
propagate. Calls serialize threads in one process but do not synchronize unrelated
|
|
187
|
+
processes. Outside Workflow, output remains prefixed stderr lines.
|
|
188
|
+
|
|
189
|
+
`WorkflowHandler(logging.Handler)` follows standard Handler construction,
|
|
190
|
+
level/filter/formatter/close behavior and overrides emit(record). It maps standard
|
|
191
|
+
logging levels to Workflow severities and formats through the installed formatter.
|
|
192
|
+
`install_logging(logger=None, *, level=logging.INFO) -> WorkflowHandler` attaches
|
|
193
|
+
one handler idempotently to that logger (root when omitted). It sets handler level,
|
|
194
|
+
not logger level; callers retain logging policy. Remove through
|
|
195
|
+
logger.removeHandler(handler) and handler.close(). Imports do not configure logging.
|
|
196
|
+
Each process configures its own logging; converter workers use a bounded queue to
|
|
197
|
+
the parent emitter rather than writing progress directly. See program-events-v1.
|
|
198
|
+
|
|
199
|
+
## Converter execution and publication
|
|
200
|
+
|
|
201
|
+
`convert_workflow_dependencies` uses min(WORKFLOW_THREADS, unique recordings),
|
|
202
|
+
with a standalone default of one. Rust supplies/reserves the allowance across
|
|
203
|
+
replicates. Spawn workers own their verified reader and arrays; threadpoolctl
|
|
204
|
+
limits native numeric pools to one thread each. There is no new mandatory public
|
|
205
|
+
argument. Progress reports planning, writing, verification, member reuse/completion,
|
|
206
|
+
and batch totals. Completion order never changes manifest member order.
|
|
207
|
+
|
|
208
|
+
Linux directory flock serializes competing publishers. Unique temporary paths
|
|
209
|
+
avoid same-process collisions. Failure terminates/joins workers, publishes no
|
|
210
|
+
success batch, and retains individually verified members for retry. Staging
|
|
211
|
+
folders left by abrupt termination are not published data. Successful metadata
|
|
212
|
+
publication uses atomic replacement. Private control checkpoints freeze work at
|
|
213
|
+
record/hash/job boundaries; parent acknowledgement requires all active jobs to
|
|
214
|
+
be paused or complete. Admission stops during pause. Cancel while paused wakes
|
|
215
|
+
and terminates work; raw log draining in Rust continues throughout.
|
|
216
|
+
|
|
217
|
+
State container detail: StateField(name, description=None) exposes those fields;
|
|
218
|
+
StateRecord(iteration, physical_time, values) exposes them, and create() wraps
|
|
219
|
+
values in a read-only MappingProxyType. StateSeries(stream, fields, records)
|
|
220
|
+
implements len, indexing/slicing, iteration, and an iterations tuple property.
|
|
221
|
+
These frozen containers do not deep-freeze decoded application payloads.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Typed access to immutable results from Workflow's declared dependencies.
|
|
2
|
+
|
|
3
|
+
Snapshot selection performs no scientific I/O and never broadens runtime scope.
|
|
4
|
+
The core module has no NumPy dependency. See api.md for the complete contract.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Generic, Iterator, TypeVar
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DependencyError(ValueError):
|
|
17
|
+
"""Invalid dependency snapshot or selection."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MissingDependencyError(DependencyError):
|
|
21
|
+
"""No result satisfies a selection requiring exactly one."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AmbiguousDependencyError(DependencyError):
|
|
25
|
+
"""Multiple results satisfy a selection requiring at most one."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, selection: str, matches: tuple[str, ...]):
|
|
28
|
+
self.selection, self.matches = selection, matches
|
|
29
|
+
super().__init__(f"ambiguous dependency {selection}: {', '.join(matches)}; select a phase or task")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class RecordingDependency:
|
|
34
|
+
phase: str
|
|
35
|
+
task: str
|
|
36
|
+
execution_unit: str
|
|
37
|
+
member: str
|
|
38
|
+
final_iteration: int
|
|
39
|
+
directory: Path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class ProgramDependency:
|
|
44
|
+
phase: str
|
|
45
|
+
task: str
|
|
46
|
+
directory: Path
|
|
47
|
+
executable: Path
|
|
48
|
+
python_script: Path | None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class NpyDependency:
|
|
53
|
+
phase: str
|
|
54
|
+
task: str
|
|
55
|
+
directory: Path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
T = TypeVar("T", RecordingDependency, ProgramDependency, NpyDependency)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class Selection(Generic[T]):
|
|
63
|
+
"""Immutable intersection of exact selectors, in snapshot order."""
|
|
64
|
+
_entries: tuple[T, ...]
|
|
65
|
+
_filters: tuple[str, ...] = ()
|
|
66
|
+
|
|
67
|
+
def _filter(self, field: str, value: str) -> Selection[T]:
|
|
68
|
+
return Selection(tuple(e for e in self._entries if getattr(e, field, None) == value),
|
|
69
|
+
(*self._filters, f"{field}={value!r}"))
|
|
70
|
+
|
|
71
|
+
def in_phase(self, phase: str) -> Selection[T]:
|
|
72
|
+
return self._filter("phase", phase)
|
|
73
|
+
|
|
74
|
+
def task(self, identity: str) -> Selection[T]:
|
|
75
|
+
return self._filter("task", identity)
|
|
76
|
+
|
|
77
|
+
def execution_unit(self, key: str) -> Selection[T]:
|
|
78
|
+
"""Restrict recording results to an execution-unit key."""
|
|
79
|
+
return self._filter("execution_unit", key)
|
|
80
|
+
|
|
81
|
+
def member(self, identity: str) -> Selection[T]:
|
|
82
|
+
"""Restrict recording results to a member identity."""
|
|
83
|
+
return self._filter("member", identity)
|
|
84
|
+
|
|
85
|
+
def one(self) -> T:
|
|
86
|
+
result = self.optional()
|
|
87
|
+
if result is None:
|
|
88
|
+
raise MissingDependencyError(f"no dependency matches {self._filters}")
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
def optional(self) -> T | None:
|
|
92
|
+
if len(self._entries) > 1:
|
|
93
|
+
raise AmbiguousDependencyError(str(self._filters), tuple(
|
|
94
|
+
f"{e.phase}/{e.task}" + (f"/{e.member}" if isinstance(e, RecordingDependency) else "")
|
|
95
|
+
for e in self._entries))
|
|
96
|
+
return self._entries[0] if self._entries else None
|
|
97
|
+
|
|
98
|
+
def __iter__(self) -> Iterator[T]:
|
|
99
|
+
return iter(self._entries)
|
|
100
|
+
|
|
101
|
+
def iter(self) -> Iterator[T]:
|
|
102
|
+
return iter(self)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _name(value: object) -> str:
|
|
106
|
+
if not isinstance(value, str) or not value or value.strip() != value:
|
|
107
|
+
raise DependencyError("expected a nonempty identifier without surrounding whitespace")
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _path(value: object) -> Path:
|
|
112
|
+
if not isinstance(value, str) or not Path(value).is_absolute():
|
|
113
|
+
raise DependencyError(f"expected absolute path, got {value!r}")
|
|
114
|
+
return Path(value)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _list(value: object) -> list:
|
|
118
|
+
if not isinstance(value, list):
|
|
119
|
+
raise DependencyError("expected an array in dependency snapshot")
|
|
120
|
+
return value
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _unique(value: str, seen: set[str]) -> None:
|
|
124
|
+
if value in seen:
|
|
125
|
+
raise DependencyError(f"duplicate dependency identity {value!r}")
|
|
126
|
+
seen.add(value)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Dependencies:
|
|
130
|
+
"""Validated dependency snapshot, including preserved unknown workload kinds."""
|
|
131
|
+
|
|
132
|
+
def __init__(self, snapshot: object):
|
|
133
|
+
self._raw = copy.deepcopy(snapshot)
|
|
134
|
+
recordings, programs, batches = [], [], []
|
|
135
|
+
try:
|
|
136
|
+
phases_seen = set()
|
|
137
|
+
for phase in _list(self._raw):
|
|
138
|
+
phase_name = _name(phase["phase"])
|
|
139
|
+
_unique(phase_name, phases_seen)
|
|
140
|
+
tasks_seen = set()
|
|
141
|
+
for task in _list(phase["tasks"]):
|
|
142
|
+
identity = _name(task["identity"])
|
|
143
|
+
_unique(identity, tasks_seen)
|
|
144
|
+
directory = _path(task["output_directory"])
|
|
145
|
+
workload = task["workload"]
|
|
146
|
+
kind = _name(workload["kind"])
|
|
147
|
+
if kind == "execution_unit":
|
|
148
|
+
key = _name(workload["execution_unit"])
|
|
149
|
+
members = _list(workload["members"])
|
|
150
|
+
if not members:
|
|
151
|
+
raise DependencyError("execution unit has no members")
|
|
152
|
+
members_seen = set()
|
|
153
|
+
for member in members:
|
|
154
|
+
name = _name(member["identity"])
|
|
155
|
+
_unique(name, members_seen)
|
|
156
|
+
iteration = member["final_iteration"]
|
|
157
|
+
if type(iteration) is not int or not 0 <= iteration <= 2**64 - 1:
|
|
158
|
+
raise DependencyError("final_iteration must be a u64")
|
|
159
|
+
recordings.append(RecordingDependency(phase_name, identity, key, name, iteration, _path(member["output_directory"])))
|
|
160
|
+
elif kind in ("program", "python"):
|
|
161
|
+
script = workload.get("python_script")
|
|
162
|
+
if kind == "python" and script is None:
|
|
163
|
+
raise DependencyError("python workload requires python_script")
|
|
164
|
+
programs.append(ProgramDependency(phase_name, identity, directory / "artifacts", _path(workload["executable"]), _path(script) if script is not None else None))
|
|
165
|
+
elif kind == "npy":
|
|
166
|
+
batches.append(NpyDependency(phase_name, identity, _path(workload["processed_directory"])))
|
|
167
|
+
except (KeyError, TypeError, AttributeError) as error:
|
|
168
|
+
raise DependencyError(f"malformed dependency snapshot: {error}") from error
|
|
169
|
+
self._recordings, self._programs, self._batches = tuple(recordings), tuple(programs), tuple(batches)
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def load(cls, path: str | Path) -> Dependencies:
|
|
173
|
+
"""Load an explicit snapshot; failures identify its expected path."""
|
|
174
|
+
try:
|
|
175
|
+
return cls(json.loads(Path(path).read_text(encoding="utf-8")))
|
|
176
|
+
except (OSError, ValueError) as error:
|
|
177
|
+
raise DependencyError(f"cannot load dependency snapshot {path}: {error}") from error
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def from_env(cls) -> Dependencies:
|
|
181
|
+
"""Load WORKFLOW_DEPENDENCIES_PATH from a standard Workflow launch."""
|
|
182
|
+
path = os.environ.get("WORKFLOW_DEPENDENCIES_PATH")
|
|
183
|
+
if not path:
|
|
184
|
+
raise DependencyError("missing WORKFLOW_DEPENDENCIES_PATH; run through Workflow's standard study layout or use Dependencies.load(path)")
|
|
185
|
+
return cls.load(path)
|
|
186
|
+
|
|
187
|
+
def raw_json(self) -> object:
|
|
188
|
+
"""Return an independent JSON copy, including unknown extensions."""
|
|
189
|
+
return copy.deepcopy(self._raw)
|
|
190
|
+
|
|
191
|
+
def recordings(self) -> Selection[RecordingDependency]:
|
|
192
|
+
return Selection(self._recordings)
|
|
193
|
+
|
|
194
|
+
def programs(self) -> Selection[ProgramDependency]:
|
|
195
|
+
return Selection(self._programs)
|
|
196
|
+
|
|
197
|
+
def npy_batches(self) -> Selection[NpyDependency]:
|
|
198
|
+
return Selection(self._batches)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Public exception hierarchy for recording validation and reconstruction."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RecordingError(Exception):
|
|
5
|
+
"""Base class for every reader failure."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MetadataError(RecordingError):
|
|
9
|
+
"""The authoritative metadata document violates the supported format."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RecordingNotCompleteError(MetadataError):
|
|
13
|
+
"""The recording has not reached successful completion."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnknownStreamError(RecordingError, KeyError):
|
|
17
|
+
"""The requested logical stream is not declared."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IntegrityError(RecordingError):
|
|
21
|
+
"""A declared immutable chunk is missing or fails integrity validation."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RecordError(RecordingError):
|
|
25
|
+
"""A JSONL state record violates its stream contract."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DecoderError(RecordingError):
|
|
29
|
+
"""A caller-supplied field decoder failed or is missing."""
|