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,81 @@
1
+ """Utilities for use in Code Ocean pipelines.
2
+
3
+ Core primitives are re-exported at the package level. The rich-aware
4
+ helpers in :mod:`.log` (``install_rich_handler``, ``build_progress``)
5
+ require the optional ``[rich]`` extra and must be imported explicitly;
6
+ :func:`attach_file_log` is stdlib-only and re-exported here. Likewise the
7
+ :mod:`.metadata` helpers (``make_data_process``, ``append_process``,
8
+ ``emit_processing``) and the :mod:`.step` ``processing.json`` decorator
9
+ (``capsule_step`` / ``processing_step``) require the optional ``[metadata]``
10
+ extra (``aind-data-schema``) and are import-only.
11
+ """
12
+
13
+ from importlib.metadata import PackageNotFoundError, version
14
+
15
+ from .cache import canonical_params, input_fingerprint
16
+ from .cli import parse_truthy
17
+ from .diagnostics import MemoryReporter, log_data_tree, start_memory_reporter
18
+ from .io import (
19
+ TRANSIENT_ERRNOS,
20
+ atomic_json_write,
21
+ atomic_write_text,
22
+ retry_on_oserror,
23
+ )
24
+ from .log import attach_file_log
25
+ from .process import (
26
+ GracefulExit,
27
+ check_shutdown,
28
+ install_shutdown_handlers,
29
+ is_shutdown_requested,
30
+ reset_shutdown_state,
31
+ shutdown_handler,
32
+ )
33
+ from .provenance import capsule_commit, package_version
34
+ from .role_dispatch import (
35
+ Role,
36
+ StreamConfigError,
37
+ default_sanitize,
38
+ find_launcher_manifest,
39
+ find_stream_config,
40
+ find_worker_manifests,
41
+ merge_manifests,
42
+ write_stream_configs,
43
+ )
44
+ from .threading_utils import submit_with_context
45
+
46
+ try:
47
+ __version__ = version("aind-code-ocean-pipeline-utils")
48
+ except PackageNotFoundError:
49
+ __version__ = "0.0.0.dev0"
50
+
51
+ __all__ = [
52
+ "TRANSIENT_ERRNOS",
53
+ "GracefulExit",
54
+ "MemoryReporter",
55
+ "Role",
56
+ "StreamConfigError",
57
+ "__version__",
58
+ "atomic_json_write",
59
+ "atomic_write_text",
60
+ "attach_file_log",
61
+ "canonical_params",
62
+ "capsule_commit",
63
+ "check_shutdown",
64
+ "default_sanitize",
65
+ "find_launcher_manifest",
66
+ "find_stream_config",
67
+ "find_worker_manifests",
68
+ "input_fingerprint",
69
+ "install_shutdown_handlers",
70
+ "is_shutdown_requested",
71
+ "log_data_tree",
72
+ "merge_manifests",
73
+ "package_version",
74
+ "parse_truthy",
75
+ "reset_shutdown_state",
76
+ "retry_on_oserror",
77
+ "shutdown_handler",
78
+ "start_memory_reporter",
79
+ "submit_with_context",
80
+ "write_stream_configs",
81
+ ]
@@ -0,0 +1,108 @@
1
+ """Deterministic fingerprints for pipeline cache keys and resume validation.
2
+
3
+ A fingerprint is ``sha256:`` followed by the hex digest of a canonical JSON
4
+ encoding of the input parameters. Canonical JSON here means:
5
+
6
+ - keys are strings, sorted recursively
7
+ - no whitespace
8
+ - NaN / ``inf`` are rejected (non-standard JSON)
9
+ - UTF-8 encoded before hashing
10
+
11
+ Given the same Python ``dict`` — regardless of insertion order — the same
12
+ fingerprint comes out every time, on every Python version that supports the
13
+ type annotations.
14
+
15
+ Non-JSON values (ndarray, Path, dataclasses, ...) raise :class:`TypeError`
16
+ with a message identifying the offending key path. Callers are expected to
17
+ coerce at the call site (``Path → str``, ``ndarray → list`` with a size
18
+ ceiling). This keeps the fingerprint well-defined and reproducible rather
19
+ than hiding silent behavior changes inside a ``default=`` hook.
20
+
21
+ The ``sha256:`` prefix leaves room to change the algorithm later without
22
+ breaking consumers that string-compare fingerprints.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import json
29
+ from typing import Any
30
+
31
+ __all__ = ["canonical_params", "input_fingerprint"]
32
+
33
+
34
+ def _validate_json(value: Any, path: str) -> None:
35
+ """Walk ``value``; raise :class:`TypeError` on any non-JSON-encodable leaf.
36
+
37
+ Raising early with an explicit key path beats the opaque
38
+ ``"Object of type X is not JSON serializable"`` that :func:`json.dumps`
39
+ emits without telling the caller *where* the bad value lives.
40
+ """
41
+ if value is None or isinstance(value, (bool, int, float, str)):
42
+ if isinstance(value, float) and (value != value or value in (float("inf"), float("-inf"))):
43
+ raise TypeError(f"value at {path} is not JSON-standard: {value!r}")
44
+ return
45
+ if isinstance(value, dict):
46
+ for key, item in value.items():
47
+ if not isinstance(key, str):
48
+ raise TypeError(f"key at {path} is not a string: got {type(key).__name__}")
49
+ _validate_json(item, f"{path}.{key}" if path != "<root>" else key)
50
+ return
51
+ if isinstance(value, (list, tuple)):
52
+ for index, item in enumerate(value):
53
+ _validate_json(item, f"{path}[{index}]")
54
+ return
55
+ raise TypeError(f"value at {path} is not JSON-serializable: got {type(value).__name__}")
56
+
57
+
58
+ def canonical_params(params: dict[str, Any]) -> str:
59
+ """Return the canonical JSON encoding of ``params``.
60
+
61
+ Parameters
62
+ ----------
63
+ params : dict[str, Any]
64
+ The input parameters. Must be JSON-serializable; see module
65
+ docstring for the allowed leaf types.
66
+
67
+ Returns
68
+ -------
69
+ str
70
+ Compact JSON with sorted keys, no whitespace, no NaN/inf.
71
+
72
+ Raises
73
+ ------
74
+ TypeError
75
+ If any value (or key) is not JSON-encodable. The message identifies
76
+ the offending key path.
77
+ """
78
+ _validate_json(params, "<root>")
79
+ return json.dumps(
80
+ params,
81
+ sort_keys=True,
82
+ separators=(",", ":"),
83
+ ensure_ascii=False,
84
+ allow_nan=False,
85
+ )
86
+
87
+
88
+ def input_fingerprint(params: dict[str, Any]) -> str:
89
+ """Return a stable ``sha256:`` fingerprint of ``params``.
90
+
91
+ Equal inputs (as Python dicts, regardless of key insertion order)
92
+ produce equal fingerprints. Distinct inputs almost-certainly produce
93
+ distinct fingerprints (SHA256 collision resistance).
94
+
95
+ Parameters
96
+ ----------
97
+ params : dict[str, Any]
98
+ The input parameters. See :func:`canonical_params` for the
99
+ serialization contract.
100
+
101
+ Returns
102
+ -------
103
+ str
104
+ ``"sha256:"`` followed by 64 hex characters.
105
+ """
106
+ canonical = canonical_params(params)
107
+ digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
108
+ return f"sha256:{digest}"
@@ -0,0 +1,51 @@
1
+ """Parsers for Code Ocean app-panel parameter values.
2
+
3
+ CO's app panel passes every parameter as a string when
4
+ ``named_parameters: true``, so bool-flag CLIs (argparse ``store_true``,
5
+ tyro's ``--flag``/``--no-flag``) don't round-trip: the panel always
6
+ emits ``--flag <string>``. Capsules end up reimplementing a
7
+ permissive truthy parser. The one here centralizes the accepted set
8
+ so consumers don't independently pick slightly different semantics.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __all__ = ["parse_truthy"]
14
+
15
+ _TRUTHY_WORDS: frozenset[str] = frozenset({"true", "yes", "y", "t"})
16
+
17
+
18
+ def parse_truthy(value: str) -> bool:
19
+ """Parse a permissive truthy string from an app-panel named parameter.
20
+
21
+ Returns True for:
22
+
23
+ - any case variant of ``"true"`` / ``"yes"`` / ``"y"`` / ``"t"``
24
+ - any numeric string (int or float) whose value is non-zero:
25
+ ``"1"``, ``"42"``, ``"-1"``, ``"3.14"``, ``"1.5e2"`` all parse as True
26
+
27
+ Returns False for everything else, including the empty string,
28
+ ``"0"``, ``"0.0"``, ``"false"``, ``"no"``, and unrecognized words.
29
+
30
+ The numeric handling exists because CO panels sometimes pass
31
+ integers through as strings ("0"/"1" being a common idiom), and
32
+ ``parse_truthy("0")`` returning True would be a subtle footgun.
33
+
34
+ Parameters
35
+ ----------
36
+ value : str
37
+ The raw parameter value from the app panel.
38
+
39
+ Returns
40
+ -------
41
+ bool
42
+ """
43
+ stripped = value.strip()
44
+ if not stripped:
45
+ return False
46
+ if stripped.lower() in _TRUTHY_WORDS:
47
+ return True
48
+ try:
49
+ return bool(float(stripped))
50
+ except ValueError:
51
+ return False
@@ -0,0 +1,202 @@
1
+ """First-log-line diagnostics for pipeline workers.
2
+
3
+ Two helpers that drop into any capsule ``run()`` so that post-mortem
4
+ analysis of a killed or crashed container is tractable.
5
+
6
+ - :func:`log_data_tree` dumps a depth-bounded view of ``/data`` on
7
+ startup so mount-path mismatches ("where did my data asset land?")
8
+ are visible in the log without an interactive session.
9
+ - :func:`start_memory_reporter` logs RSS + cgroup limit periodically
10
+ from a daemon thread. When the kernel OOM killer / AWS Batch sends
11
+ SIGKILL, no ``except`` block runs, but the last line of the captured
12
+ log shows peak approach-to-limit — enough to distinguish OOM from
13
+ spot reclamation from application errors.
14
+
15
+ Both helpers are stdlib-only and degrade gracefully on non-Linux
16
+ systems where ``/proc/self/status`` or cgroup files are absent.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import os
23
+ import threading
24
+ from pathlib import Path
25
+
26
+ __all__ = [
27
+ "MemoryReporter",
28
+ "log_data_tree",
29
+ "start_memory_reporter",
30
+ ]
31
+
32
+ _logger = logging.getLogger(__name__)
33
+
34
+ _PROC_STATUS = "/proc/self/status"
35
+ _CGROUP_LIMIT_PATHS: tuple[str, ...] = (
36
+ "/sys/fs/cgroup/memory.max", # cgroup v2
37
+ "/sys/fs/cgroup/memory/memory.limit_in_bytes", # cgroup v1
38
+ )
39
+ # Values at or above this are the kernel's "effectively unlimited"
40
+ # sentinels — skip logging them so "no limit" doesn't look like a
41
+ # meaningful ceiling.
42
+ _NO_LIMIT_SENTINEL = 1 << 50
43
+
44
+
45
+ def log_data_tree(
46
+ root: Path | str = Path("/data"),
47
+ *,
48
+ max_depth: int = 3,
49
+ logger: logging.Logger | None = None,
50
+ ) -> None:
51
+ """Log a depth-bounded listing of ``root`` on startup.
52
+
53
+ Uses :func:`os.walk` with ``followlinks=True`` because Code Ocean
54
+ stages data via symlink chains that :meth:`Path.glob` doesn't
55
+ traverse by default. Each directory logs one line with its
56
+ ``(dirs, files)`` counts. The depth cap keeps zarr chunk trees
57
+ from flooding the log.
58
+
59
+ Parameters
60
+ ----------
61
+ root : Path or str, default ``Path("/data")``
62
+ Top of the tree. If missing, one line is logged and the
63
+ function returns without an error.
64
+ max_depth : int, default 3
65
+ Number of levels below ``root`` to descend.
66
+ logger : logging.Logger, optional
67
+ Target logger. Defaults to this module's logger.
68
+ """
69
+ log = logger if logger is not None else _logger
70
+ base = Path(root)
71
+ if not base.exists():
72
+ log.info("data tree: %s does not exist", base)
73
+ return
74
+ log.info("data tree under %s (max depth %d, symlinks followed):", base, max_depth)
75
+ for cur_root, dirs, files in os.walk(base, followlinks=True):
76
+ cur_path = Path(cur_root)
77
+ depth = len(cur_path.relative_to(base).parts)
78
+ if depth >= max_depth:
79
+ dirs[:] = []
80
+ rel = cur_path.relative_to(base) if cur_path != base else Path(".")
81
+ log.info(" %s/ (%d dirs, %d files)", rel, len(dirs), len(files))
82
+
83
+
84
+ def _read_vmrss_kb(status_path: str = _PROC_STATUS) -> int | None:
85
+ """Read VmRSS (kB) from ``status_path``; return None on any failure."""
86
+ try:
87
+ with open(status_path) as f:
88
+ for line in f:
89
+ if line.startswith("VmRSS:"):
90
+ return int(line.split()[1])
91
+ except (OSError, ValueError):
92
+ return None
93
+ return None
94
+
95
+
96
+ def _read_cgroup_limit_bytes(
97
+ candidate_paths: tuple[str, ...] = _CGROUP_LIMIT_PATHS,
98
+ ) -> int | None:
99
+ """Return the first readable cgroup memory limit in bytes, or None."""
100
+ for path in candidate_paths:
101
+ try:
102
+ with open(path) as f:
103
+ raw = f.read().strip()
104
+ except OSError:
105
+ continue
106
+ if raw == "max":
107
+ return None
108
+ try:
109
+ return int(raw)
110
+ except ValueError:
111
+ continue
112
+ return None
113
+
114
+
115
+ class MemoryReporter:
116
+ """Handle for a running background memory reporter.
117
+
118
+ Returned by :func:`start_memory_reporter`. Callers can :meth:`stop`
119
+ the reporter explicitly (useful for deterministic test teardown
120
+ and for capsules that want to quiet the log before shutdown).
121
+ The underlying thread is a daemon, so omitting :meth:`stop` is
122
+ fine — the process will exit regardless.
123
+ """
124
+
125
+ def __init__(self, thread: threading.Thread, stop_event: threading.Event) -> None:
126
+ self._thread = thread
127
+ self._stop_event = stop_event
128
+
129
+ def stop(self, *, timeout: float | None = 1.0) -> None:
130
+ """Signal the reporter to exit and join its thread.
131
+
132
+ Parameters
133
+ ----------
134
+ timeout : float or None, default 1.0
135
+ Passed to :meth:`threading.Thread.join`. None waits
136
+ indefinitely.
137
+ """
138
+ self._stop_event.set()
139
+ self._thread.join(timeout=timeout)
140
+
141
+ @property
142
+ def is_alive(self) -> bool:
143
+ """Whether the reporter thread is still running."""
144
+ return self._thread.is_alive()
145
+
146
+
147
+ def start_memory_reporter(
148
+ *,
149
+ interval_s: float = 15.0,
150
+ logger: logging.Logger | None = None,
151
+ status_path: str = _PROC_STATUS,
152
+ cgroup_limit_paths: tuple[str, ...] = _CGROUP_LIMIT_PATHS,
153
+ ) -> MemoryReporter:
154
+ """Start a daemon thread that logs RSS + cgroup limit periodically.
155
+
156
+ On the first tick the cgroup limit is logged (if any). On every
157
+ tick — including the first — VmRSS is logged as a GB value and,
158
+ when a limit is known, as a percentage of that limit.
159
+
160
+ The reporter silently skips ticks on platforms where
161
+ ``/proc/self/status`` is unreadable (macOS, Windows).
162
+
163
+ Parameters
164
+ ----------
165
+ interval_s : float, default 15.0
166
+ Seconds between ticks. Also the worst-case delay between
167
+ :meth:`MemoryReporter.stop` being called and the thread exiting.
168
+ logger : logging.Logger, optional
169
+ Target logger. Defaults to this module's logger.
170
+ status_path : str
171
+ Override for ``/proc/self/status``. Primarily a test seam.
172
+ cgroup_limit_paths : tuple[str, ...]
173
+ Override for the cgroup-limit candidate list. Primarily a
174
+ test seam.
175
+
176
+ Returns
177
+ -------
178
+ MemoryReporter
179
+ Handle with a :meth:`MemoryReporter.stop` method.
180
+ """
181
+ log = logger if logger is not None else _logger
182
+ stop_event = threading.Event()
183
+
184
+ def _loop() -> None:
185
+ limit = _read_cgroup_limit_bytes(cgroup_limit_paths)
186
+ if limit is not None and limit < _NO_LIMIT_SENTINEL:
187
+ log.info("memory: cgroup limit %.1f GB", limit / 1e9)
188
+ # Loop: log a tick, then wait. Using Event.wait so stop() is
189
+ # responsive even mid-interval.
190
+ while True:
191
+ rss_kb = _read_vmrss_kb(status_path)
192
+ if rss_kb is not None:
193
+ msg = f"memory: VmRSS {rss_kb / 1e6:.2f} GB"
194
+ if limit is not None and limit < _NO_LIMIT_SENTINEL:
195
+ msg += f" ({100 * rss_kb * 1024 / limit:.0f}% of cgroup)"
196
+ log.info(msg)
197
+ if stop_event.wait(timeout=interval_s):
198
+ return
199
+
200
+ thread = threading.Thread(target=_loop, daemon=True, name="aind-mem-reporter")
201
+ thread.start()
202
+ return MemoryReporter(thread, stop_event)
@@ -0,0 +1,217 @@
1
+ """I/O helpers: retry on transient OS errors, atomic file writes.
2
+
3
+ The two primitives here address failure modes that recur in every pipeline
4
+ capsule: transient network or storage hiccups, and half-written files left
5
+ behind when a process is killed mid-write. Both exist to turn silent
6
+ correctness bugs into loud, retriable failures.
7
+
8
+ Examples
9
+ --------
10
+ Retry a flaky S3 download and write the result atomically::
11
+
12
+ from aind_code_ocean_pipeline_utils.io import retry_on_oserror, atomic_json_write
13
+
14
+ download = retry_on_oserror(_raw_download, retries=5)
15
+ payload = download(url)
16
+ atomic_json_write(out_path, payload)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import errno
22
+ import functools
23
+ import json
24
+ import logging
25
+ import os
26
+ import tempfile
27
+ import time
28
+ from collections.abc import Callable, Generator
29
+ from contextlib import contextmanager
30
+ from pathlib import Path
31
+ from typing import Any, TextIO, TypeVar
32
+
33
+ __all__ = [
34
+ "TRANSIENT_ERRNOS",
35
+ "atomic_json_write",
36
+ "atomic_write_text",
37
+ "retry_on_oserror",
38
+ ]
39
+
40
+ _logger = logging.getLogger(__name__)
41
+
42
+ T = TypeVar("T")
43
+
44
+ #: errno values considered worth retrying.
45
+ #:
46
+ #: Permanent errors (ENOENT, EACCES, EINVAL, EISDIR, ...) are intentionally
47
+ #: excluded — retrying them hides configuration mistakes behind minutes of
48
+ #: exponential backoff. Callers with different failure models (e.g. a rate-
49
+ #: limited HTTP endpoint) can union in additional codes at the call site.
50
+ TRANSIENT_ERRNOS: frozenset[int] = frozenset(
51
+ {
52
+ errno.EIO,
53
+ errno.EAGAIN,
54
+ errno.EBUSY,
55
+ errno.ENETDOWN,
56
+ errno.ENETUNREACH,
57
+ errno.ECONNRESET,
58
+ errno.ETIMEDOUT,
59
+ errno.EHOSTUNREACH,
60
+ }
61
+ )
62
+
63
+
64
+ def retry_on_oserror(
65
+ fn: Callable[..., T],
66
+ *,
67
+ retries: int = 5,
68
+ initial_delay: float = 1.0,
69
+ transient_errnos: frozenset[int] = TRANSIENT_ERRNOS,
70
+ ) -> Callable[..., T]:
71
+ """Return a wrapper that retries ``fn`` on transient :class:`OSError`.
72
+
73
+ The wrapper catches :class:`OSError` whose ``errno`` is in
74
+ ``transient_errnos``, waits ``(2 ** attempt) * initial_delay`` seconds,
75
+ and retries up to ``retries`` times. Permanent errors surface
76
+ immediately, as does the final attempt on exhaustion.
77
+
78
+ Parameters
79
+ ----------
80
+ fn : callable
81
+ The function to wrap. Can also be used as a bare decorator
82
+ (``@retry_on_oserror``) since ``fn`` is positional.
83
+ retries : int, default 5
84
+ Number of retry attempts after the initial call. Total call count
85
+ is ``retries + 1``.
86
+ initial_delay : float, default 1.0
87
+ Seconds to sleep before the first retry. Each subsequent retry
88
+ doubles the delay (exponential backoff).
89
+ transient_errnos : frozenset[int]
90
+ errno values considered transient. Defaults to
91
+ :data:`TRANSIENT_ERRNOS`.
92
+
93
+ Returns
94
+ -------
95
+ callable
96
+ A wrapper preserving ``fn``'s signature.
97
+ """
98
+
99
+ @functools.wraps(fn)
100
+ def wrapper(*args: Any, **kwargs: Any) -> T:
101
+ start = time.monotonic()
102
+ for attempt in range(retries + 1):
103
+ try:
104
+ return fn(*args, **kwargs)
105
+ except OSError as exc:
106
+ if exc.errno not in transient_errnos or attempt == retries:
107
+ raise
108
+ delay = (2**attempt) * initial_delay
109
+ _logger.warning(
110
+ "retry_on_oserror: %s errno=%d (%s) attempt=%d/%d elapsed=%.2fs sleeping=%.2fs",
111
+ getattr(fn, "__qualname__", repr(fn)),
112
+ exc.errno,
113
+ os.strerror(exc.errno or 0),
114
+ attempt + 1,
115
+ retries,
116
+ time.monotonic() - start,
117
+ delay,
118
+ )
119
+ time.sleep(delay)
120
+ # Unreachable: either a successful return or a re-raised OSError
121
+ # exits the loop.
122
+ raise AssertionError("unreachable: retry loop exited without result")
123
+
124
+ return wrapper
125
+
126
+
127
+ @contextmanager
128
+ def atomic_write_text(
129
+ path: Path | str,
130
+ *,
131
+ fsync: bool = True,
132
+ encoding: str = "utf-8",
133
+ ) -> Generator[TextIO, None, None]:
134
+ """Write ``path`` atomically via a sibling temp file + :func:`os.replace`.
135
+
136
+ A file object is yielded for the caller to write to. On clean exit the
137
+ buffer is flushed, optionally fsync'd, and the temp file is renamed
138
+ over ``path``. On any exception the temp file is removed and the
139
+ exception propagates; ``path`` is never left half-written.
140
+
141
+ Parameters
142
+ ----------
143
+ path : Path or str
144
+ Destination path. Its parent directory must already exist.
145
+ fsync : bool, default True
146
+ If True, fsync the temp file before rename. This is the only way
147
+ to get durability guarantees on crash/power-loss; off-by-default
148
+ would be a silent correctness regression on spot instances.
149
+ encoding : str, default "utf-8"
150
+ Text encoding for the yielded file handle.
151
+
152
+ Yields
153
+ ------
154
+ TextIO
155
+ A writable text file handle backed by the temp file.
156
+
157
+ Notes
158
+ -----
159
+ Uses :func:`os.replace` (not :func:`os.rename`) so the rename is atomic
160
+ on Windows as well as POSIX.
161
+ """
162
+ dest = Path(path)
163
+ parent = dest.parent
164
+ fd, tmp_name = tempfile.mkstemp(
165
+ dir=parent if str(parent) else None,
166
+ prefix=f".{dest.name}.",
167
+ suffix=".tmp",
168
+ )
169
+ tmp_path = Path(tmp_name)
170
+ handle = os.fdopen(fd, "w", encoding=encoding)
171
+ committed = False
172
+ try:
173
+ try:
174
+ yield handle
175
+ handle.flush()
176
+ if fsync:
177
+ os.fsync(handle.fileno())
178
+ finally:
179
+ handle.close()
180
+ os.replace(tmp_path, dest)
181
+ committed = True
182
+ finally:
183
+ if not committed:
184
+ try:
185
+ tmp_path.unlink()
186
+ except FileNotFoundError:
187
+ pass
188
+
189
+
190
+ def atomic_json_write(
191
+ path: Path | str,
192
+ data: Any,
193
+ *,
194
+ fsync: bool = True,
195
+ indent: int | None = 2,
196
+ sort_keys: bool = True,
197
+ ) -> None:
198
+ """Serialize ``data`` as JSON and write to ``path`` atomically.
199
+
200
+ Thin wrapper over :func:`atomic_write_text` for the common JSON case.
201
+
202
+ Parameters
203
+ ----------
204
+ path : Path or str
205
+ Destination path.
206
+ data : Any
207
+ JSON-serializable value.
208
+ fsync : bool, default True
209
+ See :func:`atomic_write_text`.
210
+ indent : int or None, default 2
211
+ Pass-through to :func:`json.dump`. Use ``None`` for compact output.
212
+ sort_keys : bool, default True
213
+ Pass-through to :func:`json.dump`. Sorted keys make diffs and
214
+ fingerprints stable.
215
+ """
216
+ with atomic_write_text(path, fsync=fsync) as handle:
217
+ json.dump(data, handle, indent=indent, sort_keys=sort_keys)