loomcache 1.0.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.
- loom/__init__.py +49 -0
- loom/async_run.py +182 -0
- loom/cache.py +114 -0
- loom/cache_remote.py +143 -0
- loom/cli.py +92 -0
- loom/diff.py +58 -0
- loom/hashing.py +83 -0
- loom/langchain.py +133 -0
- loom/run.py +250 -0
- loom/trace.py +15 -0
- loom/tracing.py +132 -0
- loom/web.py +145 -0
- loomcache-1.0.0.dist-info/METADATA +302 -0
- loomcache-1.0.0.dist-info/RECORD +17 -0
- loomcache-1.0.0.dist-info/WHEEL +4 -0
- loomcache-1.0.0.dist-info/entry_points.txt +2 -0
- loomcache-1.0.0.dist-info/licenses/LICENSE +21 -0
loom/hashing.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Content-addressed hashing utilities for Loom.
|
|
2
|
+
|
|
3
|
+
Every cache key in Loom is derived deterministically from:
|
|
4
|
+
1. The *source code* of the step function (so editing a prompt
|
|
5
|
+
invalidates the cache automatically).
|
|
6
|
+
2. The values (or upstream node hashes) of every argument.
|
|
7
|
+
|
|
8
|
+
This mirrors how Bazel/Nix key build outputs off content instead of
|
|
9
|
+
timestamps, and is the reason Loom can safely skip re-running a step.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import inspect
|
|
15
|
+
import json
|
|
16
|
+
import pickle
|
|
17
|
+
from typing import Any, Callable, Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _stable_json(value: Any) -> Optional[str]:
|
|
21
|
+
try:
|
|
22
|
+
return json.dumps(value, sort_keys=True, default=str)
|
|
23
|
+
except (TypeError, ValueError):
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def hash_value(value: Any) -> str:
|
|
28
|
+
"""Return a stable content hash for an arbitrary Python value.
|
|
29
|
+
|
|
30
|
+
If `value` is itself the traced output of an upstream step, its
|
|
31
|
+
identity for hashing purposes IS the upstream node's hash — this is
|
|
32
|
+
what turns a chain of step calls into a real dependency graph.
|
|
33
|
+
|
|
34
|
+
Otherwise: try JSON first (fast, stable across processes and
|
|
35
|
+
machines), fall back to pickle bytes, and finally to repr() for
|
|
36
|
+
exotic objects that are neither JSON-serializable nor picklable.
|
|
37
|
+
"""
|
|
38
|
+
node = getattr(value, "_loom_node", None)
|
|
39
|
+
if node is not None:
|
|
40
|
+
return f"node:{node.node_hash}"
|
|
41
|
+
|
|
42
|
+
as_json = _stable_json(value)
|
|
43
|
+
if as_json is not None:
|
|
44
|
+
payload = f"json:{as_json}".encode()
|
|
45
|
+
else:
|
|
46
|
+
try:
|
|
47
|
+
payload = b"pickle:" + pickle.dumps(value)
|
|
48
|
+
except Exception:
|
|
49
|
+
payload = f"repr:{value!r}".encode()
|
|
50
|
+
return hashlib.sha256(payload).hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def hash_source(func: Callable) -> str:
|
|
54
|
+
"""Hash the source code of a function.
|
|
55
|
+
|
|
56
|
+
If the function body changes (e.g. you tweak a prompt template),
|
|
57
|
+
this hash changes, which changes every node hash downstream of it —
|
|
58
|
+
Loom will correctly recompute rather than serve a stale cache hit.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
src = inspect.getsource(func)
|
|
62
|
+
except (OSError, TypeError):
|
|
63
|
+
src = f"{func.__module__}.{func.__qualname__}"
|
|
64
|
+
return hashlib.sha256(src.encode()).hexdigest()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def hash_node(
|
|
68
|
+
step_name: str,
|
|
69
|
+
source_hash: str,
|
|
70
|
+
args: tuple,
|
|
71
|
+
kwargs: dict,
|
|
72
|
+
extra: str = "",
|
|
73
|
+
) -> str:
|
|
74
|
+
"""Combine step identity + source + argument hashes into one node hash."""
|
|
75
|
+
parts = [f"step:{step_name}", f"src:{source_hash}"]
|
|
76
|
+
for a in args:
|
|
77
|
+
parts.append(f"arg:{hash_value(a)}")
|
|
78
|
+
for k in sorted(kwargs):
|
|
79
|
+
parts.append(f"kw:{k}={hash_value(kwargs[k])}")
|
|
80
|
+
if extra:
|
|
81
|
+
parts.append(f"extra:{extra}")
|
|
82
|
+
payload = "|".join(parts).encode()
|
|
83
|
+
return hashlib.sha256(payload).hexdigest()
|
loom/langchain.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""LangChain adapter: cache any Runnable via Loom.
|
|
2
|
+
|
|
3
|
+
from loom.langchain import wrap_runnable
|
|
4
|
+
from langchain.chains import LLMChain
|
|
5
|
+
|
|
6
|
+
chain = LLMChain(...)
|
|
7
|
+
cached_chain = wrap_runnable(chain, name="my_chain")
|
|
8
|
+
|
|
9
|
+
with loom.Run("run") as run:
|
|
10
|
+
result = cached_chain.invoke({"input": "Hello"})
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import inspect
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any, Optional, TypeVar
|
|
19
|
+
|
|
20
|
+
from loom import current_run, default_cache, attach_node, hashing
|
|
21
|
+
from loom.run import Node
|
|
22
|
+
from loom.cache import Cache
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from langchain_core.runnables import Runnable, RunnableConfig
|
|
26
|
+
except ImportError:
|
|
27
|
+
# Stub for optional dependency
|
|
28
|
+
class Runnable: # type: ignore[no-redef]
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
RunnableConfig = Any
|
|
32
|
+
|
|
33
|
+
T = TypeVar("T", bound=Runnable)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CachedRunnable(Runnable):
|
|
37
|
+
"""A Runnable that delegates to the original and caches invoke results."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
runnable: Runnable,
|
|
42
|
+
*,
|
|
43
|
+
name: Optional[str] = None,
|
|
44
|
+
cache: Optional[Cache] = None,
|
|
45
|
+
version: Optional[str] = "",
|
|
46
|
+
source_hash: Optional[str] = None,
|
|
47
|
+
):
|
|
48
|
+
self._runnable = runnable
|
|
49
|
+
self._cache = cache or default_cache()
|
|
50
|
+
self._name = name or getattr(runnable, "name", None) or runnable.__class__.__name__
|
|
51
|
+
self._version = version or ""
|
|
52
|
+
|
|
53
|
+
if source_hash is None:
|
|
54
|
+
try:
|
|
55
|
+
src = inspect.getsource(runnable.invoke)
|
|
56
|
+
source_hash = hashlib.sha256(src.encode()).hexdigest()
|
|
57
|
+
except (OSError, TypeError):
|
|
58
|
+
source_hash = "unknown"
|
|
59
|
+
self._source_hash = source_hash
|
|
60
|
+
|
|
61
|
+
def _cache_key(self, input: Any, config: Optional[RunnableConfig] = None) -> str:
|
|
62
|
+
identity = f"langchain:{self._name}:{self._source_hash}:{self._version}"
|
|
63
|
+
input_hash = hashing.hash_value(input)
|
|
64
|
+
return hashing.hash_node(
|
|
65
|
+
step_name=self._name,
|
|
66
|
+
source_hash=identity,
|
|
67
|
+
args=(input_hash,),
|
|
68
|
+
kwargs={},
|
|
69
|
+
extra="langchain",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def invoke(self, input: Any, config: Optional[RunnableConfig] = None, **kwargs) -> Any:
|
|
73
|
+
run = current_run()
|
|
74
|
+
if run is None:
|
|
75
|
+
return self._runnable.invoke(input, config, **kwargs)
|
|
76
|
+
|
|
77
|
+
key = self._cache_key(input, config)
|
|
78
|
+
entry = self._cache.get(key)
|
|
79
|
+
|
|
80
|
+
if entry is not None:
|
|
81
|
+
output = entry.output
|
|
82
|
+
cache_hit = True
|
|
83
|
+
duration = 0.0
|
|
84
|
+
time_saved = entry.metadata.get("duration_s", 0.0)
|
|
85
|
+
else:
|
|
86
|
+
start = time.time()
|
|
87
|
+
output = self._runnable.invoke(input, config, **kwargs)
|
|
88
|
+
duration = time.time() - start
|
|
89
|
+
self._cache.put(
|
|
90
|
+
key,
|
|
91
|
+
output,
|
|
92
|
+
metadata={
|
|
93
|
+
"step_name": self._name,
|
|
94
|
+
"timestamp": time.time(),
|
|
95
|
+
"duration_s": duration,
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
cache_hit = False
|
|
99
|
+
time_saved = 0.0
|
|
100
|
+
|
|
101
|
+
node = Node(
|
|
102
|
+
node_hash=key,
|
|
103
|
+
step_name=self._name,
|
|
104
|
+
parents=[],
|
|
105
|
+
cache_hit=cache_hit,
|
|
106
|
+
duration_s=duration,
|
|
107
|
+
time_saved_s=time_saved,
|
|
108
|
+
timestamp=time.time(),
|
|
109
|
+
args_repr=repr(input),
|
|
110
|
+
kwargs_repr=repr(config),
|
|
111
|
+
output_repr=repr(output),
|
|
112
|
+
)
|
|
113
|
+
run.record(node)
|
|
114
|
+
return attach_node(output, node)
|
|
115
|
+
|
|
116
|
+
# Async and batch methods are not yet implemented (v1.0 roadmap).
|
|
117
|
+
async def ainvoke(self, input: Any, config: Optional[RunnableConfig] = None, **kwargs) -> Any:
|
|
118
|
+
raise NotImplementedError("Async caching is not yet supported for Runnables.")
|
|
119
|
+
|
|
120
|
+
def batch(self, inputs, config=None, **kwargs):
|
|
121
|
+
raise NotImplementedError("Batch caching is not yet supported.")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def wrap_runnable(
|
|
125
|
+
runnable: T,
|
|
126
|
+
*,
|
|
127
|
+
name: Optional[str] = None,
|
|
128
|
+
cache: Optional[Cache] = None,
|
|
129
|
+
version: Optional[str] = "",
|
|
130
|
+
source_hash: Optional[str] = None,
|
|
131
|
+
) -> T:
|
|
132
|
+
"""Wrap a LangChain Runnable with Loom caching."""
|
|
133
|
+
return CachedRunnable(runnable, name=name, cache=cache, version=version, source_hash=source_hash)
|
loom/run.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Core execution-tracking primitives: steps, nodes, and runs.
|
|
2
|
+
|
|
3
|
+
@loom.step
|
|
4
|
+
def plan(query: str) -> str:
|
|
5
|
+
return llm_call(f"Plan: {query}")
|
|
6
|
+
|
|
7
|
+
with loom.Run("my-pipeline") as run:
|
|
8
|
+
result = plan("hello world")
|
|
9
|
+
run.save()
|
|
10
|
+
|
|
11
|
+
Every `@loom.step` call made while a `Run` is active is hashed,
|
|
12
|
+
looked up in the cache, executed only on a miss, and recorded onto
|
|
13
|
+
`run.nodes` in order.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextvars
|
|
18
|
+
import functools
|
|
19
|
+
import json
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from dataclasses import dataclass, asdict
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable, Optional, Union
|
|
25
|
+
|
|
26
|
+
from . import hashing
|
|
27
|
+
from .cache import Cache, default_cache
|
|
28
|
+
from .tracing import attach_node, get_node, TracedBox, unwrap_recursive
|
|
29
|
+
|
|
30
|
+
_current_run: contextvars.ContextVar = contextvars.ContextVar(
|
|
31
|
+
"loom_current_run", default=None
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def current_run() -> Optional["Run"]:
|
|
36
|
+
"""The `Run` currently active via a `with loom.Run(...)` block, if any."""
|
|
37
|
+
return _current_run.get()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Node:
|
|
42
|
+
"""One recorded execution (or cache hit) of a single `@step` call."""
|
|
43
|
+
|
|
44
|
+
node_hash: str
|
|
45
|
+
step_name: str
|
|
46
|
+
parents: list
|
|
47
|
+
cache_hit: bool
|
|
48
|
+
duration_s: float
|
|
49
|
+
time_saved_s: float
|
|
50
|
+
timestamp: float
|
|
51
|
+
args_repr: str
|
|
52
|
+
kwargs_repr: str
|
|
53
|
+
output_repr: str
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict:
|
|
56
|
+
return asdict(self)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _safe_repr(value: Any, limit: int = 300) -> str:
|
|
60
|
+
try:
|
|
61
|
+
text = repr(value)
|
|
62
|
+
except Exception:
|
|
63
|
+
text = f"<unrepr-able {type(value).__name__}>"
|
|
64
|
+
return text if len(text) <= limit else text[: limit - 3] + "..."
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _unwrap(value: Any) -> Any:
|
|
68
|
+
if isinstance(value, TracedBox):
|
|
69
|
+
return value.unwrap()
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Run:
|
|
74
|
+
"""A single execution of a pipeline.
|
|
75
|
+
|
|
76
|
+
Use as a context manager. Every `@step` call made while this Run is
|
|
77
|
+
active gets recorded, in order, into `run.nodes`.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
name: str,
|
|
83
|
+
cache: Optional[Cache] = None,
|
|
84
|
+
root: Union[str, Path] = ".loom_runs",
|
|
85
|
+
):
|
|
86
|
+
self.name = name
|
|
87
|
+
self.run_id = f"{name}-{uuid.uuid4().hex[:8]}"
|
|
88
|
+
self.cache = cache or default_cache()
|
|
89
|
+
self.root = Path(root)
|
|
90
|
+
self.nodes: list = []
|
|
91
|
+
self.created_at = time.time()
|
|
92
|
+
self._token = None
|
|
93
|
+
|
|
94
|
+
# -- context manager ------------------------------------------------
|
|
95
|
+
def __enter__(self) -> "Run":
|
|
96
|
+
self._token = _current_run.set(self)
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def __exit__(self, exc_type, exc, tb):
|
|
100
|
+
_current_run.reset(self._token)
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
# -- recording --------------------------------------------------------
|
|
104
|
+
def record(self, node: Node) -> None:
|
|
105
|
+
self.nodes.append(node)
|
|
106
|
+
|
|
107
|
+
# -- persistence --------------------------------------------------------
|
|
108
|
+
def to_dict(self) -> dict:
|
|
109
|
+
return {
|
|
110
|
+
"run_id": self.run_id,
|
|
111
|
+
"name": self.name,
|
|
112
|
+
"created_at": self.created_at,
|
|
113
|
+
"nodes": [n.to_dict() for n in self.nodes],
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
def save(self, path: Optional[Union[str, Path]] = None) -> Path:
|
|
117
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
out_path = Path(path) if path else self.root / f"{self.run_id}.json"
|
|
119
|
+
out_path.write_text(json.dumps(self.to_dict(), indent=2, default=str))
|
|
120
|
+
return out_path
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def load(cls, path: Union[str, Path]) -> "Run":
|
|
124
|
+
data = json.loads(Path(path).read_text())
|
|
125
|
+
run = cls.__new__(cls)
|
|
126
|
+
run.name = data["name"]
|
|
127
|
+
run.run_id = data["run_id"]
|
|
128
|
+
run.created_at = data["created_at"]
|
|
129
|
+
run.cache = default_cache()
|
|
130
|
+
run.root = Path(path).parent
|
|
131
|
+
run.nodes = [Node(**n) for n in data["nodes"]]
|
|
132
|
+
run._token = None
|
|
133
|
+
return run
|
|
134
|
+
|
|
135
|
+
# -- introspection --------------------------------------------------------
|
|
136
|
+
def stats(self) -> dict:
|
|
137
|
+
hits = sum(1 for n in self.nodes if n.cache_hit)
|
|
138
|
+
total = len(self.nodes)
|
|
139
|
+
return {
|
|
140
|
+
"total_nodes": total,
|
|
141
|
+
"cache_hits": hits,
|
|
142
|
+
"cache_misses": total - hits,
|
|
143
|
+
"hit_rate": (hits / total) if total else 0.0,
|
|
144
|
+
"wall_time_s": round(sum(n.duration_s for n in self.nodes), 3),
|
|
145
|
+
"time_saved_s": round(sum(n.time_saved_s for n in self.nodes), 3),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
def fork(self, pipeline_fn: Callable, name: Optional[str] = None, **new_kwargs) -> "Run":
|
|
149
|
+
"""Re-run `pipeline_fn` (typically with one input changed).
|
|
150
|
+
|
|
151
|
+
Because every `@step` call is content-addressed, any step whose
|
|
152
|
+
hash is unaffected by the change is served from cache instantly;
|
|
153
|
+
only the changed step and everything downstream of it actually
|
|
154
|
+
re-executes. This mirrors how `bazel build` or `make` only
|
|
155
|
+
rebuild the targets whose inputs changed.
|
|
156
|
+
"""
|
|
157
|
+
forked = Run(name or f"{self.name}-fork", cache=self.cache, root=self.root)
|
|
158
|
+
with forked:
|
|
159
|
+
pipeline_fn(**new_kwargs)
|
|
160
|
+
return forked
|
|
161
|
+
|
|
162
|
+
def __repr__(self) -> str:
|
|
163
|
+
return f"<Run {self.run_id} nodes={len(self.nodes)}>"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def step(func: Optional[Callable] = None, *, cache: Optional[Cache] = None):
|
|
167
|
+
"""Decorator that turns a plain function into a cached, tracked step.
|
|
168
|
+
|
|
169
|
+
Every call is:
|
|
170
|
+
1. Hashed from (source code of the function + argument values, or
|
|
171
|
+
upstream node hashes for arguments that are themselves the
|
|
172
|
+
traced output of another step).
|
|
173
|
+
2. Looked up in the cache — on a hit, the cached output is
|
|
174
|
+
returned immediately with zero re-execution.
|
|
175
|
+
3. On a miss, executed normally, cached, and recorded.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def decorator(f: Callable) -> Callable:
|
|
179
|
+
source_hash = hashing.hash_source(f)
|
|
180
|
+
|
|
181
|
+
@functools.wraps(f)
|
|
182
|
+
def wrapper(*args, **kwargs):
|
|
183
|
+
run = current_run()
|
|
184
|
+
active_cache = cache or (run.cache if run is not None else default_cache())
|
|
185
|
+
|
|
186
|
+
node_hash = hashing.hash_node(f.__qualname__, source_hash, args, kwargs)
|
|
187
|
+
parents = [
|
|
188
|
+
n.node_hash
|
|
189
|
+
for n in (get_node(a) for a in list(args) + list(kwargs.values()))
|
|
190
|
+
if n is not None
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
entry = active_cache.get(node_hash)
|
|
194
|
+
if entry is not None:
|
|
195
|
+
output = entry.output # already clean (unwrapped)
|
|
196
|
+
cache_hit = True
|
|
197
|
+
duration = 0.0
|
|
198
|
+
time_saved = float(entry.metadata.get("duration_s", 0.0))
|
|
199
|
+
clean_output = output
|
|
200
|
+
else:
|
|
201
|
+
# Unwrap arguments before calling the function
|
|
202
|
+
call_args = tuple(_unwrap(a) for a in args)
|
|
203
|
+
call_kwargs = {k: _unwrap(v) for k, v in kwargs.items()}
|
|
204
|
+
t0 = time.time()
|
|
205
|
+
raw_output = f(*call_args, **call_kwargs)
|
|
206
|
+
duration = time.time() - t0
|
|
207
|
+
|
|
208
|
+
# Recursively unwrap any traced objects to get a clean, pickleable value
|
|
209
|
+
clean_output = unwrap_recursive(raw_output)
|
|
210
|
+
|
|
211
|
+
active_cache.put(
|
|
212
|
+
node_hash,
|
|
213
|
+
clean_output,
|
|
214
|
+
metadata={
|
|
215
|
+
"step_name": f.__qualname__,
|
|
216
|
+
"timestamp": time.time(),
|
|
217
|
+
"duration_s": duration,
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
cache_hit = False
|
|
221
|
+
time_saved = 0.0
|
|
222
|
+
|
|
223
|
+
# Create the Node object (for both cache hit and miss)
|
|
224
|
+
node = Node(
|
|
225
|
+
node_hash=node_hash,
|
|
226
|
+
step_name=f.__qualname__,
|
|
227
|
+
parents=parents,
|
|
228
|
+
cache_hit=cache_hit,
|
|
229
|
+
duration_s=duration,
|
|
230
|
+
time_saved_s=time_saved,
|
|
231
|
+
timestamp=time.time(),
|
|
232
|
+
args_repr=_safe_repr(args),
|
|
233
|
+
kwargs_repr=_safe_repr(kwargs),
|
|
234
|
+
output_repr=_safe_repr(clean_output),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if run is not None:
|
|
238
|
+
run.record(node)
|
|
239
|
+
|
|
240
|
+
# Attach the node to the clean output and return traced version
|
|
241
|
+
return attach_node(clean_output, node)
|
|
242
|
+
|
|
243
|
+
wrapper._loom_step = True # type: ignore[attr-defined]
|
|
244
|
+
wrapper._loom_source_hash = source_hash # type: ignore[attr-defined]
|
|
245
|
+
wrapper.__wrapped_func__ = f # type: ignore[attr-defined]
|
|
246
|
+
return wrapper
|
|
247
|
+
|
|
248
|
+
if func is not None:
|
|
249
|
+
return decorator(func)
|
|
250
|
+
return decorator
|
loom/trace.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Alias module: `from loom import trace` then use `trace.step`, `trace.Run`.
|
|
2
|
+
|
|
3
|
+
Purely a namespacing convenience some people prefer stylistically —
|
|
4
|
+
identical in behavior to importing `step` / `Run` from `loom` directly.
|
|
5
|
+
|
|
6
|
+
from loom import trace
|
|
7
|
+
|
|
8
|
+
@trace.step
|
|
9
|
+
def plan(query: str) -> str:
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
with trace.Run("my-pipeline") as run:
|
|
13
|
+
...
|
|
14
|
+
"""
|
|
15
|
+
from .run import Node, Run, current_run, step # noqa: F401
|
loom/tracing.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Transparent value wrapping so Loom can track data lineage.
|
|
2
|
+
|
|
3
|
+
When a @step function returns a value, Loom attaches an invisible
|
|
4
|
+
`_loom_node` marker to it. If that value is later passed into another
|
|
5
|
+
@step call, Loom sees the marker and knows there is a dependency edge
|
|
6
|
+
between the two nodes — without requiring the user to declare any
|
|
7
|
+
dependencies manually. This is what lets a plain chain of Python
|
|
8
|
+
function calls become a real, hashable DAG.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
_TRACED_SUBCLASS_CACHE: dict = {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _traced_subclass(base: type) -> type:
|
|
18
|
+
if base in _TRACED_SUBCLASS_CACHE:
|
|
19
|
+
return _TRACED_SUBCLASS_CACHE[base]
|
|
20
|
+
|
|
21
|
+
class _Traced(base): # type: ignore[misc,valid-type]
|
|
22
|
+
__slots__ = ("_loom_node",)
|
|
23
|
+
|
|
24
|
+
_Traced.__name__ = f"Traced{base.__name__.capitalize()}"
|
|
25
|
+
_TRACED_SUBCLASS_CACHE[base] = _Traced
|
|
26
|
+
return _Traced
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TracedBox:
|
|
30
|
+
"""Fallback wrapper for values that can't be subclassed or tagged
|
|
31
|
+
directly (e.g. `bool`, or some C-extension objects).
|
|
32
|
+
|
|
33
|
+
Behaves like the wrapped value for repr/equality/truthiness/hash,
|
|
34
|
+
and transparently forwards attribute access, so downstream code can
|
|
35
|
+
usually treat it exactly like the original object. Call `.unwrap()`
|
|
36
|
+
to get the raw value back explicitly.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
__slots__ = ("_loom_value", "_loom_node")
|
|
40
|
+
|
|
41
|
+
def __init__(self, value: Any, node: Any):
|
|
42
|
+
object.__setattr__(self, "_loom_value", value)
|
|
43
|
+
object.__setattr__(self, "_loom_node", node)
|
|
44
|
+
|
|
45
|
+
def __getattr__(self, name):
|
|
46
|
+
return getattr(object.__getattribute__(self, "_loom_value"), name)
|
|
47
|
+
|
|
48
|
+
def __repr__(self):
|
|
49
|
+
return repr(object.__getattribute__(self, "_loom_value"))
|
|
50
|
+
|
|
51
|
+
def __str__(self):
|
|
52
|
+
return str(object.__getattribute__(self, "_loom_value"))
|
|
53
|
+
|
|
54
|
+
def __eq__(self, other):
|
|
55
|
+
return object.__getattribute__(self, "_loom_value") == other
|
|
56
|
+
|
|
57
|
+
def __hash__(self):
|
|
58
|
+
return hash(object.__getattribute__(self, "_loom_value"))
|
|
59
|
+
|
|
60
|
+
def __bool__(self):
|
|
61
|
+
return bool(object.__getattribute__(self, "_loom_value"))
|
|
62
|
+
|
|
63
|
+
def unwrap(self):
|
|
64
|
+
return object.__getattribute__(self, "_loom_value")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# CPython allows adding `__slots__` to subclasses of `str`, `tuple`, and
|
|
68
|
+
# `frozenset` (their C layout has room for it), but NOT to subclasses of
|
|
69
|
+
# `int` or `float` (fixed-size numeric layout, no room for extra slots) --
|
|
70
|
+
# those go through `TracedBox` instead, same as `bool`.
|
|
71
|
+
_DIRECTLY_TAGGABLE_IMMUTABLES = (str, tuple, frozenset)
|
|
72
|
+
_DIRECTLY_TAGGABLE_MUTABLES = (list, dict, set)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def attach_node(value: Any, node: Any) -> Any:
|
|
76
|
+
"""Attach a lineage marker to `value`, returning a traced version of it.
|
|
77
|
+
|
|
78
|
+
Supports directly: str, tuple, frozenset, list, dict, set, and any
|
|
79
|
+
plain object with a `__dict__`. Everything else (notably `int`,
|
|
80
|
+
`float`, and `bool`, whose fixed C layout doesn't allow extra
|
|
81
|
+
attributes) falls back to the transparent `TracedBox` wrapper.
|
|
82
|
+
"""
|
|
83
|
+
if getattr(value, "_loom_node", None) is not None:
|
|
84
|
+
return value # already traced (e.g. a step just returns its input)
|
|
85
|
+
|
|
86
|
+
cls = type(value)
|
|
87
|
+
|
|
88
|
+
if cls in _DIRECTLY_TAGGABLE_IMMUTABLES or cls in _DIRECTLY_TAGGABLE_MUTABLES:
|
|
89
|
+
traced_cls = _traced_subclass(cls)
|
|
90
|
+
obj = traced_cls(value)
|
|
91
|
+
object.__setattr__(obj, "_loom_node", node)
|
|
92
|
+
return obj
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
object.__setattr__(value, "_loom_node", node)
|
|
96
|
+
return value
|
|
97
|
+
except (AttributeError, TypeError):
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
return TracedBox(value, node)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_node(value: Any):
|
|
104
|
+
return getattr(value, "_loom_node", None)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def unwrap(value: Any) -> Any:
|
|
108
|
+
if isinstance(value, TracedBox):
|
|
109
|
+
return value.unwrap()
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def unwrap_recursive(value: Any) -> Any:
|
|
114
|
+
"""Recursively remove all Loom tracing from a value, returning a plain Python object."""
|
|
115
|
+
if hasattr(value, "_loom_node"):
|
|
116
|
+
# It's a traced object. Convert to its base type.
|
|
117
|
+
if isinstance(value, TracedBox):
|
|
118
|
+
return unwrap_recursive(value.unwrap())
|
|
119
|
+
else:
|
|
120
|
+
# Subclass of built‑in (e.g., TracedStr). Get the base class and convert.
|
|
121
|
+
base = value.__class__.__base__
|
|
122
|
+
try:
|
|
123
|
+
return unwrap_recursive(base(value))
|
|
124
|
+
except Exception:
|
|
125
|
+
# Fallback – return the value as‑is (might still break pickle)
|
|
126
|
+
return value
|
|
127
|
+
elif isinstance(value, dict):
|
|
128
|
+
return {unwrap_recursive(k): unwrap_recursive(v) for k, v in value.items()}
|
|
129
|
+
elif isinstance(value, (list, tuple, set, frozenset)):
|
|
130
|
+
return type(value)(unwrap_recursive(v) for v in value)
|
|
131
|
+
else:
|
|
132
|
+
return value
|