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.
@@ -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)