cairns 0.2.0__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.
cairns/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ """Cairns: compute graph orchestration with caching and observability."""
2
+
3
+ from cairns.core import (
4
+ Cairn,
5
+ Handle,
6
+ Record,
7
+ Run,
8
+ Runtime,
9
+ cached_output,
10
+ cached_tracing,
11
+ cairn,
12
+ default_runtime,
13
+ step,
14
+ trace,
15
+ )
16
+ from cairns.patterns import rate_limited, replayable
17
+ from cairns.run import (
18
+ arun,
19
+ gc,
20
+ list_runs,
21
+ remove_run,
22
+ remove_runs_before,
23
+ run,
24
+ )
25
+
26
+ __all__ = [
27
+ # canonical
28
+ "step",
29
+ "trace",
30
+ "Handle",
31
+ "run",
32
+ "arun",
33
+ "Runtime",
34
+ "default_runtime",
35
+ "Cairn",
36
+ "cairn",
37
+ "Record",
38
+ # tier-3 primitive (advanced)
39
+ "Run",
40
+ # batteries
41
+ "cached_output",
42
+ "cached_tracing",
43
+ "rate_limited",
44
+ "replayable",
45
+ # ops
46
+ "gc",
47
+ "list_runs",
48
+ "remove_run",
49
+ "remove_runs_before",
50
+ ]
cairns/cli/__init__.py ADDED
@@ -0,0 +1,219 @@
1
+ """Cairns CLI.
2
+
3
+ Usage:
4
+ cairns script.py [ENTRY] Run a script (default action)
5
+ cairns Browse runs interactively
6
+ cairns list List all runs
7
+ cairns show [RUN_ID] Show trace (latest if omitted)
8
+ cairns output PATH Show a cached output
9
+ cairns gc [--before DATE] Garbage collect
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import importlib.util
16
+ import os
17
+ import sys
18
+ from datetime import datetime, timezone
19
+ from typing import Any
20
+
21
+
22
+ def _store_path(args: argparse.Namespace) -> str:
23
+ return getattr(args, "store", None) or ".cairns"
24
+
25
+
26
+ # ── Commands ──
27
+
28
+
29
+ def cmd_list(args: argparse.Namespace) -> None:
30
+ from cairns.run import show_runs
31
+
32
+ show_runs(_store_path(args))
33
+
34
+
35
+ def cmd_show(args: argparse.Namespace) -> None:
36
+ from cairns.run import show_trace
37
+
38
+ run_id: str | None = getattr(args, "run_id", None)
39
+ show_trace(_store_path(args), run_id=run_id)
40
+
41
+
42
+ def cmd_output(args: argparse.Namespace) -> None:
43
+ from cairns.run import show_output
44
+
45
+ path: str = args.path
46
+ if os.path.islink(path):
47
+ path = str(os.path.realpath(path))
48
+ show_output(path)
49
+
50
+
51
+ def cmd_gc(args: argparse.Namespace) -> None:
52
+ from cairns.run import gc, list_runs
53
+
54
+ store = _store_path(args)
55
+ before: datetime | None = None
56
+ if args.before:
57
+ before = datetime.fromisoformat(args.before).replace(tzinfo=timezone.utc)
58
+
59
+ keep_latest: bool = args.keep_latest
60
+
61
+ # Show current state first
62
+ runs = list_runs(store)
63
+ if runs:
64
+ from cairns.run import show_runs
65
+ show_runs(store)
66
+
67
+ removed_runs, removed_outputs = gc(store, before=before, keep_latest=keep_latest)
68
+
69
+ if removed_runs:
70
+ print(f"Removed {len(removed_runs)} run(s):")
71
+ for r in removed_runs:
72
+ print(f" {r}")
73
+ if removed_outputs:
74
+ print(f"Removed {len(removed_outputs)} orphaned output(s)")
75
+ if not removed_runs and not removed_outputs:
76
+ print("Nothing to clean up.")
77
+
78
+
79
+ def cmd_run(script: str, entry_name: str, store: str, *, force: bool = False) -> None:
80
+ from cairns.run import run as cairn_run
81
+
82
+ # Load the script as a module
83
+ script_dir = os.path.dirname(os.path.abspath(script))
84
+ if script_dir not in sys.path:
85
+ sys.path.insert(0, script_dir)
86
+
87
+ spec = importlib.util.spec_from_file_location("__cairn_script__", script)
88
+ if spec is None or spec.loader is None:
89
+ print(f"Error: cannot load {script}", file=sys.stderr)
90
+ sys.exit(1)
91
+ module = importlib.util.module_from_spec(spec)
92
+ sys.modules["__cairn_script__"] = module
93
+ spec.loader.exec_module(module)
94
+
95
+ # Find the entry point: try explicit name, then 'main', then script basename
96
+ entry: Any = getattr(module, entry_name, None)
97
+ if entry is None and entry_name == "main":
98
+ basename = os.path.splitext(os.path.basename(script))[0]
99
+ entry = getattr(module, basename, None)
100
+ if entry is not None:
101
+ entry_name = basename
102
+ if entry is None:
103
+ candidates = [
104
+ name for name in dir(module)
105
+ if not name.startswith("_") and callable(getattr(module, name))
106
+ ]
107
+ print(f"Error: {script} has no function '{entry_name}'", file=sys.stderr)
108
+ if candidates:
109
+ print(f"Available functions: {', '.join(candidates)}", file=sys.stderr)
110
+ sys.exit(1)
111
+
112
+ # Build label from script path + entry name
113
+ script_rel = os.path.relpath(script)
114
+ script_module = os.path.splitext(script_rel)[0].replace(os.sep, ".")
115
+ label = f"{script_module}:{entry_name}"
116
+
117
+ # --force: remove previous runs for this entry point + GC orphaned outputs
118
+ if force:
119
+ from cairns.run import gc_outputs, list_runs, remove_run
120
+ runs = [r for r in list_runs(store) if r.entry_name == label]
121
+ for r in runs:
122
+ remove_run(store, r.run_id)
123
+ removed = gc_outputs(store)
124
+ if runs or removed:
125
+ print(f"Force: removed {len(runs)} run(s), {len(removed)} output(s)", file=sys.stderr)
126
+
127
+ try:
128
+ from cairns.tui import run_app
129
+ run_app(entry, store_path=store, label=label)
130
+ except ImportError:
131
+ # Fallback to headless mode
132
+ print(f"Running {script}:{entry_name}", file=sys.stderr)
133
+ print(f"Store: {store}/\n", file=sys.stderr)
134
+ try:
135
+ result = cairn_run(entry, store_path=store, label=label)
136
+ print(f"\nResult: {result}", file=sys.stderr)
137
+ except Exception as e:
138
+ print(f"\nError: {e}", file=sys.stderr)
139
+ sys.exit(1)
140
+
141
+
142
+ def cmd_browse(store: str) -> None:
143
+ """Interactive run browser using Textual TUI."""
144
+ try:
145
+ from cairns.tui import browse
146
+ browse(store)
147
+ except ImportError:
148
+ # Fallback: just list runs
149
+ from cairns.run import show_runs
150
+ show_runs(store)
151
+ print("Install cairn[tui] for interactive browsing: uv pip install cairn[tui]")
152
+
153
+
154
+ # ── Main ──
155
+
156
+
157
+ def main() -> None:
158
+ # Quick check: is the first arg a .py file? → run it directly
159
+ if len(sys.argv) >= 2 and not sys.argv[1].startswith("-"):
160
+ first_arg = sys.argv[1]
161
+ if first_arg.endswith(".py") or os.path.isfile(first_arg):
162
+ # cairns script.py [entry] [--store PATH] [--force]
163
+ parser = argparse.ArgumentParser(prog="cairns")
164
+ parser.add_argument("script", help="Python script to run")
165
+ parser.add_argument("entry", nargs="?", default="main", help="Entry point function")
166
+ parser.add_argument("--store", "-s", default=".cairns")
167
+ parser.add_argument("--force", "-f", action="store_true", help="Clear cache for this entry point before running")
168
+ args = parser.parse_args()
169
+ cmd_run(args.script, args.entry, args.store, force=args.force)
170
+ return
171
+
172
+ # Otherwise: subcommand mode
173
+ parser = argparse.ArgumentParser(
174
+ prog="cairns",
175
+ description="Compute graph orchestration with caching and observability",
176
+ )
177
+ parser.add_argument("--store", "-s", default=".cairns", help="Store path (default: .cairns)")
178
+ subparsers = parser.add_subparsers(dest="command")
179
+
180
+ # cairns list
181
+ subparsers.add_parser("list", help="List all runs")
182
+
183
+ # cairns show [RUN_ID]
184
+ p_show = subparsers.add_parser("show", help="Show trace (latest if no run_id)")
185
+ p_show.add_argument("run_id", nargs="?", default=None)
186
+
187
+ # cairns output PATH
188
+ p_output = subparsers.add_parser("output", help="Show a cached output")
189
+ p_output.add_argument("path")
190
+
191
+ # cairns gc
192
+ p_gc = subparsers.add_parser("gc", help="Garbage collect")
193
+ p_gc.add_argument("--before", help="Remove runs before this ISO date")
194
+ p_gc.add_argument("--keep-latest", action="store_true", default=True)
195
+ p_gc.add_argument("--no-keep-latest", dest="keep_latest", action="store_false")
196
+
197
+ args = parser.parse_args()
198
+
199
+ commands: dict[str, Any] = {
200
+ "list": cmd_list,
201
+ "show": cmd_show,
202
+ "output": cmd_output,
203
+ "gc": cmd_gc,
204
+ }
205
+
206
+ if args.command is None:
207
+ # cairns with no args → interactive browser
208
+ cmd_browse(args.store)
209
+ return
210
+
211
+ cmd = commands.get(args.command)
212
+ if cmd is None:
213
+ parser.print_help()
214
+ sys.exit(1)
215
+ cmd(args)
216
+
217
+
218
+ if __name__ == "__main__":
219
+ main()
@@ -0,0 +1,91 @@
1
+ """Cairn core primitives.
2
+
3
+ Public surface of `cairns.core` — re-exported from dedicated submodules so
4
+ external code can say `from cairns.core import step` without knowing where
5
+ each name lives.
6
+ """
7
+
8
+ from .step import (
9
+ Handle,
10
+ cached_output,
11
+ cached_tracing,
12
+ step,
13
+ trace,
14
+ )
15
+ from .runtime import (
16
+ Runtime,
17
+ Event,
18
+ InteractionSink,
19
+ MemorySink,
20
+ NullSink,
21
+ Run,
22
+ Sink,
23
+ current_run,
24
+ current_span,
25
+ default_runtime,
26
+ emit_event,
27
+ )
28
+ from .cairn import Cairn, cairn # noqa: F401
29
+ from .hash import (
30
+ compute_cairn_id,
31
+ resolve_hashable,
32
+ )
33
+ from cairns.patterns import rate_limited, replayable
34
+ from .serial import (
35
+ Serializer,
36
+ from_jsonable,
37
+ to_jsonable,
38
+ )
39
+ from .sink import CompositeSink, JSONLSink, event_to_dict
40
+ from .store import FileStore, MemoryStore, OverlayStore, Store, StoreStats
41
+ from .types import Record, SpanMetrics, StepInfo, TaskSpan, TraceRecord
42
+
43
+ __all__ = [
44
+ # decorator + Handle
45
+ "step",
46
+ "Handle",
47
+ "trace",
48
+ "cached_output",
49
+ "cached_tracing",
50
+ # cairn inspection
51
+ "Cairn",
52
+ "cairn",
53
+ # runtime
54
+ "Runtime",
55
+ "Run",
56
+ "default_runtime",
57
+ "current_run",
58
+ "current_span",
59
+ "Event",
60
+ "Sink",
61
+ "InteractionSink",
62
+ "MemorySink",
63
+ "NullSink",
64
+ "emit_event",
65
+ # hash
66
+ "compute_cairn_id",
67
+ "resolve_hashable",
68
+ # serial
69
+ "Serializer",
70
+ "to_jsonable",
71
+ "from_jsonable",
72
+ # sink
73
+ "JSONLSink",
74
+ "CompositeSink",
75
+ "event_to_dict",
76
+ # store
77
+ "Store",
78
+ "MemoryStore",
79
+ "FileStore",
80
+ "OverlayStore",
81
+ "StoreStats",
82
+ # patterns
83
+ "rate_limited",
84
+ "replayable",
85
+ # types
86
+ "StepInfo",
87
+ "TraceRecord",
88
+ "Record",
89
+ "SpanMetrics",
90
+ "TaskSpan",
91
+ ]
cairns/core/cairn.py ADDED
@@ -0,0 +1,98 @@
1
+ """The `Cairn` view + the `cairn()` accessor.
2
+
3
+ A `Cairn` is a lazy, iterable view over a Store's record stack for one
4
+ cairn_id. Newest-first iteration. Doesn't load records eagerly — yields
5
+ on demand.
6
+
7
+ Three entry points:
8
+
9
+ - `cairn()` — the cairn for the currently-executing `@step`.
10
+ - `step_fn.cairn(*args, **kwargs)` — the cairn for an arbitrary `@step`
11
+ invocation, computed without invoking. (Method on the decorated
12
+ function; defined in `step.py`.)
13
+ - `Cairn.from_store(store, cairn_id)` — explicit construction. For CLI
14
+ / scripts / advanced use, when no Run is active.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import TYPE_CHECKING, Iterator
20
+
21
+ from .runtime import current_run, current_span
22
+ from .types import Record
23
+
24
+ if TYPE_CHECKING:
25
+ from .store import Store
26
+
27
+
28
+ class Cairn:
29
+ """View over the record stack for one cairn_id."""
30
+
31
+ def __init__(self, cairn_id: str, store: "Store") -> None:
32
+ self.cairn_id = cairn_id
33
+ self._store = store
34
+
35
+ @classmethod
36
+ def from_store(cls, store: "Store", cairn_id: str) -> "Cairn":
37
+ """Explicit construction. For inspection outside an active Run."""
38
+ return cls(cairn_id, store)
39
+
40
+ def __iter__(self) -> Iterator[Record]:
41
+ return self._store.iter_records(self.cairn_id)
42
+
43
+ def __len__(self) -> int:
44
+ return sum(1 for _ in self)
45
+
46
+ def __bool__(self) -> bool:
47
+ return next(iter(self), None) is not None
48
+
49
+ def latest(
50
+ self,
51
+ *,
52
+ version: str | None = None,
53
+ body_hash: str | None = None,
54
+ include_errors: bool = False,
55
+ ) -> Record | None:
56
+ """Newest record matching the given filters.
57
+
58
+ `version` / `body_hash` constrain on the corresponding fields when set.
59
+ By default skips errored records — pass `include_errors=True` to
60
+ surface the most recent regardless.
61
+ """
62
+ for record in self:
63
+ if record.error is not None and not include_errors:
64
+ continue
65
+ if version is not None and record.version != version:
66
+ continue
67
+ if body_hash is not None and record.body_hash != body_hash:
68
+ continue
69
+ return record
70
+ return None
71
+
72
+ def at(self, record_id: str) -> Record | None:
73
+ """Pinpoint a record by its id."""
74
+ for record in self:
75
+ if record.record_id == record_id:
76
+ return record
77
+ return None
78
+
79
+
80
+ def cairn() -> Cairn:
81
+ """The cairn for the currently-executing `@step`.
82
+
83
+ Reads `current_span` for the step's identity + bound args, looks up
84
+ the cairn_id, and returns a view over the active Run's store.
85
+
86
+ Raises if no `@step` is active (i.e. called from outside a step body).
87
+ """
88
+ span = current_span.get()
89
+ if span is None:
90
+ raise RuntimeError(
91
+ "cairn() called outside a @step body — no current span"
92
+ )
93
+ if span.cairn_id is None:
94
+ raise RuntimeError(
95
+ "cairn() called before the step's cairn_id was computed — "
96
+ "this should only happen if called before _resolve_args completes"
97
+ )
98
+ return Cairn(span.cairn_id, current_run().store)
cairns/core/hash.py ADDED
@@ -0,0 +1,178 @@
1
+ """Hashing utilities for cache key computation.
2
+
3
+ Hashers live on `Runtime` instances (see `cairns.core.runtime`). The
4
+ default hashers (Path, functools.partial, Pydantic) are installed by
5
+ `Runtime.__init__`. For per-Runtime customization use
6
+ `runtime.register_hasher(...)`; for tests use `Harness(hash_funcs=...)`.
7
+
8
+ `resolve_hashable` defaults to the active Run's runtime hashers, falling
9
+ back to `default_runtime` when no Run is active (e.g. body fingerprinting
10
+ at `@step` decoration time). Pass `hash_funcs=` explicitly to override
11
+ for one call.
12
+
13
+ Limitation: registered hashers (`_hash_partial`) that recursively call
14
+ `resolve_hashable` will themselves see the active runtime's funcs (via
15
+ the same default-lookup), so transitive overrides do flow through.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import functools
21
+ import hashlib
22
+ import json
23
+ from pathlib import Path
24
+ from typing import Any, Callable, cast
25
+
26
+
27
+ def resolve_hashable(
28
+ value: Any,
29
+ _seen: dict[int, bool] | None = None,
30
+ hash_funcs: dict[type, Callable[[Any], Any]] | None = None,
31
+ ) -> Any:
32
+ """Turn any value into a canonical tree of primitives for hashing.
33
+
34
+ `hash_funcs`, if provided, fully replaces the active runtime's
35
+ hashers for this call (and its recursion). Otherwise the active
36
+ Run's runtime is consulted, falling back to `default_runtime`.
37
+
38
+ Returns a JSON-serializable structure. Unknown types raise TypeError
39
+ (fail-loud — no silent repr truncation of numpy/pandas/torch objects).
40
+ Cycles are replaced with a sentinel.
41
+ """
42
+ if _seen is None:
43
+ _seen = {}
44
+
45
+ if value is None or isinstance(value, (bool, int, float, str)):
46
+ return value
47
+
48
+ vid = id(value)
49
+ if vid in _seen:
50
+ return {"__cycle__": True}
51
+
52
+ if isinstance(value, bytes):
53
+ return {"__bytes__": value.hex()}
54
+
55
+ if isinstance(value, dict):
56
+ d = cast(dict[Any, Any], value)
57
+ _seen[vid] = True
58
+ try:
59
+ return {
60
+ "__dict__": {
61
+ str(k): resolve_hashable(d[k], _seen, hash_funcs)
62
+ for k in sorted(d, key=lambda x: str(x))
63
+ }
64
+ }
65
+ finally:
66
+ del _seen[vid]
67
+
68
+ if isinstance(value, (list, tuple)):
69
+ seq = cast(list[Any] | tuple[Any, ...], value)
70
+ tag = "__list__" if isinstance(value, list) else "__tuple__"
71
+ _seen[vid] = True
72
+ try:
73
+ return {tag: [resolve_hashable(v, _seen, hash_funcs) for v in seq]}
74
+ finally:
75
+ del _seen[vid]
76
+
77
+
78
+ if isinstance(value, (frozenset, set)):
79
+ tag = "__frozenset__" if isinstance(value, frozenset) else "__set__"
80
+ fs = cast(set[Any] | frozenset[Any], value)
81
+ _seen[vid] = True
82
+ try:
83
+ items = [resolve_hashable(v, _seen, hash_funcs) for v in fs]
84
+ items.sort(key=lambda x: json.dumps(x, sort_keys=True))
85
+ return {tag: items}
86
+ finally:
87
+ del _seen[vid]
88
+
89
+ if hash_funcs is None:
90
+ from .runtime import active_hash_funcs # noqa: PLC0415
91
+
92
+ funcs = active_hash_funcs()
93
+ else:
94
+ funcs = hash_funcs
95
+
96
+ for tp in type(value).__mro__:
97
+ if tp in funcs:
98
+ return funcs[tp](value)
99
+
100
+ raise TypeError(
101
+ f"Unhashable type for cache key: {type(value).__name__}. "
102
+ f"Register a hasher via `runtime.register_hasher(...)`."
103
+ )
104
+
105
+
106
+ def compute_cairn_id(identity: str, resolved_args: dict[str, Any]) -> str:
107
+ """Compute a cairn id: computation identity + args, excluding version."""
108
+ canonical = json.dumps(
109
+ {
110
+ "identity": identity,
111
+ "args": resolve_hashable(resolved_args),
112
+ },
113
+ sort_keys=True,
114
+ separators=(",", ":"),
115
+ )
116
+ return hashlib.sha256(canonical.encode()).hexdigest()
117
+
118
+
119
+ # ── Default type hashers ──
120
+ #
121
+ # These are installed onto `Runtime.hash_funcs` by `_install_defaults`
122
+ # in runtime.py. They're public-ish (exported as private-by-convention)
123
+ # so subclassing or extending Runtimes can reuse them.
124
+
125
+
126
+ def _hash_path(p: Path) -> Any:
127
+ # No resolve() — symlinks are often deliberate (pointing at a "current"
128
+ # artifact); resolving would invalidate on every target swap. Users
129
+ # wanting content-hashing or resolved paths can re-register.
130
+ path_str = str(p)
131
+ try:
132
+ st = p.stat()
133
+ except FileNotFoundError:
134
+ return {"__path__": {"s": path_str, "state": "missing"}}
135
+ except OSError as e:
136
+ return {"__path__": {"s": path_str, "state": "stat_error", "errno": e.errno}}
137
+ return {"__path__": {"s": path_str, "mtime_ns": st.st_mtime_ns, "size": st.st_size}}
138
+
139
+
140
+ def _hash_partial(p: "functools.partial[Any]") -> Any:
141
+ # Reuse StepInfo.from_function so body edits to p.func invalidate. That
142
+ # also respects @step's attached `.info` (including user overrides).
143
+ from .types import StepInfo
144
+
145
+ return {
146
+ "__partial__": {
147
+ "func": StepInfo.from_function(p.func).version,
148
+ "args": [resolve_hashable(a) for a in p.args],
149
+ "keywords": {k: resolve_hashable(v) for k, v in p.keywords.items()},
150
+ }
151
+ }
152
+
153
+
154
+ def _hash_pydantic(model: Any) -> Any:
155
+ # pydantic v2: model_dump(mode="json") coerces datetimes/enums/UUIDs to
156
+ # JSON primitives and recurses into nested models. Class qualname is
157
+ # included so structurally-identical models in different classes don't
158
+ # collide. Schema changes that don't affect dumped values (docstring edits,
159
+ # field reordering) don't invalidate — usually what you want.
160
+ cls_mod = type(model).__module__
161
+ cls_name = type(model).__qualname__
162
+ return {
163
+ "__pydantic__": {
164
+ "cls": f"{cls_mod}:{cls_name}",
165
+ "data": model.model_dump(mode="json"),
166
+ }
167
+ }
168
+
169
+
170
+ def install_defaults(runtime: Any) -> None:
171
+ """Install Path / functools.partial / Pydantic hashers on `runtime`."""
172
+ runtime.hash_funcs[Path] = _hash_path
173
+ runtime.hash_funcs[functools.partial] = _hash_partial
174
+ try:
175
+ from pydantic import BaseModel # noqa: PLC0415
176
+ except ImportError:
177
+ return
178
+ runtime.hash_funcs[BaseModel] = _hash_pydantic
cairns/core/lock.py ADDED
@@ -0,0 +1,65 @@
1
+ """Shared/exclusive store lock.
2
+
3
+ `.cairns/gc.lock` is the rendezvous file. `FileStore.put` takes a **shared** lock
4
+ while publishing a record (makedirs → rename); `gc_outputs` takes an **exclusive**
5
+ lock while running mark + sweep. Multiple concurrent runs can publish; a GC
6
+ request waits until in-flight publishes drain, and blocks new publishes until
7
+ sweep completes.
8
+
9
+ POSIX-only (fcntl). The design doc calls out that Windows isn't supported.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import fcntl
15
+ import os
16
+ from contextlib import contextmanager
17
+ from typing import Iterator
18
+
19
+
20
+ def _lock_path(store_path: str) -> str:
21
+ return os.path.join(store_path, "gc.lock")
22
+
23
+
24
+ def _ensure_lockfile(store_path: str) -> str:
25
+ os.makedirs(store_path, exist_ok=True)
26
+ path = _lock_path(store_path)
27
+ if not os.path.exists(path):
28
+ # Touch the file; O_EXCL avoids a rare race where two callers both
29
+ # create it. EEXIST means someone got there first — fine.
30
+ try:
31
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
32
+ os.close(fd)
33
+ except FileExistsError:
34
+ pass
35
+ return path
36
+
37
+
38
+ @contextmanager
39
+ def store_shared(store_path: str) -> Iterator[None]:
40
+ """Shared (reader) lock: held by each FileStore.put during publication."""
41
+ path = _ensure_lockfile(store_path)
42
+ fd = os.open(path, os.O_RDONLY)
43
+ try:
44
+ fcntl.flock(fd, fcntl.LOCK_SH)
45
+ try:
46
+ yield
47
+ finally:
48
+ fcntl.flock(fd, fcntl.LOCK_UN)
49
+ finally:
50
+ os.close(fd)
51
+
52
+
53
+ @contextmanager
54
+ def gc_exclusive(store_path: str) -> Iterator[None]:
55
+ """Exclusive (writer) lock: held by gc during mark + sweep."""
56
+ path = _ensure_lockfile(store_path)
57
+ fd = os.open(path, os.O_RDWR)
58
+ try:
59
+ fcntl.flock(fd, fcntl.LOCK_EX)
60
+ try:
61
+ yield
62
+ finally:
63
+ fcntl.flock(fd, fcntl.LOCK_UN)
64
+ finally:
65
+ os.close(fd)