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/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