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 +50 -0
- cairns/cli/__init__.py +219 -0
- cairns/core/__init__.py +91 -0
- cairns/core/cairn.py +98 -0
- cairns/core/hash.py +178 -0
- cairns/core/lock.py +65 -0
- cairns/core/runtime.py +376 -0
- cairns/core/serial.py +144 -0
- cairns/core/sink.py +80 -0
- cairns/core/step.py +720 -0
- cairns/core/store.py +621 -0
- cairns/core/types.py +340 -0
- cairns/ext/__init__.py +0 -0
- cairns/ext/claude.py +244 -0
- cairns/interaction/__init__.py +250 -0
- cairns/patterns/__init__.py +58 -0
- cairns/patterns/semver.py +55 -0
- cairns/py.typed +0 -0
- cairns/run/__init__.py +271 -0
- cairns/run/gc.py +266 -0
- cairns/run/show.py +306 -0
- cairns/run/spans.py +183 -0
- cairns/testing.py +144 -0
- cairns/tui/__init__.py +31 -0
- cairns/tui/app.py +738 -0
- cairns/tui/messages.py +91 -0
- cairns/tui/render.py +43 -0
- cairns/tui/sinks.py +110 -0
- cairns/tui/widgets.py +144 -0
- cairns-0.2.0.dist-info/METADATA +178 -0
- cairns-0.2.0.dist-info/RECORD +34 -0
- cairns-0.2.0.dist-info/WHEEL +4 -0
- cairns-0.2.0.dist-info/entry_points.txt +2 -0
- cairns-0.2.0.dist-info/licenses/LICENSE +21 -0
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()
|
cairns/core/__init__.py
ADDED
|
@@ -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)
|