aind-code-ocean-pipeline-utils 0.4.2__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.
- aind_code_ocean_pipeline_utils/__init__.py +81 -0
- aind_code_ocean_pipeline_utils/cache.py +108 -0
- aind_code_ocean_pipeline_utils/cli.py +51 -0
- aind_code_ocean_pipeline_utils/diagnostics.py +202 -0
- aind_code_ocean_pipeline_utils/io.py +217 -0
- aind_code_ocean_pipeline_utils/log.py +321 -0
- aind_code_ocean_pipeline_utils/metadata.py +332 -0
- aind_code_ocean_pipeline_utils/process.py +199 -0
- aind_code_ocean_pipeline_utils/provenance.py +106 -0
- aind_code_ocean_pipeline_utils/py.typed +1 -0
- aind_code_ocean_pipeline_utils/role_dispatch.py +391 -0
- aind_code_ocean_pipeline_utils/step.py +569 -0
- aind_code_ocean_pipeline_utils/threading_utils.py +92 -0
- aind_code_ocean_pipeline_utils-0.4.2.dist-info/METADATA +443 -0
- aind_code_ocean_pipeline_utils-0.4.2.dist-info/RECORD +17 -0
- aind_code_ocean_pipeline_utils-0.4.2.dist-info/WHEEL +4 -0
- aind_code_ocean_pipeline_utils-0.4.2.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Logging helpers for Code Ocean pipelines.
|
|
2
|
+
|
|
3
|
+
This module has two groups of helpers:
|
|
4
|
+
|
|
5
|
+
:func:`attach_file_log`
|
|
6
|
+
Stdlib-only. Attaches a :class:`logging.FileHandler` to the root
|
|
7
|
+
logger so a capsule's log survives as a file alongside the stream
|
|
8
|
+
output captured by Code Ocean. Always available.
|
|
9
|
+
|
|
10
|
+
:func:`install_rich_handler`, :func:`build_progress`, :func:`make_progress_callback`
|
|
11
|
+
Rich-aware helpers that play nicely with
|
|
12
|
+
:class:`rich.progress.Progress`. Require the optional ``[rich]``
|
|
13
|
+
extra. Rich is imported lazily on first call; if the extra isn't
|
|
14
|
+
installed the call raises :class:`ImportError` with install
|
|
15
|
+
instructions.
|
|
16
|
+
|
|
17
|
+
Rich progress: the problem
|
|
18
|
+
--------------------------
|
|
19
|
+
:class:`rich.progress.Progress` (and :class:`rich.live.Live` more generally)
|
|
20
|
+
repaints a live area 2–10 times per second. If Python's standard
|
|
21
|
+
:mod:`logging` emits a record from inside a ``with Progress():`` block
|
|
22
|
+
without going through rich, the next tick paints over the tail of the log
|
|
23
|
+
output. The most common casualty is :meth:`logging.Logger.exception`:
|
|
24
|
+
multi-line tracebacks get clipped and the ``ErrorType: message`` line that
|
|
25
|
+
tells you *what* went wrong silently disappears.
|
|
26
|
+
|
|
27
|
+
The fix: route :mod:`logging` through :class:`rich.logging.RichHandler`,
|
|
28
|
+
sharing the **same** :class:`rich.console.Console` instance that
|
|
29
|
+
:class:`Progress` / :class:`Live` uses. Rich then serializes log output
|
|
30
|
+
with the live area (pauses, prints, resumes)::
|
|
31
|
+
|
|
32
|
+
from aind_code_ocean_pipeline_utils.log import install_rich_handler
|
|
33
|
+
from rich.progress import Progress
|
|
34
|
+
|
|
35
|
+
console = install_rich_handler()
|
|
36
|
+
with Progress(console=console) as progress: # <- same console!
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
Passing a separate ``Console`` to ``Progress`` (or letting it create its own)
|
|
40
|
+
reintroduces the bug. This module's signature — returning the ``Console`` —
|
|
41
|
+
is designed to make sharing the obvious path.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import logging
|
|
47
|
+
from collections.abc import Callable, Iterator
|
|
48
|
+
from contextlib import contextmanager
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from typing import TYPE_CHECKING, Any
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from rich.console import Console
|
|
54
|
+
from rich.progress import Progress, TaskID
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"attach_file_log",
|
|
58
|
+
"build_progress",
|
|
59
|
+
"install_rich_handler",
|
|
60
|
+
"make_progress_callback",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_RICH_EXTRA_MSG = (
|
|
65
|
+
"aind_code_ocean_pipeline_utils.log's rich-aware helpers require "
|
|
66
|
+
"the [rich] extra. Install with: "
|
|
67
|
+
"pip install aind-code-ocean-pipeline-utils[rich]"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _require_rich() -> Any:
|
|
72
|
+
"""Import rich lazily, raising a pointed error if the extra is missing."""
|
|
73
|
+
try:
|
|
74
|
+
import rich.console
|
|
75
|
+
import rich.logging
|
|
76
|
+
import rich.progress
|
|
77
|
+
except ImportError as exc:
|
|
78
|
+
raise ImportError(_RICH_EXTRA_MSG) from exc
|
|
79
|
+
return rich
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ── Stdlib-only: file-logging primitive ──
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def attach_file_log(
|
|
86
|
+
path: Path,
|
|
87
|
+
*,
|
|
88
|
+
level: int = logging.INFO,
|
|
89
|
+
mode: str = "w",
|
|
90
|
+
logger: logging.Logger | None = None,
|
|
91
|
+
) -> logging.FileHandler:
|
|
92
|
+
"""Attach a :class:`logging.FileHandler` alongside existing handlers.
|
|
93
|
+
|
|
94
|
+
Keeps any previously configured StreamHandler in place so output still
|
|
95
|
+
goes to stdout/stderr (captured by Code Ocean) while the log also lands
|
|
96
|
+
on disk — useful for preserving a trace in ``/results`` after the
|
|
97
|
+
capsule run ends.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
path : pathlib.Path
|
|
102
|
+
Destination file. Parent directories are created if needed.
|
|
103
|
+
level : int, default ``logging.INFO``
|
|
104
|
+
Level set on the installed handler.
|
|
105
|
+
mode : str, default ``"w"``
|
|
106
|
+
File-open mode. Defaults to overwrite so each role restarts its
|
|
107
|
+
own log fresh. Pass ``"a"`` if you deliberately want to append
|
|
108
|
+
across invocations.
|
|
109
|
+
logger : logging.Logger, optional
|
|
110
|
+
Logger to attach to. Defaults to the root logger.
|
|
111
|
+
|
|
112
|
+
Returns
|
|
113
|
+
-------
|
|
114
|
+
logging.FileHandler
|
|
115
|
+
The handler, already attached. Callers can keep the reference if
|
|
116
|
+
they want to remove it later.
|
|
117
|
+
"""
|
|
118
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
handler = logging.FileHandler(path, mode=mode)
|
|
120
|
+
handler.setLevel(level)
|
|
121
|
+
handler.setFormatter(
|
|
122
|
+
logging.Formatter(
|
|
123
|
+
fmt="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
124
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
target = logger if logger is not None else logging.getLogger()
|
|
128
|
+
target.addHandler(handler)
|
|
129
|
+
return handler
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── Rich-aware helpers (require the [rich] extra) ──
|
|
133
|
+
|
|
134
|
+
_HANDLER_MARKER = "_aind_pipeline_utils_rich_handler"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def install_rich_handler(
|
|
138
|
+
logger: logging.Logger | None = None,
|
|
139
|
+
*,
|
|
140
|
+
level: int = logging.INFO,
|
|
141
|
+
console: Console | None = None,
|
|
142
|
+
show_path: bool = False,
|
|
143
|
+
rich_tracebacks: bool = True,
|
|
144
|
+
) -> Console:
|
|
145
|
+
"""Attach a :class:`RichHandler` to ``logger`` and return the :class:`Console`.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
logger : logging.Logger, optional
|
|
150
|
+
Logger to configure. Defaults to the root logger.
|
|
151
|
+
level : int, default ``logging.INFO``
|
|
152
|
+
Level to set on ``logger`` *and* on the installed handler.
|
|
153
|
+
console : rich.console.Console, optional
|
|
154
|
+
Console the handler should render to. If omitted, a new one is
|
|
155
|
+
created. **Pass this same Console to any Progress / Live instance
|
|
156
|
+
in the process** — see the module docstring for why.
|
|
157
|
+
show_path : bool, default False
|
|
158
|
+
Pass-through to :class:`RichHandler`. Module paths in log output
|
|
159
|
+
are noise for CLI use; enable for debug builds.
|
|
160
|
+
rich_tracebacks : bool, default True
|
|
161
|
+
Pass-through to :class:`RichHandler`. Enables syntax-highlighted
|
|
162
|
+
tracebacks with local-variable context.
|
|
163
|
+
|
|
164
|
+
Returns
|
|
165
|
+
-------
|
|
166
|
+
rich.console.Console
|
|
167
|
+
The :class:`Console` used by the handler — either ``console`` if
|
|
168
|
+
supplied, or the freshly-created one. Callers should share it with
|
|
169
|
+
any :class:`Progress` / :class:`Live` instance they construct.
|
|
170
|
+
|
|
171
|
+
Notes
|
|
172
|
+
-----
|
|
173
|
+
Idempotent per logger: calling repeatedly on the same logger replaces
|
|
174
|
+
the previously installed handler rather than stacking duplicates. The
|
|
175
|
+
logger's other (non-rich) handlers are left untouched.
|
|
176
|
+
"""
|
|
177
|
+
rich = _require_rich()
|
|
178
|
+
target = logger if logger is not None else logging.getLogger()
|
|
179
|
+
shared_console = console if console is not None else rich.console.Console()
|
|
180
|
+
|
|
181
|
+
_remove_existing_handlers(target)
|
|
182
|
+
|
|
183
|
+
handler = rich.logging.RichHandler(
|
|
184
|
+
console=shared_console,
|
|
185
|
+
show_path=show_path,
|
|
186
|
+
rich_tracebacks=rich_tracebacks,
|
|
187
|
+
level=level,
|
|
188
|
+
)
|
|
189
|
+
setattr(handler, _HANDLER_MARKER, True)
|
|
190
|
+
target.addHandler(handler)
|
|
191
|
+
target.setLevel(level)
|
|
192
|
+
|
|
193
|
+
return shared_console
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _remove_existing_handlers(logger: logging.Logger) -> None:
|
|
197
|
+
"""Remove handlers previously installed by this module from ``logger``."""
|
|
198
|
+
to_remove = [h for h in logger.handlers if getattr(h, _HANDLER_MARKER, False)]
|
|
199
|
+
for handler in to_remove:
|
|
200
|
+
logger.removeHandler(handler)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _find_installed_console() -> Console:
|
|
204
|
+
"""Return the :class:`Console` from the root logger's installed :class:`RichHandler`.
|
|
205
|
+
|
|
206
|
+
Raises :class:`RuntimeError` if :func:`install_rich_handler` has
|
|
207
|
+
not been called. The error message points the caller at the fix.
|
|
208
|
+
"""
|
|
209
|
+
for handler in logging.getLogger().handlers:
|
|
210
|
+
if getattr(handler, _HANDLER_MARKER, False):
|
|
211
|
+
# handler.console is a rich.console.Console; the getattr is
|
|
212
|
+
# needed because the RichHandler class isn't in the static
|
|
213
|
+
# type namespace after the rich lazy-import refactor.
|
|
214
|
+
console: Console = handler.console # type: ignore[attr-defined]
|
|
215
|
+
return console
|
|
216
|
+
msg = (
|
|
217
|
+
"no rich handler found on the root logger; call install_rich_handler() "
|
|
218
|
+
"before build_progress(), or pass console= explicitly"
|
|
219
|
+
)
|
|
220
|
+
raise RuntimeError(msg)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@contextmanager
|
|
224
|
+
def build_progress(
|
|
225
|
+
total_items: int,
|
|
226
|
+
*,
|
|
227
|
+
console: Console | None = None,
|
|
228
|
+
refresh_per_second: float = 0.5,
|
|
229
|
+
) -> Iterator[tuple[Progress, TaskID, TaskID]]:
|
|
230
|
+
"""Two-row progress display: overall counter + per-item bar.
|
|
231
|
+
|
|
232
|
+
Yields ``(progress, overall_task, item_task)``. The overall task
|
|
233
|
+
tracks completed items out of ``total_items`` — the caller
|
|
234
|
+
advances it with ``progress.advance(overall_task)`` after each
|
|
235
|
+
item finishes. The item task is initially hidden; the caller
|
|
236
|
+
resets + retargets it per item::
|
|
237
|
+
|
|
238
|
+
with build_progress(len(items)) as (progress, overall, item):
|
|
239
|
+
for it in items:
|
|
240
|
+
progress.reset(item, total=it.size, description=it.name, visible=True)
|
|
241
|
+
do_work(it, on_progress=make_progress_callback(progress, item))
|
|
242
|
+
progress.advance(overall)
|
|
243
|
+
|
|
244
|
+
The :class:`Console` used for rendering must be the same one the
|
|
245
|
+
:class:`RichHandler` is writing to; otherwise log output gets
|
|
246
|
+
painted over. By default the :class:`Console` is discovered from
|
|
247
|
+
the installed handler, so callers who use
|
|
248
|
+
:func:`install_rich_handler` get correct behavior automatically.
|
|
249
|
+
|
|
250
|
+
Parameters
|
|
251
|
+
----------
|
|
252
|
+
total_items : int
|
|
253
|
+
Expected number of items the caller will process.
|
|
254
|
+
console : rich.console.Console, optional
|
|
255
|
+
Explicit console override. If omitted, the module looks up
|
|
256
|
+
the :class:`Console` attached to the installed
|
|
257
|
+
:class:`RichHandler`.
|
|
258
|
+
refresh_per_second : float, default 0.5
|
|
259
|
+
Live-area refresh rate. Kept low by default because most
|
|
260
|
+
pipeline capsules have long per-item work and high-frequency
|
|
261
|
+
repaints burn CPU for no user benefit.
|
|
262
|
+
|
|
263
|
+
Yields
|
|
264
|
+
------
|
|
265
|
+
tuple[rich.progress.Progress, rich.progress.TaskID, rich.progress.TaskID]
|
|
266
|
+
``(progress, overall_task, item_task)``.
|
|
267
|
+
|
|
268
|
+
Raises
|
|
269
|
+
------
|
|
270
|
+
RuntimeError
|
|
271
|
+
If ``console`` is ``None`` and no rich handler has been
|
|
272
|
+
installed on the root logger.
|
|
273
|
+
"""
|
|
274
|
+
rich = _require_rich()
|
|
275
|
+
resolved = console if console is not None else _find_installed_console()
|
|
276
|
+
columns = (
|
|
277
|
+
rich.progress.SpinnerColumn(),
|
|
278
|
+
rich.progress.TextColumn("[progress.description]{task.description}"),
|
|
279
|
+
rich.progress.BarColumn(),
|
|
280
|
+
rich.progress.TaskProgressColumn(),
|
|
281
|
+
rich.progress.TimeRemainingColumn(),
|
|
282
|
+
)
|
|
283
|
+
with rich.progress.Progress(
|
|
284
|
+
*columns,
|
|
285
|
+
console=resolved,
|
|
286
|
+
refresh_per_second=refresh_per_second,
|
|
287
|
+
) as progress:
|
|
288
|
+
overall_task = progress.add_task("overall", total=total_items)
|
|
289
|
+
item_task = progress.add_task("item", total=1, visible=False)
|
|
290
|
+
yield progress, overall_task, item_task
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def make_progress_callback(
|
|
294
|
+
progress: Progress,
|
|
295
|
+
task_id: TaskID,
|
|
296
|
+
) -> Callable[[int, int], None]:
|
|
297
|
+
"""Return a ``(processed, total) -> None`` callback that updates ``task_id``.
|
|
298
|
+
|
|
299
|
+
Matches the ``ProgressCallback`` shape several AIND processing
|
|
300
|
+
libraries expect (``build_mipmap(progress=...)`` and similar) so
|
|
301
|
+
consumers can pass the returned callable directly.
|
|
302
|
+
|
|
303
|
+
Parameters
|
|
304
|
+
----------
|
|
305
|
+
progress : rich.progress.Progress
|
|
306
|
+
The Progress instance the task belongs to.
|
|
307
|
+
task_id : rich.progress.TaskID
|
|
308
|
+
Which task the callback should update.
|
|
309
|
+
|
|
310
|
+
Returns
|
|
311
|
+
-------
|
|
312
|
+
callable
|
|
313
|
+
Accepts ``(processed, total)`` and calls
|
|
314
|
+
:meth:`Progress.update` with ``completed=processed,
|
|
315
|
+
total=total``.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
def _callback(processed: int, total: int) -> None:
|
|
319
|
+
progress.update(task_id, completed=processed, total=total)
|
|
320
|
+
|
|
321
|
+
return _callback
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Build a Code Ocean node's aind-data-schema ``processing.json`` incrementally.
|
|
2
|
+
|
|
3
|
+
Optional module — requires the ``[metadata]`` extra (``aind-data-schema``).
|
|
4
|
+
Import it explicitly (it is *not* re-exported from the package root, since the
|
|
5
|
+
core package is stdlib-only).
|
|
6
|
+
|
|
7
|
+
Why this exists
|
|
8
|
+
---------------
|
|
9
|
+
A Code Ocean pipeline is a Nextflow DAG, but no single capsule sees the whole
|
|
10
|
+
graph, and the standard metadata aggregator chains standalone ``DataProcess``
|
|
11
|
+
records in filesystem-discovery order — which has nothing to do with the real
|
|
12
|
+
topology. The only place the true edges are knowable with *local* information
|
|
13
|
+
is along the data-flow: a node's inputs **are** its DAG parents.
|
|
14
|
+
|
|
15
|
+
So each node builds its ``processing.json`` from the ``processing.json`` files
|
|
16
|
+
handed to it by its upstream nodes (read from ``/data``), appends its own
|
|
17
|
+
``DataProcess`` wired to the *frontier* of the merged upstream graph, and writes
|
|
18
|
+
the result to ``/results``. Fan-in (a node with several upstream
|
|
19
|
+
``processing.json`` inputs) is a graph union plus an edge from the new node to
|
|
20
|
+
each incoming branch's frontier. The terminal node then holds the complete,
|
|
21
|
+
correct DAG. This is fully compatible with the existing aggregator, which
|
|
22
|
+
preserves ``dependency_graph`` from any ``processing.json`` it receives.
|
|
23
|
+
|
|
24
|
+
Pairs with :mod:`aind_code_ocean_pipeline_utils.provenance` for stamping each
|
|
25
|
+
``DataProcess`` with a commit hash / package version. Verified against
|
|
26
|
+
aind-data-schema 2.7.1.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
from collections.abc import Mapping, Sequence
|
|
34
|
+
from datetime import UTC, datetime
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from aind_data_schema.core.processing import (
|
|
39
|
+
Code,
|
|
40
|
+
DataProcess,
|
|
41
|
+
Processing,
|
|
42
|
+
ProcessName,
|
|
43
|
+
ProcessStage,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"append_process",
|
|
48
|
+
"emit_processing",
|
|
49
|
+
"make_data_process",
|
|
50
|
+
"read_processings",
|
|
51
|
+
"utcnow",
|
|
52
|
+
"write_processing",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
_logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def utcnow() -> datetime:
|
|
59
|
+
"""Return a timezone-aware UTC ``datetime``.
|
|
60
|
+
|
|
61
|
+
``DataProcess`` timestamps must be timezone-aware; the naive
|
|
62
|
+
:func:`datetime.datetime.now` fails validation.
|
|
63
|
+
|
|
64
|
+
Returns
|
|
65
|
+
-------
|
|
66
|
+
datetime.datetime
|
|
67
|
+
The current time in UTC, tz-aware.
|
|
68
|
+
"""
|
|
69
|
+
return datetime.now(UTC)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def make_data_process(
|
|
73
|
+
*,
|
|
74
|
+
process_type: ProcessName,
|
|
75
|
+
code_url: str,
|
|
76
|
+
experimenters: Sequence[str],
|
|
77
|
+
start: datetime,
|
|
78
|
+
end: datetime | None = None,
|
|
79
|
+
stage: ProcessStage = ProcessStage.PROCESSING,
|
|
80
|
+
name: str | None = None,
|
|
81
|
+
version: str | None = None,
|
|
82
|
+
commit_hash: str | None = None,
|
|
83
|
+
parameters: Mapping[str, Any] | None = None,
|
|
84
|
+
output_path: str | Path | None = None,
|
|
85
|
+
notes: str | None = None,
|
|
86
|
+
) -> DataProcess:
|
|
87
|
+
"""Build a single :class:`DataProcess` for this capsule's step.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
process_type : ProcessName
|
|
92
|
+
The aind-data-schema process category (e.g.
|
|
93
|
+
``ProcessName.IMAGE_ATLAS_ALIGNMENT``).
|
|
94
|
+
code_url : str
|
|
95
|
+
Repository URL of the capsule that ran this step.
|
|
96
|
+
experimenters : Sequence[str]
|
|
97
|
+
Names of those responsible; serialized as a list.
|
|
98
|
+
start : datetime.datetime
|
|
99
|
+
Timezone-aware start time (capture before the work runs).
|
|
100
|
+
end : datetime.datetime, optional
|
|
101
|
+
Timezone-aware end time; defaults to :func:`utcnow` if omitted.
|
|
102
|
+
stage : ProcessStage, default ``ProcessStage.PROCESSING``
|
|
103
|
+
Processing vs Analysis stage.
|
|
104
|
+
name : str, optional
|
|
105
|
+
Unique node name. Required if this process will participate in a
|
|
106
|
+
dependency graph (i.e. for :func:`append_process`).
|
|
107
|
+
version : str, optional
|
|
108
|
+
Code version (e.g. package version of the logic that ran).
|
|
109
|
+
commit_hash : str, optional
|
|
110
|
+
Git commit of the capsule code.
|
|
111
|
+
parameters : Mapping[str, Any], optional
|
|
112
|
+
Run parameters, stored on the ``Code`` record.
|
|
113
|
+
output_path : str or pathlib.Path, optional
|
|
114
|
+
Where this step wrote its outputs.
|
|
115
|
+
notes : str, optional
|
|
116
|
+
Free-text notes (required by the schema when
|
|
117
|
+
``process_type`` is ``ProcessName.OTHER``).
|
|
118
|
+
|
|
119
|
+
Returns
|
|
120
|
+
-------
|
|
121
|
+
DataProcess
|
|
122
|
+
"""
|
|
123
|
+
return DataProcess(
|
|
124
|
+
process_type=process_type,
|
|
125
|
+
name=name,
|
|
126
|
+
stage=stage,
|
|
127
|
+
experimenters=list(experimenters),
|
|
128
|
+
start_date_time=start,
|
|
129
|
+
end_date_time=end if end is not None else utcnow(),
|
|
130
|
+
output_path=str(output_path) if output_path is not None else None,
|
|
131
|
+
code=Code(
|
|
132
|
+
url=code_url,
|
|
133
|
+
version=version,
|
|
134
|
+
commit_hash=commit_hash,
|
|
135
|
+
parameters=dict(parameters) if parameters else None,
|
|
136
|
+
),
|
|
137
|
+
notes=notes,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def read_processings(input_dir: str | Path, *, pattern: str = "*processing.json") -> list[Processing]:
|
|
142
|
+
"""Load upstream :class:`Processing` records handed to this node.
|
|
143
|
+
|
|
144
|
+
Recursively globs ``input_dir`` (Code Ocean mounts upstream outputs under
|
|
145
|
+
``/data``), deduplicating by resolved path. Unreadable / invalid files are
|
|
146
|
+
skipped with a warning rather than raising, so a metadata-emit path never
|
|
147
|
+
crashes the capsule.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
input_dir : str or pathlib.Path
|
|
152
|
+
Directory to search (typically ``/data``).
|
|
153
|
+
pattern : str, default ``"*processing.json"``
|
|
154
|
+
Glob applied recursively.
|
|
155
|
+
|
|
156
|
+
Returns
|
|
157
|
+
-------
|
|
158
|
+
list[Processing]
|
|
159
|
+
Parsed upstream Processing objects (possibly empty).
|
|
160
|
+
"""
|
|
161
|
+
root = Path(input_dir)
|
|
162
|
+
seen: set[Path] = set()
|
|
163
|
+
out: list[Processing] = []
|
|
164
|
+
for file_path in sorted(root.rglob(pattern)):
|
|
165
|
+
try:
|
|
166
|
+
key = file_path.resolve()
|
|
167
|
+
except OSError:
|
|
168
|
+
key = file_path
|
|
169
|
+
if key in seen:
|
|
170
|
+
continue
|
|
171
|
+
seen.add(key)
|
|
172
|
+
try:
|
|
173
|
+
data = json.loads(file_path.read_text())
|
|
174
|
+
out.append(Processing.model_validate(data))
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
_logger.warning("skipping unreadable processing.json %s: %s", file_path, exc)
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _dedup_codes(codes: Sequence[Code]) -> list[Code]:
|
|
181
|
+
"""Deduplicate ``Code`` entries by ``(url, name, version)``, preserving order."""
|
|
182
|
+
seen: set[tuple[str | None, str | None, str | None]] = set()
|
|
183
|
+
out: list[Code] = []
|
|
184
|
+
for code in codes:
|
|
185
|
+
key = (code.url, code.name, code.version)
|
|
186
|
+
if key in seen:
|
|
187
|
+
continue
|
|
188
|
+
seen.add(key)
|
|
189
|
+
out.append(code)
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def append_process(
|
|
194
|
+
incoming: Sequence[Processing],
|
|
195
|
+
new: DataProcess,
|
|
196
|
+
*,
|
|
197
|
+
pipelines: Sequence[Code] | None = None,
|
|
198
|
+
) -> Processing:
|
|
199
|
+
"""Merge upstream graphs and append ``new`` at the frontier.
|
|
200
|
+
|
|
201
|
+
Builds a single :class:`Processing` whose ``dependency_graph`` is the union
|
|
202
|
+
of every incoming graph plus one node for ``new`` that depends on the
|
|
203
|
+
*frontier* of the merged graph — the set of upstream processes that nothing
|
|
204
|
+
else depends on yet (the sinks). A fan-in (several ``incoming`` graphs)
|
|
205
|
+
therefore connects ``new`` to the head of each branch. Duplicate process
|
|
206
|
+
names across branches (a diamond) are collapsed: the first record wins and
|
|
207
|
+
its dependency lists are unioned.
|
|
208
|
+
|
|
209
|
+
Parameters
|
|
210
|
+
----------
|
|
211
|
+
incoming : Sequence[Processing]
|
|
212
|
+
Upstream Processing records (from :func:`read_processings`). Empty for
|
|
213
|
+
a source node.
|
|
214
|
+
new : DataProcess
|
|
215
|
+
This node's process. Must have a unique, non-``None`` ``name``.
|
|
216
|
+
pipelines : Sequence[Code], optional
|
|
217
|
+
Pipeline repositories to record; unioned with any carried by
|
|
218
|
+
``incoming`` and deduplicated.
|
|
219
|
+
|
|
220
|
+
Returns
|
|
221
|
+
-------
|
|
222
|
+
Processing
|
|
223
|
+
The merged record with ``new`` appended.
|
|
224
|
+
|
|
225
|
+
Raises
|
|
226
|
+
------
|
|
227
|
+
ValueError
|
|
228
|
+
If ``new.name`` is ``None``, if any incoming ``DataProcess`` lacks a
|
|
229
|
+
name, or if ``new.name`` already exists upstream.
|
|
230
|
+
"""
|
|
231
|
+
data_processes: list[DataProcess] = []
|
|
232
|
+
graph: dict[str, list[str]] = {}
|
|
233
|
+
|
|
234
|
+
def _register(process: DataProcess, deps: Sequence[str]) -> None:
|
|
235
|
+
name = process.name
|
|
236
|
+
if name is None:
|
|
237
|
+
raise ValueError("encountered a DataProcess with no name; cannot graph it")
|
|
238
|
+
if name in graph:
|
|
239
|
+
for dep in deps:
|
|
240
|
+
if dep not in graph[name]:
|
|
241
|
+
graph[name].append(dep)
|
|
242
|
+
return
|
|
243
|
+
data_processes.append(process)
|
|
244
|
+
graph[name] = list(deps)
|
|
245
|
+
|
|
246
|
+
for processing in incoming:
|
|
247
|
+
existing = processing.dependency_graph or {}
|
|
248
|
+
for process in processing.data_processes:
|
|
249
|
+
pname = process.name
|
|
250
|
+
if pname is None:
|
|
251
|
+
raise ValueError("incoming DataProcess has no name; cannot graph it")
|
|
252
|
+
_register(process, list(existing.get(pname, [])))
|
|
253
|
+
|
|
254
|
+
new_name = new.name
|
|
255
|
+
if new_name is None:
|
|
256
|
+
raise ValueError("new DataProcess must have a name to be added to the graph")
|
|
257
|
+
if new_name in graph:
|
|
258
|
+
raise ValueError(f"DataProcess name {new_name!r} already present upstream")
|
|
259
|
+
|
|
260
|
+
referenced = {dep for deps in graph.values() for dep in deps}
|
|
261
|
+
frontier = [name for name in graph if name not in referenced]
|
|
262
|
+
_register(new, frontier)
|
|
263
|
+
|
|
264
|
+
merged_pipelines: list[Code] = []
|
|
265
|
+
for processing in incoming:
|
|
266
|
+
merged_pipelines.extend(processing.pipelines or [])
|
|
267
|
+
if pipelines:
|
|
268
|
+
merged_pipelines.extend(pipelines)
|
|
269
|
+
deduped = _dedup_codes(merged_pipelines)
|
|
270
|
+
|
|
271
|
+
return Processing(
|
|
272
|
+
data_processes=data_processes,
|
|
273
|
+
dependency_graph=graph,
|
|
274
|
+
pipelines=deduped or None,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def write_processing(processing: Processing, output_dir: str | Path) -> Path:
|
|
279
|
+
"""Write ``processing.json`` into ``output_dir`` via aind-data-schema.
|
|
280
|
+
|
|
281
|
+
Parameters
|
|
282
|
+
----------
|
|
283
|
+
processing : Processing
|
|
284
|
+
The record to serialize.
|
|
285
|
+
output_dir : str or pathlib.Path
|
|
286
|
+
Destination directory (created if needed); the file is always named
|
|
287
|
+
``processing.json`` per the aind-data-schema convention.
|
|
288
|
+
|
|
289
|
+
Returns
|
|
290
|
+
-------
|
|
291
|
+
pathlib.Path
|
|
292
|
+
Path to the written ``processing.json``.
|
|
293
|
+
"""
|
|
294
|
+
out = Path(output_dir)
|
|
295
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
296
|
+
processing.write_standard_file(output_directory=out)
|
|
297
|
+
return out / "processing.json"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def emit_processing(
|
|
301
|
+
new: DataProcess,
|
|
302
|
+
*,
|
|
303
|
+
input_dir: str | Path,
|
|
304
|
+
output_dir: str | Path,
|
|
305
|
+
pipelines: Sequence[Code] | None = None,
|
|
306
|
+
) -> Path:
|
|
307
|
+
"""Read upstream graphs, append ``new``, and write ``processing.json``.
|
|
308
|
+
|
|
309
|
+
Convenience one-call wrapper around :func:`read_processings`,
|
|
310
|
+
:func:`append_process`, and :func:`write_processing` for the common capsule
|
|
311
|
+
path.
|
|
312
|
+
|
|
313
|
+
Parameters
|
|
314
|
+
----------
|
|
315
|
+
new : DataProcess
|
|
316
|
+
This node's process (must have a unique name).
|
|
317
|
+
input_dir : str or pathlib.Path
|
|
318
|
+
Where to read upstream ``processing.json`` files (typically ``/data``).
|
|
319
|
+
output_dir : str or pathlib.Path
|
|
320
|
+
Where to write the merged ``processing.json`` (typically a
|
|
321
|
+
subject-namespaced subdir of ``/results``).
|
|
322
|
+
pipelines : Sequence[Code], optional
|
|
323
|
+
Pipeline repositories to record.
|
|
324
|
+
|
|
325
|
+
Returns
|
|
326
|
+
-------
|
|
327
|
+
pathlib.Path
|
|
328
|
+
Path to the written ``processing.json``.
|
|
329
|
+
"""
|
|
330
|
+
incoming = read_processings(input_dir)
|
|
331
|
+
processing = append_process(incoming, new, pipelines=pipelines)
|
|
332
|
+
return write_processing(processing, output_dir)
|