rubedo 0.1.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.
rubedo/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """
2
+ Rubedo: A local-first batch processing engine.
3
+
4
+ This package provides a framework for defining DAG pipelines over collections of
5
+ coordinates with content-addressed caching, durable run history, and surgical invalidation.
6
+ """
7
+ from .spec import (
8
+ step,
9
+ source,
10
+ pipeline,
11
+ describe,
12
+ PipelineSpec,
13
+ StepSpec,
14
+ PipelineBuilder,
15
+ )
16
+ from .models import Filtered, ProcessResult, RunSummary
17
+ from .selection import Selection
18
+ from .sources import Source, SourceItem, FolderSource, CsvSource
19
+ from .invalidation import invalidate
20
+ from .runner import plan, run, RunPlan
21
+ from .progress import TerminalProgress
22
+
23
+ __all__ = [
24
+ "invalidate",
25
+ "Filtered",
26
+ "ProcessResult",
27
+ "RunSummary",
28
+ "Selection",
29
+ "Source",
30
+ "SourceItem",
31
+ "FolderSource",
32
+ "CsvSource",
33
+ "step",
34
+ "source",
35
+ "pipeline",
36
+ "describe",
37
+ "PipelineSpec",
38
+ "StepSpec",
39
+ "PipelineBuilder",
40
+ "run",
41
+ "plan",
42
+ "RunPlan",
43
+ "TerminalProgress",
44
+ ]
rubedo/cli.py ADDED
@@ -0,0 +1,143 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from .db import get_session
8
+ from .queries import get_recent_runs, get_run_summary, get_run_failures
9
+ from .invalidation import invalidate
10
+ from .selection import Selection
11
+
12
+ console = Console()
13
+
14
+ def cmd_ls(args):
15
+ with get_session() as session:
16
+ runs = get_recent_runs(session, limit=args.limit)
17
+
18
+ if not runs:
19
+ console.print("No runs found.")
20
+ return
21
+
22
+ table = Table(show_header=True, header_style="bold magenta")
23
+ table.add_column("ID", style="dim")
24
+ table.add_column("Pipeline")
25
+ table.add_column("Status")
26
+ table.add_column("Created / Reused")
27
+ table.add_column("Failed")
28
+ table.add_column("Started At")
29
+
30
+ for r in runs:
31
+ pipeline = r.pipeline_id or "(none)"
32
+ stats = f"{r.created_count} / {r.reused_count}"
33
+ table.add_row(
34
+ r.id,
35
+ pipeline,
36
+ r.status,
37
+ stats,
38
+ str(r.failed_count),
39
+ r.started_at
40
+ )
41
+ console.print(table)
42
+
43
+
44
+ def cmd_show(args):
45
+ with get_session() as session:
46
+ run = get_run_summary(session, args.run_id)
47
+ if not run:
48
+ console.print(f"[red]Run {args.run_id} not found.[/red]")
49
+ sys.exit(1)
50
+
51
+ if args.failed:
52
+ failures = get_run_failures(session, args.run_id)
53
+ if args.json:
54
+ print(json.dumps(failures, indent=2))
55
+ return
56
+
57
+ if not failures:
58
+ console.print("No failures recorded for this run.")
59
+ return
60
+
61
+ table = Table(title=f"Failures for Run {args.run_id}", show_header=True)
62
+ table.add_column("Step")
63
+ table.add_column("Coordinate")
64
+ table.add_column("Error Type")
65
+ table.add_column("Message")
66
+
67
+ for f in failures:
68
+ table.add_row(
69
+ str(f["step_name"]),
70
+ str(f["coordinate"]),
71
+ str(f["error_type"]),
72
+ str(f["error_message"])
73
+ )
74
+ console.print(table)
75
+ return
76
+
77
+ if args.json:
78
+ print(run.model_dump_json(indent=2))
79
+ return
80
+
81
+ console.print(f"[bold]Run ID:[/bold] {run.id}")
82
+ console.print(f"[bold]Pipeline:[/bold] {run.pipeline_id or '(none)'}")
83
+ console.print(f"[bold]Status:[/bold] {run.status}")
84
+ console.print(f"[bold]Started At:[/bold] {run.started_at}")
85
+ console.print(f"[bold]Finished At:[/bold] {run.finished_at or 'N/A'}")
86
+ console.print(f"[bold]Summary:[/bold] Created: {run.created_count}, Reused: {run.reused_count}, Failed: {run.failed_count}, Blocked: {run.blocked_count}, Filtered: {run.filtered_count}")
87
+
88
+ if run.by_step:
89
+ table = Table(title="Step Outcomes", show_header=True)
90
+ table.add_column("Step")
91
+ table.add_column("Created")
92
+ table.add_column("Reused")
93
+ table.add_column("Failed")
94
+ table.add_column("Blocked")
95
+ table.add_column("Filtered")
96
+ for step_name, counts in run.by_step.items():
97
+ table.add_row(
98
+ step_name,
99
+ str(counts.get("created", 0)),
100
+ str(counts.get("reused", 0)),
101
+ str(counts.get("failed", 0)),
102
+ str(counts.get("blocked", 0)),
103
+ str(counts.get("filtered", 0))
104
+ )
105
+ console.print(table)
106
+
107
+
108
+ def cmd_invalidate(args):
109
+ try:
110
+ selection = Selection.parse(args.selection)
111
+ except Exception as e:
112
+ console.print(f"[red]Error parsing selection:[/red] {e}")
113
+ sys.exit(1)
114
+
115
+ result = invalidate(selection, args.reason)
116
+ console.print(f"Invalidated [bold green]{result['invalidated_count']}[/bold green] materializations.")
117
+ console.print(f"New Run ID recorded for invalidation: [cyan]{result['run_id']}[/cyan]")
118
+
119
+
120
+ def main():
121
+ parser = argparse.ArgumentParser(description="Rubedo Read-Only Ops CLI")
122
+ subparsers = parser.add_subparsers(dest="command", required=True)
123
+
124
+ parser_ls = subparsers.add_parser("ls", help="List recent runs")
125
+ parser_ls.add_argument("--limit", type=int, default=50, help="Number of runs to show")
126
+ parser_ls.set_defaults(func=cmd_ls)
127
+
128
+ parser_show = subparsers.add_parser("show", help="Show details for a specific run")
129
+ parser_show.add_argument("run_id", help="The Run ID to show")
130
+ parser_show.add_argument("--json", action="store_true", help="Output as JSON")
131
+ parser_show.add_argument("--failed", action="store_true", help="Show failure details")
132
+ parser_show.set_defaults(func=cmd_show)
133
+
134
+ parser_inv = subparsers.add_parser("invalidate", help="Invalidate materializations by selection query")
135
+ parser_inv.add_argument("selection", help="Selection query (e.g., 'pipeline:my-pipe step:extract')")
136
+ parser_inv.add_argument("--reason", required=True, help="Reason for invalidation")
137
+ parser_inv.set_defaults(func=cmd_invalidate)
138
+
139
+ args = parser.parse_args()
140
+ args.func(args)
141
+
142
+ if __name__ == "__main__":
143
+ main()
rubedo/db.py ADDED
@@ -0,0 +1,76 @@
1
+ """
2
+ Database initialization and session management.
3
+
4
+ Provides utilities for setting up the SQLite database, ensuring the directory
5
+ is gitignored, configuring WAL mode for concurrency, and providing sessions.
6
+ """
7
+ import os
8
+ from .util import _ensure_gitignore
9
+ from sqlalchemy import create_engine
10
+ from sqlalchemy.orm import sessionmaker, Session
11
+ from typing import Optional, Any
12
+ from .models import Base
13
+
14
+ engine: Any = None
15
+ SessionLocal: Any = None
16
+
17
+
18
+ def init_db(db_path: Optional[str] = None):
19
+ """
20
+ Initialize the database engine and create tables.
21
+
22
+ Args:
23
+ db_path (str, optional): The database URL or file path. If None, uses
24
+ RUBEDO_DB_PATH, or RUBEDO_HOME/rubedo.sqlite, or the default
25
+ '.rubedo/rubedo.sqlite' — in that precedence order.
26
+ """
27
+ global engine, SessionLocal
28
+ if engine is not None:
29
+ try:
30
+ engine.dispose()
31
+ except Exception:
32
+ pass
33
+
34
+ if db_path is None:
35
+ db_path = os.environ.get("RUBEDO_DB_PATH") or os.path.join(
36
+ os.environ.get("RUBEDO_HOME", ".rubedo"), "rubedo.sqlite"
37
+ )
38
+ dir_path = (
39
+ db_path.replace("sqlite:///", "")
40
+ if db_path.startswith("sqlite:///")
41
+ else db_path
42
+ )
43
+
44
+ db_dir = os.path.dirname(dir_path)
45
+ if db_dir:
46
+ os.makedirs(db_dir, exist_ok=True)
47
+ _ensure_gitignore(db_dir)
48
+ if db_path.startswith("sqlite:///"):
49
+ engine_url = db_path
50
+ else:
51
+ engine_url = f"sqlite:///{db_path}"
52
+ engine = create_engine(engine_url)
53
+
54
+ if engine_url.startswith("sqlite"):
55
+ from sqlalchemy import event
56
+ @event.listens_for(engine, "connect")
57
+ def set_sqlite_pragma(dbapi_connection, connection_record):
58
+ cursor = dbapi_connection.cursor()
59
+ cursor.execute("PRAGMA journal_mode=WAL")
60
+ cursor.execute("PRAGMA busy_timeout=5000")
61
+ cursor.close()
62
+
63
+ Base.metadata.create_all(bind=engine)
64
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
65
+
66
+
67
+ def get_session() -> Session:
68
+ """
69
+ Get a new SQLAlchemy session, initializing the DB if necessary.
70
+
71
+ Returns:
72
+ Session: A new database session.
73
+ """
74
+ if SessionLocal is None:
75
+ init_db()
76
+ return SessionLocal()
rubedo/execution.py ADDED
@@ -0,0 +1,397 @@
1
+ """Execute phase: running step functions.
2
+
3
+ No database access — inputs come in as refs, results go out as
4
+ ExecutionOutcome values for the ledger to persist.
5
+ """
6
+
7
+ import concurrent.futures
8
+ import threading
9
+ import time
10
+ import traceback
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
13
+
14
+ import loky
15
+
16
+
17
+ from .hashing import hash_json, hash_bytes
18
+ from .models import Filtered, ProcessResult
19
+ from .planning import (
20
+ EphemeralRef,
21
+ MatRef,
22
+ StepDecision,
23
+ _build_step_params,
24
+ _step_accepts_params,
25
+ expand_anchor_address,
26
+ expand_child_coord,
27
+ expand_child_identity,
28
+ )
29
+ from .spec import StepSpec
30
+ from .sources import Source
31
+ from .store import read_materialization_output
32
+
33
+
34
+ class _RateLimiter:
35
+ """Paces calls evenly across a step's worker threads."""
36
+
37
+ def __init__(self, count: int, period_seconds: float):
38
+ """
39
+ Initialize the rate limiter.
40
+
41
+ Args:
42
+ count (int): Number of allowed calls per period.
43
+ period_seconds (float): Time period in seconds.
44
+ """
45
+ self.min_interval = period_seconds / count
46
+ self._lock = threading.Lock()
47
+ self._next_free = 0.0
48
+
49
+ def acquire(self):
50
+ """Wait until it is safe to proceed according to the rate limit."""
51
+ with self._lock:
52
+ now = time.monotonic()
53
+ wait = self._next_free - now
54
+ self._next_free = max(now, self._next_free) + self.min_interval
55
+ if wait > 0:
56
+ time.sleep(wait)
57
+
58
+
59
+ class _RunMemo:
60
+ """Per-run memo so an ephemeral step runs at most once per coordinate.
61
+
62
+ Reentrant lock: chained skip_cache steps resolve recursively on the
63
+ same worker thread. Exceptions are memoized too, so every consumer of
64
+ a failed util sees the same failure.
65
+ """
66
+
67
+ def __init__(self):
68
+ """Initialize the run memoizer with a per-key locking scheme."""
69
+ self._lock = threading.Lock()
70
+ self._values: Dict[Tuple[str, str], Tuple[str, Any]] = {}
71
+
72
+ def compute(self, key: Tuple[str, str], producer: Callable[[], Any]) -> Any:
73
+ """
74
+ Compute or retrieve a memoized value for the given key.
75
+
76
+ Args:
77
+ key: (step_name, coordinate) — see _compute_ephemeral's call site.
78
+ producer: A zero-argument function that produces the value.
79
+
80
+ Returns:
81
+ Any: The produced or cached value.
82
+ """
83
+ with self._lock:
84
+ state = self._values.get(key)
85
+ if state is None:
86
+ event = threading.Event()
87
+ self._values[key] = ("computing", event)
88
+ elif state[0] == "done":
89
+ kind, value = state[1]
90
+ if kind == "err":
91
+ raise value
92
+ return value
93
+ else:
94
+ event = state[1]
95
+
96
+ if state is not None:
97
+ event.wait()
98
+ kind, value = self._values[key][1]
99
+ if kind == "err":
100
+ raise value
101
+ return value
102
+
103
+ try:
104
+ val = producer()
105
+ res = ("ok", val)
106
+ except Exception as e:
107
+ res = ("err", e)
108
+
109
+ with self._lock:
110
+ self._values[key] = ("done", res)
111
+ event.set()
112
+
113
+ kind, value = res
114
+ if kind == "err":
115
+ raise value
116
+ return value
117
+
118
+
119
+ @dataclass
120
+ class ExecutionOutcome:
121
+ """Represents the final result of attempting to execute a step for a coordinate."""
122
+ decision: StepDecision
123
+ success: bool
124
+ result: Any = None
125
+ error_trace: Optional[str] = None
126
+ attempts: int = 1
127
+ attempt_errors: List[str] = field(default_factory=list)
128
+ # An expand step's cache anchor (the full yielded list, addressed by the
129
+ # parent): stored so a re-run can skip the fn, but it is not a lane — no
130
+ # status, count, edge, or coord_step_mats entry.
131
+ is_anchor: bool = False
132
+
133
+
134
+ def _resolve_parent_value(
135
+ ref, step_sources: Dict[str, Source], params: Optional[dict], memo: _RunMemo
136
+ ):
137
+ """
138
+ Resolve the output value of a parent step, computing it lazily if ephemeral.
139
+
140
+ Args:
141
+ ref: The reference to the parent output (MatRef or EphemeralRef).
142
+ step_sources: step name -> the Source it reads (root/skip_cache roots).
143
+ params (Optional[dict]): Run parameters.
144
+ memo (_RunMemo): The run memoizer for ephemeral steps.
145
+
146
+ Returns:
147
+ Any: The resolved output value.
148
+ """
149
+ if isinstance(ref, EphemeralRef):
150
+ return _compute_ephemeral(ref, step_sources, params, memo)
151
+ return read_materialization_output(ref)
152
+
153
+
154
+ def _compute_ephemeral(
155
+ ref: EphemeralRef, step_sources: Dict[str, Source], params: Optional[dict],
156
+ memo: _RunMemo,
157
+ ):
158
+ """Lazily compute a skip_cache step's value, at most once per run."""
159
+
160
+ def produce():
161
+ step = ref.step
162
+ if not step.depends_on:
163
+ args = [step_sources[step.name].load(ref.item)]
164
+ kwargs = {}
165
+ else:
166
+ args = []
167
+ kwargs = {
168
+ dep: _resolve_parent_value(
169
+ ref.parent_refs[dep], step_sources, params, memo
170
+ )
171
+ for dep in step.depends_on
172
+ }
173
+ if _step_accepts_params(step):
174
+ kwargs["params"] = _build_step_params(step, params)
175
+ result = step.fn(*args, **kwargs)
176
+ if isinstance(result, Filtered):
177
+ raise RuntimeError(
178
+ f"skip_cache step '{step.name}' returned Filtered: filtering "
179
+ "is a cacheable decision, so filter steps must be materialized"
180
+ )
181
+ # Consumers of materialized steps receive the unwrapped value;
182
+ # keep the contract identical (minus the serialization round-trip)
183
+ if isinstance(result, ProcessResult):
184
+ result = result.value
185
+ return result
186
+
187
+ return memo.compute((ref.step.name, ref.item.coordinate), produce)
188
+
189
+
190
+ def _materialized_ancestors(parent_refs: Dict[str, Any]) -> Dict[int, MatRef]:
191
+ """Nearest materialized ancestors, skipping through ephemeral hops."""
192
+ out: Dict[int, MatRef] = {}
193
+ for ref in parent_refs.values():
194
+ if isinstance(ref, EphemeralRef):
195
+ out.update(_materialized_ancestors(ref.parent_refs))
196
+ else:
197
+ out[ref.id] = ref
198
+ return out
199
+
200
+
201
+ def _validate_output(step: StepSpec, value: Any) -> None:
202
+ """Run data quality assertions on a step's output."""
203
+ if step.output_model is not None:
204
+ step.output_model.model_validate(value)
205
+ if step.assertions:
206
+ for assertion in step.assertions:
207
+ assertion(value)
208
+
209
+
210
+ def _execute_step(
211
+ step: StepSpec,
212
+ decisions: List[StepDecision],
213
+ step_sources: Dict[str, Source],
214
+ params: Optional[dict],
215
+ accepts_params: bool,
216
+ workers: Optional[int],
217
+ memo: _RunMemo,
218
+ ) -> Iterator[ExecutionOutcome]:
219
+ """Run the step function for each execute decision; yield as completed.
220
+
221
+ Honors the step's rate limit (shared across workers, retries included)
222
+ and retry policy (only exceptions matching retry_on are retried).
223
+ `step_sources` maps a step name to the Source it reads (root/skip_cache
224
+ roots), so each root loads from its own source in a multi-source pipeline.
225
+ """
226
+ limiter = _RateLimiter(*step.rate_limit) if step.rate_limit else None
227
+ params_hash = hash_json(params or {})
228
+
229
+ def call(decision: StepDecision, pool: Optional[Any] = None):
230
+ # Root steps get the source payload positionally; dependent steps
231
+ # get parent outputs by parameter name. Either kind may declare
232
+ # `params`. A *root expand* is itself a source — it reads no payload.
233
+ if not step.depends_on:
234
+ if step.shape == "expand":
235
+ args = []
236
+ else:
237
+ assert decision.item is not None
238
+ args = [step_sources[step.name].load(decision.item)]
239
+ kwargs = {}
240
+ elif step.shape == "reduce":
241
+ args = []
242
+ kwargs = {
243
+ dep: {
244
+ lane: _resolve_parent_value(
245
+ ref, step_sources, params, memo
246
+ )
247
+ for lane, ref in decision.parent_mats[dep].items()
248
+ }
249
+ for dep in step.depends_on
250
+ }
251
+ else:
252
+ args = []
253
+ kwargs = {
254
+ dep: _resolve_parent_value(
255
+ decision.parent_mats[dep], step_sources, params, memo
256
+ )
257
+ for dep in step.depends_on
258
+ }
259
+ if accepts_params:
260
+ kwargs["params"] = _build_step_params(step, params)
261
+
262
+ if pool is not None:
263
+ return pool.submit(step.fn, *args, **kwargs).result()
264
+ return step.fn(*args, **kwargs)
265
+
266
+ def _expand_outcomes(
267
+ decision: StepDecision, values: List[Any], attempt: int, attempt_errors: List[str]
268
+ ) -> List[ExecutionOutcome]:
269
+ """Fan one parent lane's yielded payloads into content-addressed lanes.
270
+
271
+ A dependent expand emits the cache anchor first (the child content
272
+ hashes, addressed by the parent so a re-run can skip the fn). A *root*
273
+ expand (a source) has no parent to cache against, so it writes no
274
+ anchor and always re-runs. Then one child per distinct payload — each a
275
+ content-addressed lane `row-<hash>`; identical payloads collapse.
276
+ """
277
+ seen: set = set()
278
+ children: List[tuple] = [] # (child_hash, value)
279
+ for value in values:
280
+ _validate_output(step, value)
281
+ if isinstance(value, bytes):
282
+ child_hash = "b:" + hash_bytes(value)
283
+ else:
284
+ child_hash = hash_json(value)
285
+ if child_hash in seen:
286
+ continue # identical payload — one lane
287
+ seen.add(child_hash)
288
+ children.append((child_hash, value))
289
+
290
+ outcomes: List[ExecutionOutcome] = []
291
+ if step.depends_on:
292
+ # Anchor: the child hashes, addressed by the parent. Not a lane.
293
+ parent_hash = decision.parent_mats[step.depends_on[0]].output_content_hash
294
+ anchor = StepDecision(
295
+ coordinate=decision.coordinate,
296
+ action="execute",
297
+ input_hash=parent_hash,
298
+ output_address=expand_anchor_address(
299
+ step, parent_hash, params_hash, accepts_params
300
+ ),
301
+ parent_mats=decision.parent_mats,
302
+ )
303
+ outcomes.append(
304
+ ExecutionOutcome(
305
+ anchor, True, result=[h for h, _ in children],
306
+ attempts=attempt, attempt_errors=attempt_errors, is_anchor=True,
307
+ )
308
+ )
309
+
310
+ for child_hash, value in children:
311
+ input_hash, child_address = expand_child_identity(
312
+ step, child_hash, params_hash, accepts_params
313
+ )
314
+ child = StepDecision(
315
+ coordinate=expand_child_coord(child_hash),
316
+ action="execute",
317
+ input_hash=input_hash,
318
+ output_address=child_address,
319
+ parent_mats=decision.parent_mats,
320
+ )
321
+ outcomes.append(
322
+ ExecutionOutcome(
323
+ child, True, result=value, attempts=attempt,
324
+ attempt_errors=attempt_errors,
325
+ )
326
+ )
327
+ return outcomes
328
+
329
+ def process( # type: ignore
330
+ decision: StepDecision, pool: Optional[Any] = None
331
+ ) -> List[ExecutionOutcome]:
332
+ attempt_errors: List[str] = []
333
+ delay = step.retry_delay
334
+ for attempt in range(1, step.retries + 2):
335
+ if limiter:
336
+ limiter.acquire()
337
+ try:
338
+ result = call(decision, pool)
339
+ if step.shape == "expand":
340
+ # Consume the iterable inside the try so a failing
341
+ # generator body is caught and retried like any other.
342
+ return _expand_outcomes(
343
+ decision, list(result), attempt, attempt_errors
344
+ )
345
+ _validate_output(step, result)
346
+ return [
347
+ ExecutionOutcome(
348
+ decision,
349
+ True,
350
+ result=result,
351
+ attempts=attempt,
352
+ attempt_errors=attempt_errors,
353
+ )
354
+ ]
355
+ except Exception as e:
356
+ trace = traceback.format_exc()
357
+ retryable = attempt <= step.retries and isinstance(e, step.retry_on)
358
+ if not retryable:
359
+ return [
360
+ ExecutionOutcome(
361
+ decision,
362
+ False,
363
+ error_trace=trace,
364
+ attempts=attempt,
365
+ attempt_errors=attempt_errors,
366
+ )
367
+ ]
368
+ attempt_errors.append(trace)
369
+ if delay > 0:
370
+ time.sleep(delay)
371
+ delay *= step.retry_backoff
372
+
373
+
374
+
375
+ # Never spin up more workers (or subprocesses) than there is work.
376
+ pool_size = min(workers or step.workers, len(decisions))
377
+ if pool_size < 1:
378
+ return
379
+
380
+ # Two layers, on purpose. The thread pool is the orchestrator: it drives
381
+ # the retry loop and the parent-shared rate limiter for every lane. A
382
+ # process pool, when requested, is only where the CPU-bound step body
383
+ # runs — retries and rate limiting must stay in the parent, so process
384
+ # steps still need the thread layer to feed them.
385
+ process_pool = (
386
+ loky.ProcessPoolExecutor(max_workers=pool_size)
387
+ if step.executor == "process"
388
+ else None
389
+ )
390
+ try:
391
+ with concurrent.futures.ThreadPoolExecutor(max_workers=pool_size) as executor:
392
+ futures = [executor.submit(process, d, process_pool) for d in decisions]
393
+ for future in concurrent.futures.as_completed(futures):
394
+ yield from future.result()
395
+ finally:
396
+ if process_pool is not None:
397
+ process_pool.shutdown()