rubedo 0.1.0__tar.gz

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.
Files changed (61) hide show
  1. rubedo-0.1.0/LICENSE +21 -0
  2. rubedo-0.1.0/PKG-INFO +254 -0
  3. rubedo-0.1.0/README.md +221 -0
  4. rubedo-0.1.0/pyproject.toml +90 -0
  5. rubedo-0.1.0/setup.cfg +4 -0
  6. rubedo-0.1.0/src/rubedo/__init__.py +44 -0
  7. rubedo-0.1.0/src/rubedo/cli.py +143 -0
  8. rubedo-0.1.0/src/rubedo/db.py +76 -0
  9. rubedo-0.1.0/src/rubedo/execution.py +397 -0
  10. rubedo-0.1.0/src/rubedo/hashing.py +49 -0
  11. rubedo-0.1.0/src/rubedo/invalidation.py +96 -0
  12. rubedo-0.1.0/src/rubedo/ledger.py +670 -0
  13. rubedo-0.1.0/src/rubedo/models.py +406 -0
  14. rubedo-0.1.0/src/rubedo/planning.py +838 -0
  15. rubedo-0.1.0/src/rubedo/progress.py +44 -0
  16. rubedo-0.1.0/src/rubedo/py.typed +0 -0
  17. rubedo-0.1.0/src/rubedo/queries.py +90 -0
  18. rubedo-0.1.0/src/rubedo/runner.py +401 -0
  19. rubedo-0.1.0/src/rubedo/schemas.py +150 -0
  20. rubedo-0.1.0/src/rubedo/selection.py +156 -0
  21. rubedo-0.1.0/src/rubedo/server.py +569 -0
  22. rubedo-0.1.0/src/rubedo/sources.py +322 -0
  23. rubedo-0.1.0/src/rubedo/spec.py +570 -0
  24. rubedo-0.1.0/src/rubedo/store.py +145 -0
  25. rubedo-0.1.0/src/rubedo/util.py +33 -0
  26. rubedo-0.1.0/src/rubedo.egg-info/PKG-INFO +254 -0
  27. rubedo-0.1.0/src/rubedo.egg-info/SOURCES.txt +59 -0
  28. rubedo-0.1.0/src/rubedo.egg-info/dependency_links.txt +1 -0
  29. rubedo-0.1.0/src/rubedo.egg-info/entry_points.txt +2 -0
  30. rubedo-0.1.0/src/rubedo.egg-info/requires.txt +10 -0
  31. rubedo-0.1.0/src/rubedo.egg-info/top_level.txt +1 -0
  32. rubedo-0.1.0/tests/test_api.py +134 -0
  33. rubedo-0.1.0/tests/test_code_detection.py +187 -0
  34. rubedo-0.1.0/tests/test_concurrency_safety.py +163 -0
  35. rubedo-0.1.0/tests/test_crash_recovery.py +154 -0
  36. rubedo-0.1.0/tests/test_dag.py +277 -0
  37. rubedo-0.1.0/tests/test_data_quality.py +177 -0
  38. rubedo-0.1.0/tests/test_engine.py +273 -0
  39. rubedo-0.1.0/tests/test_expand.py +319 -0
  40. rubedo-0.1.0/tests/test_filters.py +193 -0
  41. rubedo-0.1.0/tests/test_generations.py +284 -0
  42. rubedo-0.1.0/tests/test_group_key.py +229 -0
  43. rubedo-0.1.0/tests/test_immutability.py +168 -0
  44. rubedo-0.1.0/tests/test_index.py +202 -0
  45. rubedo-0.1.0/tests/test_join.py +305 -0
  46. rubedo-0.1.0/tests/test_multisource.py +183 -0
  47. rubedo-0.1.0/tests/test_pairing_guard.py +178 -0
  48. rubedo-0.1.0/tests/test_pipelines.py +127 -0
  49. rubedo-0.1.0/tests/test_plan.py +170 -0
  50. rubedo-0.1.0/tests/test_process_executor.py +121 -0
  51. rubedo-0.1.0/tests/test_reduce.py +324 -0
  52. rubedo-0.1.0/tests/test_run_liveness.py +156 -0
  53. rubedo-0.1.0/tests/test_run_status.py +232 -0
  54. rubedo-0.1.0/tests/test_selection.py +106 -0
  55. rubedo-0.1.0/tests/test_selection_language.py +52 -0
  56. rubedo-0.1.0/tests/test_skip_cache.py +283 -0
  57. rubedo-0.1.0/tests/test_sources.py +180 -0
  58. rubedo-0.1.0/tests/test_staging_lifecycle.py +91 -0
  59. rubedo-0.1.0/tests/test_step_policies.py +301 -0
  60. rubedo-0.1.0/tests/test_table_source.py +225 -0
  61. rubedo-0.1.0/tests/test_tier0_fixes.py +343 -0
rubedo-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Saurav Das
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
rubedo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,254 @@
1
+ Metadata-Version: 2.4
2
+ Name: rubedo
3
+ Version: 0.1.0
4
+ Summary: Content-addressed caching and run history for Python batch pipelines — built for steps you can't afford to re-run
5
+ Author: Saurav Das
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/dinosaurav/Rubedo
8
+ Project-URL: Repository, https://github.com/dinosaurav/Rubedo
9
+ Project-URL: Issues, https://github.com/dinosaurav/Rubedo/issues
10
+ Project-URL: Changelog, https://github.com/dinosaurav/Rubedo/blob/main/CHANGELOG.md
11
+ Keywords: pipeline,dag,caching,batch,incremental,llm,data-engineering
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: sqlalchemy
24
+ Requires-Dist: pydantic
25
+ Requires-Dist: packaging>=26.2
26
+ Requires-Dist: loky>=3.5.6
27
+ Requires-Dist: cloudpickle>=3.1.2
28
+ Requires-Dist: rich>=13.0.0
29
+ Provides-Extra: server
30
+ Requires-Dist: fastapi; extra == "server"
31
+ Requires-Dist: uvicorn; extra == "server"
32
+ Dynamic: license-file
33
+
34
+ # Rubedo
35
+
36
+ **Content-addressed caching and run history for Python batch pipelines — built for steps you can't afford to re-run.**
37
+
38
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)
39
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
40
+ [![Status: pre-1.0](https://img.shields.io/badge/status-pre--1.0-orange.svg)](#project-status)
41
+
42
+ Rubedo is a local-first batch engine: you define a DAG of Python steps over a collection of items — files in a folder, rows in a CSV, rows in a SQL table — and Rubedo runs it with **dbt-style state**. Every step output is stored immutably at a deterministic address (`hash(step, code_version, input_hash)`), so re-running a pipeline recomputes only what actually changed. An append-only run ledger records what happened to every item in every run, and lineage edges connect each output to the outputs it was derived from.
43
+
44
+ It exists for **non-idempotent, expensive steps** — LLM calls, scraping, paid APIs — where "just re-run the script" means paying for everything again and hoping the results come back the same.
45
+
46
+ ## Why
47
+
48
+ If you've ever processed a thousand rows through an LLM and then needed to fix the last step, you know the failure modes:
49
+
50
+ - **Re-running re-pays.** Without durable per-item state, every code tweak or crash means re-running every API call before it.
51
+ - **`functools.cache` and pickle files don't know your DAG.** Ad-hoc caches can't tell you *why* something recomputed, can't invalidate downstream when an input changes, and silently go stale when the code does.
52
+ - **Orchestrators are the wrong tool.** Airflow/Prefect/Dagster schedule and monitor services; they don't give you row-level, content-addressed incrementality inside a local script. dbt does — but only for SQL.
53
+ - **Make/Snakemake track files.** Rubedo tracks *content*, at row granularity, with a queryable history of every run.
54
+
55
+ Rubedo is a library, not a platform: no daemon, no registry, no magic module. The engine never imports your code — you import the engine. State lives in a `.rubedo/` directory (SQLite ledger + content-addressed object store), created on first run and gitignored automatically.
56
+
57
+ > **Note:** `.rubedo/` resolves **relative to the current working directory** — pipelines, the CLI, and the server must all run from the same directory (typically your project root) to see the same state. Running from somewhere else silently creates a fresh, empty store there. To run from anywhere, pin the location with the `RUBEDO_HOME` (or `RUBEDO_DB_PATH`) environment variable.
58
+
59
+ ## Install
60
+
61
+ Rubedo is not on PyPI yet. Install from source:
62
+
63
+ ```bash
64
+ git clone https://github.com/dinosaurav/Rubedo && cd Rubedo
65
+ uv sync # or: pip install -e ".[server]"
66
+ ```
67
+
68
+ Requires Python 3.11+. The `server` extra adds the read-only FastAPI backend for the web dashboard.
69
+
70
+ ## Quickstart
71
+
72
+ Pipelines are plain Python objects — define them wherever your code lives:
73
+
74
+ ```python
75
+ from rubedo import ProcessResult, step, pipeline, run, plan, describe
76
+
77
+ @step(name="read_lines", version="read-v1")
78
+ def read_lines(path: str):
79
+ return {"lines": open(path).read().splitlines()}
80
+
81
+ @step(name="count_lines", version="count-v1", depends_on=["read_lines"])
82
+ def count_lines(read_lines: dict) -> ProcessResult:
83
+ return ProcessResult(value={"line_count": len(read_lines["lines"])})
84
+
85
+ p = pipeline(id="count-lines", name="Count Lines", folder="input",
86
+ steps=[read_lines, count_lines])
87
+
88
+ print(describe(p)) # the DAG, before ever running (also: format="mermaid")
89
+ print(plan(p)) # dry-run: what would run() do to my data, and why
90
+ summary = run(p) # execute
91
+ print(f"created={summary.created_count} reused={summary.reused_count}")
92
+ ```
93
+
94
+ Run it twice and watch the point of the whole project:
95
+
96
+ ```text
97
+ # first run created=8 reused=0
98
+ # second run created=0 reused=8 ← nothing changed, nothing recomputed
99
+ # edit one file... created=2 reused=6 ← only that file's lanes re-run
100
+ ```
101
+
102
+ Each run also snapshots the pipeline's definition (steps, edges, policies) into the ledger, so history and the dashboard can show the DAG of anything that has ever run — no imports of user code required.
103
+
104
+ Prefer a fluent style? `PipelineBuilder` builds the same object:
105
+
106
+ ```python
107
+ from rubedo import PipelineBuilder
108
+ p = PipelineBuilder(id="count-lines", name="Count Lines", folder="input")
109
+
110
+ @p.step(name="read_lines", version="read-v1")
111
+ def read_lines(path: str): ...
112
+
113
+ count_lines = p.build()
114
+ ```
115
+
116
+ ## Sources
117
+
118
+ Items come from a `Source` — anything that can enumerate `(coordinate, content_hash)` pairs and load payloads. `folder="..."` is sugar for `FolderSource` (each file is a lane; root steps receive its path). `CsvSource` makes each row a lane and hands root steps the row dict; `TableSource` does the same for SQL rows, with an optional `batch_size` streaming mode:
119
+
120
+ ```python
121
+ from rubedo import CsvSource, step, pipeline
122
+
123
+ @step(name="enrich", version="v1")
124
+ def enrich(row: dict):
125
+ return {"email": row["email"], "summary": call_llm(row["notes"])}
126
+
127
+ leads = pipeline(id="enrich-leads", name="Enrich Leads",
128
+ source=CsvSource("data/leads.csv"),
129
+ steps=[enrich])
130
+ ```
131
+
132
+ Each row is a **content-addressed lane** (`row-<hash>`): identical rows collapse to one lane, and an edited row shows up as removed + created — so incrementality survives row reordering, deduplication, and appends for free. To find or track a row by a human field (email, id), index it with `@step(index=[...])` and query — the lane key is never a human key.
133
+
134
+ A step consumes up to two things, each with its own slot in the cache key: **data** (the source payload for root steps, parent outputs for dependent steps — always hashed) and **params** (run-level knobs, validated against the pipeline's `params_model` and hashed only for steps that declare a `params` parameter — so turning a knob recomputes exactly the steps that read it).
135
+
136
+ ## Built for flaky, expensive work
137
+
138
+ Steps carry their own execution policies:
139
+
140
+ ```python
141
+ def check_price_positive(val: dict):
142
+ if val["price"] < 0: raise ValueError("Negative price")
143
+
144
+ @step(name="enrich", version="1.0.0",
145
+ retries=3, retry_on=(TimeoutError, ConnectionError), retry_delay=1, retry_backoff=2,
146
+ rate_limit="30/min", stale_after="24h", assertions=[check_price_positive])
147
+ def enrich(row: dict): ...
148
+ ```
149
+
150
+ - **Retries** apply only to exceptions matching `retry_on` (keep it narrow — retrying a deterministic bug on a paid API just multiplies cost). Every attempt lands in the run event log.
151
+ - **`rate_limit`** paces the step evenly across all its workers, retries included.
152
+ - **`stale_after`** expires outputs: past the TTL the step re-executes — different bytes supersede the old generation (downstream recomputes), identical bytes just refresh the clock.
153
+ - **`assertions`** run against the output value before it commits; if any raise, the step fails and bad data never propagates downstream.
154
+ - **`executor="process"`** switches a step from the default thread pool to a process pool (`loky` + `cloudpickle`, so closures are fine) for CPU-bound work.
155
+
156
+ A step can **decline an item** by returning `Filtered(reason=...)`: downstream steps skip it with status `filtered` instead of executing, and the verdict itself is cached like any output — an expensive LLM-based filter runs once per input, not once per run. When the input changes, the decision is made fresh.
157
+
158
+ `skip_cache=True` marks an inline util — a quick, idempotent helper that keeps other steps readable. It's never materialized or recorded: its identity fuses into its consumers' cache keys, and it executes lazily (memoized per run) only when a consumer actually runs, so fully-cached runs skip it entirely. If a step is expensive, flaky, or non-deterministic, it deserves materialization — don't skip it.
159
+
160
+ ## Shapes
161
+
162
+ By default a step is `map` — 1:1 per lane. Three more shapes cover fan-in, fan-out, and joins:
163
+
164
+ - **`reduce`** (N:1) — fan in over all a parent's surviving lanes: `@step(shape="reduce")` receives `{lane: value}` and returns one output. Add `group_key="field"` to fan in *per group* instead — one output per value of an indexed field. By default it drops failed parent lanes and proceeds with what passed (`on_failed="use_passed"`).
165
+ - **`expand`** (1:N) — the step `yield`s a payload per item and each becomes its own content-addressed downstream lane (fetch a feed → a lane per article). The whole expansion is cached against its parent, so a scrape runs once and a re-run re-expands nothing; `stale_after` gives periodic re-scrape.
166
+ - **`join`** — an N-way equijoin across multiple sources, matched on an indexed field, minting one lane per matched tuple:
167
+
168
+ ```python
169
+ @step(name="order", version="1", source="orders", index=["cust"])
170
+ def order(row): return {"oid": row["oid"], "cust": row["cust"]}
171
+
172
+ @step(name="customer", version="1", source="customers", index=["cid"])
173
+ def customer(row): return {"cid": row["cid"], "name": row["name"]}
174
+
175
+ @step(name="enrich", version="1", shape="join",
176
+ depends_on=["order", "customer"],
177
+ join_on={"order": "cust", "customer": "cid"})
178
+ def enrich(order, customer): # one lane per matched pair
179
+ return {"oid": order["oid"], "name": customer["name"]}
180
+
181
+ p = pipeline(id="enrich", name="Enrich",
182
+ sources={"orders": CsvSource("orders.csv"),
183
+ "customers": CsvSource("customers.csv")},
184
+ steps=[order, customer, enrich])
185
+ ```
186
+
187
+ Multiple sources are declared with `sources={name: Source}` (single `source=`/`folder=` are the one-source sugar), and each root step names its source with `@step(source="name")`. See [`examples/newsroom`](examples/newsroom/) for join → expand → `group_key` working together.
188
+
189
+ ## Search and surgical invalidation
190
+
191
+ Outputs are **searchable by their content**: `@step(index=["company", "meta.region"])` extracts those value fields into an index at commit time, so you can select by what a step *computed*, regardless of file names or row keys:
192
+
193
+ ```python
194
+ from rubedo import Selection, invalidate
195
+
196
+ invalidate(Selection(index={"company": "acme"})) # recompute acme's rows next run
197
+ Selection.parse("step:extract company:acme live:true") # query-string form (Python, CLI, and UI)
198
+ ```
199
+
200
+ Reserved prefixes (`step:`, `live:`, `version:<2.0`-style ranges, lane-key globs) cover engine facts; any other `field:value` matches an indexed field. A label is just data you chose to index — non-unique, multi-valued, attachable at any step, never part of cache identity. Invalidation is a logical tombstone, never a delete: history stays intact, and the next run recomputes exactly the invalidated lanes plus their downstream.
201
+
202
+ ## Code changes and caching
203
+
204
+ Two independent axes on `@step`:
205
+
206
+ - **`version`** is the semantic identity — bump it for deliberate behavior changes (also the escape hatch for edits the engine can't see, like helpers your step calls).
207
+ - **`code`** decides what a *source edit* means. `code="auto"` folds the function's source hash into the cache identity, so any edit recomputes without version bookkeeping (right for cheap, deterministic steps). `code="warn"` (the default) never recomputes on edits, but warns loudly — in the run output, the event log, and `plan()` — whenever it reuses an output whose code has since changed, so recomputing an expensive LLM step stays a deliberate choice.
208
+
209
+ ## Inspecting runs
210
+
211
+ `plan()` is a read-only dry-run: it tells you what `run()` would do to every lane and why (reuse, execute, blocked, filtered, stale, code-drift) without writing anything.
212
+
213
+ The **CLI** browses and invalidates against the local ledger:
214
+
215
+ ```bash
216
+ rubedo ls # recent runs
217
+ rubedo show <run_id> --failed # what broke, per lane (--json for scripts)
218
+ rubedo invalidate "step:enrich company:acme" --reason "bad prompt"
219
+ ```
220
+
221
+ The **web dashboard** is a read-only browser over runs, materializations, lineage, and current outputs, with search to drill into specific values or errors:
222
+
223
+ ```bash
224
+ uv run uvicorn rubedo.server:app --reload # API on :8000
225
+ cd web && npm run dev # UI on :5173
226
+ ```
227
+
228
+ Running, recomputing, and invalidation always happen from library code or the CLI; the UI never mutates state.
229
+
230
+ ## Examples
231
+
232
+ Every example in [`examples/`](examples/) is a self-contained folder that talks to **real** services (Hacker News, GitHub, Open-Meteo, Project Gutenberg, an LLM via OpenRouter) using only the standard library:
233
+
234
+ ```bash
235
+ uv run python examples/count_lines/count_lines.py # run it twice — watch everything reuse
236
+ ```
237
+
238
+ See the [examples README](examples/README.md) for the full table of what each one demonstrates.
239
+
240
+ ## Design
241
+
242
+ The ledger is **append-only** and enforced at the ORM layer: committed outputs are immutable, every liveness transition is recorded, and workers can die at any point without corrupting committed state. Planning is read-only and value-free; execution is DB-free; all writes go through one commit path. [notes/invariants.md](notes/invariants.md) is the canonical vocabulary and the eight invariants the engine guarantees; [notes/producer-model.md](notes/producer-model.md) covers the design behind sources, `expand`, and `join`.
243
+
244
+ ## Project status
245
+
246
+ Pre-1.0 and moving fast: the API is unstable and there are **no migrations or backwards-compatibility shims** — schema changes mean deleting `.rubedo/` and re-running. The core model (content-addressed lanes, the four shapes, multi-source, the ledger protocol) is designed and built; hardening and polish are ongoing in [notes/TODO.md](notes/TODO.md).
247
+
248
+ ## Contributing
249
+
250
+ Small fixes and discussion are welcome; larger features should start as an issue before any code — see [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the verification checklist, and conventions (the short version: small commits, no compat shims, prefer deleting a concept to adding a knob).
251
+
252
+ ## License
253
+
254
+ [MIT](LICENSE)
rubedo-0.1.0/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # Rubedo
2
+
3
+ **Content-addressed caching and run history for Python batch pipelines — built for steps you can't afford to re-run.**
4
+
5
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
+ [![Status: pre-1.0](https://img.shields.io/badge/status-pre--1.0-orange.svg)](#project-status)
8
+
9
+ Rubedo is a local-first batch engine: you define a DAG of Python steps over a collection of items — files in a folder, rows in a CSV, rows in a SQL table — and Rubedo runs it with **dbt-style state**. Every step output is stored immutably at a deterministic address (`hash(step, code_version, input_hash)`), so re-running a pipeline recomputes only what actually changed. An append-only run ledger records what happened to every item in every run, and lineage edges connect each output to the outputs it was derived from.
10
+
11
+ It exists for **non-idempotent, expensive steps** — LLM calls, scraping, paid APIs — where "just re-run the script" means paying for everything again and hoping the results come back the same.
12
+
13
+ ## Why
14
+
15
+ If you've ever processed a thousand rows through an LLM and then needed to fix the last step, you know the failure modes:
16
+
17
+ - **Re-running re-pays.** Without durable per-item state, every code tweak or crash means re-running every API call before it.
18
+ - **`functools.cache` and pickle files don't know your DAG.** Ad-hoc caches can't tell you *why* something recomputed, can't invalidate downstream when an input changes, and silently go stale when the code does.
19
+ - **Orchestrators are the wrong tool.** Airflow/Prefect/Dagster schedule and monitor services; they don't give you row-level, content-addressed incrementality inside a local script. dbt does — but only for SQL.
20
+ - **Make/Snakemake track files.** Rubedo tracks *content*, at row granularity, with a queryable history of every run.
21
+
22
+ Rubedo is a library, not a platform: no daemon, no registry, no magic module. The engine never imports your code — you import the engine. State lives in a `.rubedo/` directory (SQLite ledger + content-addressed object store), created on first run and gitignored automatically.
23
+
24
+ > **Note:** `.rubedo/` resolves **relative to the current working directory** — pipelines, the CLI, and the server must all run from the same directory (typically your project root) to see the same state. Running from somewhere else silently creates a fresh, empty store there. To run from anywhere, pin the location with the `RUBEDO_HOME` (or `RUBEDO_DB_PATH`) environment variable.
25
+
26
+ ## Install
27
+
28
+ Rubedo is not on PyPI yet. Install from source:
29
+
30
+ ```bash
31
+ git clone https://github.com/dinosaurav/Rubedo && cd Rubedo
32
+ uv sync # or: pip install -e ".[server]"
33
+ ```
34
+
35
+ Requires Python 3.11+. The `server` extra adds the read-only FastAPI backend for the web dashboard.
36
+
37
+ ## Quickstart
38
+
39
+ Pipelines are plain Python objects — define them wherever your code lives:
40
+
41
+ ```python
42
+ from rubedo import ProcessResult, step, pipeline, run, plan, describe
43
+
44
+ @step(name="read_lines", version="read-v1")
45
+ def read_lines(path: str):
46
+ return {"lines": open(path).read().splitlines()}
47
+
48
+ @step(name="count_lines", version="count-v1", depends_on=["read_lines"])
49
+ def count_lines(read_lines: dict) -> ProcessResult:
50
+ return ProcessResult(value={"line_count": len(read_lines["lines"])})
51
+
52
+ p = pipeline(id="count-lines", name="Count Lines", folder="input",
53
+ steps=[read_lines, count_lines])
54
+
55
+ print(describe(p)) # the DAG, before ever running (also: format="mermaid")
56
+ print(plan(p)) # dry-run: what would run() do to my data, and why
57
+ summary = run(p) # execute
58
+ print(f"created={summary.created_count} reused={summary.reused_count}")
59
+ ```
60
+
61
+ Run it twice and watch the point of the whole project:
62
+
63
+ ```text
64
+ # first run created=8 reused=0
65
+ # second run created=0 reused=8 ← nothing changed, nothing recomputed
66
+ # edit one file... created=2 reused=6 ← only that file's lanes re-run
67
+ ```
68
+
69
+ Each run also snapshots the pipeline's definition (steps, edges, policies) into the ledger, so history and the dashboard can show the DAG of anything that has ever run — no imports of user code required.
70
+
71
+ Prefer a fluent style? `PipelineBuilder` builds the same object:
72
+
73
+ ```python
74
+ from rubedo import PipelineBuilder
75
+ p = PipelineBuilder(id="count-lines", name="Count Lines", folder="input")
76
+
77
+ @p.step(name="read_lines", version="read-v1")
78
+ def read_lines(path: str): ...
79
+
80
+ count_lines = p.build()
81
+ ```
82
+
83
+ ## Sources
84
+
85
+ Items come from a `Source` — anything that can enumerate `(coordinate, content_hash)` pairs and load payloads. `folder="..."` is sugar for `FolderSource` (each file is a lane; root steps receive its path). `CsvSource` makes each row a lane and hands root steps the row dict; `TableSource` does the same for SQL rows, with an optional `batch_size` streaming mode:
86
+
87
+ ```python
88
+ from rubedo import CsvSource, step, pipeline
89
+
90
+ @step(name="enrich", version="v1")
91
+ def enrich(row: dict):
92
+ return {"email": row["email"], "summary": call_llm(row["notes"])}
93
+
94
+ leads = pipeline(id="enrich-leads", name="Enrich Leads",
95
+ source=CsvSource("data/leads.csv"),
96
+ steps=[enrich])
97
+ ```
98
+
99
+ Each row is a **content-addressed lane** (`row-<hash>`): identical rows collapse to one lane, and an edited row shows up as removed + created — so incrementality survives row reordering, deduplication, and appends for free. To find or track a row by a human field (email, id), index it with `@step(index=[...])` and query — the lane key is never a human key.
100
+
101
+ A step consumes up to two things, each with its own slot in the cache key: **data** (the source payload for root steps, parent outputs for dependent steps — always hashed) and **params** (run-level knobs, validated against the pipeline's `params_model` and hashed only for steps that declare a `params` parameter — so turning a knob recomputes exactly the steps that read it).
102
+
103
+ ## Built for flaky, expensive work
104
+
105
+ Steps carry their own execution policies:
106
+
107
+ ```python
108
+ def check_price_positive(val: dict):
109
+ if val["price"] < 0: raise ValueError("Negative price")
110
+
111
+ @step(name="enrich", version="1.0.0",
112
+ retries=3, retry_on=(TimeoutError, ConnectionError), retry_delay=1, retry_backoff=2,
113
+ rate_limit="30/min", stale_after="24h", assertions=[check_price_positive])
114
+ def enrich(row: dict): ...
115
+ ```
116
+
117
+ - **Retries** apply only to exceptions matching `retry_on` (keep it narrow — retrying a deterministic bug on a paid API just multiplies cost). Every attempt lands in the run event log.
118
+ - **`rate_limit`** paces the step evenly across all its workers, retries included.
119
+ - **`stale_after`** expires outputs: past the TTL the step re-executes — different bytes supersede the old generation (downstream recomputes), identical bytes just refresh the clock.
120
+ - **`assertions`** run against the output value before it commits; if any raise, the step fails and bad data never propagates downstream.
121
+ - **`executor="process"`** switches a step from the default thread pool to a process pool (`loky` + `cloudpickle`, so closures are fine) for CPU-bound work.
122
+
123
+ A step can **decline an item** by returning `Filtered(reason=...)`: downstream steps skip it with status `filtered` instead of executing, and the verdict itself is cached like any output — an expensive LLM-based filter runs once per input, not once per run. When the input changes, the decision is made fresh.
124
+
125
+ `skip_cache=True` marks an inline util — a quick, idempotent helper that keeps other steps readable. It's never materialized or recorded: its identity fuses into its consumers' cache keys, and it executes lazily (memoized per run) only when a consumer actually runs, so fully-cached runs skip it entirely. If a step is expensive, flaky, or non-deterministic, it deserves materialization — don't skip it.
126
+
127
+ ## Shapes
128
+
129
+ By default a step is `map` — 1:1 per lane. Three more shapes cover fan-in, fan-out, and joins:
130
+
131
+ - **`reduce`** (N:1) — fan in over all a parent's surviving lanes: `@step(shape="reduce")` receives `{lane: value}` and returns one output. Add `group_key="field"` to fan in *per group* instead — one output per value of an indexed field. By default it drops failed parent lanes and proceeds with what passed (`on_failed="use_passed"`).
132
+ - **`expand`** (1:N) — the step `yield`s a payload per item and each becomes its own content-addressed downstream lane (fetch a feed → a lane per article). The whole expansion is cached against its parent, so a scrape runs once and a re-run re-expands nothing; `stale_after` gives periodic re-scrape.
133
+ - **`join`** — an N-way equijoin across multiple sources, matched on an indexed field, minting one lane per matched tuple:
134
+
135
+ ```python
136
+ @step(name="order", version="1", source="orders", index=["cust"])
137
+ def order(row): return {"oid": row["oid"], "cust": row["cust"]}
138
+
139
+ @step(name="customer", version="1", source="customers", index=["cid"])
140
+ def customer(row): return {"cid": row["cid"], "name": row["name"]}
141
+
142
+ @step(name="enrich", version="1", shape="join",
143
+ depends_on=["order", "customer"],
144
+ join_on={"order": "cust", "customer": "cid"})
145
+ def enrich(order, customer): # one lane per matched pair
146
+ return {"oid": order["oid"], "name": customer["name"]}
147
+
148
+ p = pipeline(id="enrich", name="Enrich",
149
+ sources={"orders": CsvSource("orders.csv"),
150
+ "customers": CsvSource("customers.csv")},
151
+ steps=[order, customer, enrich])
152
+ ```
153
+
154
+ Multiple sources are declared with `sources={name: Source}` (single `source=`/`folder=` are the one-source sugar), and each root step names its source with `@step(source="name")`. See [`examples/newsroom`](examples/newsroom/) for join → expand → `group_key` working together.
155
+
156
+ ## Search and surgical invalidation
157
+
158
+ Outputs are **searchable by their content**: `@step(index=["company", "meta.region"])` extracts those value fields into an index at commit time, so you can select by what a step *computed*, regardless of file names or row keys:
159
+
160
+ ```python
161
+ from rubedo import Selection, invalidate
162
+
163
+ invalidate(Selection(index={"company": "acme"})) # recompute acme's rows next run
164
+ Selection.parse("step:extract company:acme live:true") # query-string form (Python, CLI, and UI)
165
+ ```
166
+
167
+ Reserved prefixes (`step:`, `live:`, `version:<2.0`-style ranges, lane-key globs) cover engine facts; any other `field:value` matches an indexed field. A label is just data you chose to index — non-unique, multi-valued, attachable at any step, never part of cache identity. Invalidation is a logical tombstone, never a delete: history stays intact, and the next run recomputes exactly the invalidated lanes plus their downstream.
168
+
169
+ ## Code changes and caching
170
+
171
+ Two independent axes on `@step`:
172
+
173
+ - **`version`** is the semantic identity — bump it for deliberate behavior changes (also the escape hatch for edits the engine can't see, like helpers your step calls).
174
+ - **`code`** decides what a *source edit* means. `code="auto"` folds the function's source hash into the cache identity, so any edit recomputes without version bookkeeping (right for cheap, deterministic steps). `code="warn"` (the default) never recomputes on edits, but warns loudly — in the run output, the event log, and `plan()` — whenever it reuses an output whose code has since changed, so recomputing an expensive LLM step stays a deliberate choice.
175
+
176
+ ## Inspecting runs
177
+
178
+ `plan()` is a read-only dry-run: it tells you what `run()` would do to every lane and why (reuse, execute, blocked, filtered, stale, code-drift) without writing anything.
179
+
180
+ The **CLI** browses and invalidates against the local ledger:
181
+
182
+ ```bash
183
+ rubedo ls # recent runs
184
+ rubedo show <run_id> --failed # what broke, per lane (--json for scripts)
185
+ rubedo invalidate "step:enrich company:acme" --reason "bad prompt"
186
+ ```
187
+
188
+ The **web dashboard** is a read-only browser over runs, materializations, lineage, and current outputs, with search to drill into specific values or errors:
189
+
190
+ ```bash
191
+ uv run uvicorn rubedo.server:app --reload # API on :8000
192
+ cd web && npm run dev # UI on :5173
193
+ ```
194
+
195
+ Running, recomputing, and invalidation always happen from library code or the CLI; the UI never mutates state.
196
+
197
+ ## Examples
198
+
199
+ Every example in [`examples/`](examples/) is a self-contained folder that talks to **real** services (Hacker News, GitHub, Open-Meteo, Project Gutenberg, an LLM via OpenRouter) using only the standard library:
200
+
201
+ ```bash
202
+ uv run python examples/count_lines/count_lines.py # run it twice — watch everything reuse
203
+ ```
204
+
205
+ See the [examples README](examples/README.md) for the full table of what each one demonstrates.
206
+
207
+ ## Design
208
+
209
+ The ledger is **append-only** and enforced at the ORM layer: committed outputs are immutable, every liveness transition is recorded, and workers can die at any point without corrupting committed state. Planning is read-only and value-free; execution is DB-free; all writes go through one commit path. [notes/invariants.md](notes/invariants.md) is the canonical vocabulary and the eight invariants the engine guarantees; [notes/producer-model.md](notes/producer-model.md) covers the design behind sources, `expand`, and `join`.
210
+
211
+ ## Project status
212
+
213
+ Pre-1.0 and moving fast: the API is unstable and there are **no migrations or backwards-compatibility shims** — schema changes mean deleting `.rubedo/` and re-running. The core model (content-addressed lanes, the four shapes, multi-source, the ledger protocol) is designed and built; hardening and polish are ongoing in [notes/TODO.md](notes/TODO.md).
214
+
215
+ ## Contributing
216
+
217
+ Small fixes and discussion are welcome; larger features should start as an issue before any code — see [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the verification checklist, and conventions (the short version: small commits, no compat shims, prefer deleting a concept to adding a knob).
218
+
219
+ ## License
220
+
221
+ [MIT](LICENSE)
@@ -0,0 +1,90 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rubedo"
7
+ version = "0.1.0"
8
+ description = "Content-addressed caching and run history for Python batch pipelines — built for steps you can't afford to re-run"
9
+ readme = "README.md"
10
+ authors = [
11
+ { name = "Saurav Das" }
12
+ ]
13
+ requires-python = ">=3.11"
14
+ license = "MIT"
15
+ keywords = [
16
+ "pipeline",
17
+ "dag",
18
+ "caching",
19
+ "batch",
20
+ "incremental",
21
+ "llm",
22
+ "data-engineering",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 3 - Alpha",
26
+ "Intended Audience :: Developers",
27
+ "Operating System :: OS Independent",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.11",
30
+ "Programming Language :: Python :: 3.12",
31
+ "Programming Language :: Python :: 3.13",
32
+ "Topic :: Software Development :: Libraries :: Python Modules",
33
+ ]
34
+ dependencies = [
35
+ "sqlalchemy",
36
+ "pydantic",
37
+ "packaging>=26.2",
38
+ "loky>=3.5.6",
39
+ "cloudpickle>=3.1.2",
40
+ "rich>=13.0.0",
41
+ ]
42
+
43
+ [project.urls]
44
+ Homepage = "https://github.com/dinosaurav/Rubedo"
45
+ Repository = "https://github.com/dinosaurav/Rubedo"
46
+ Issues = "https://github.com/dinosaurav/Rubedo/issues"
47
+ Changelog = "https://github.com/dinosaurav/Rubedo/blob/main/CHANGELOG.md"
48
+
49
+ [project.scripts]
50
+ rubedo = "rubedo.cli:main"
51
+
52
+ [project.optional-dependencies]
53
+ server = ["fastapi", "uvicorn"]
54
+
55
+ # The examples call real services (Hacker News, GitHub, Open-Meteo, an LLM via
56
+ # OpenRouter) using only the standard library — no extra dependencies to install.
57
+ # LLM examples read OPENROUTER_API_KEY from a .env file at the repo root.
58
+
59
+ [tool.setuptools.packages.find]
60
+ where = ["src"]
61
+
62
+ [tool.pytest.ini_options]
63
+ testpaths = ["tests"]
64
+
65
+ [dependency-groups]
66
+ dev = [
67
+ "fastapi",
68
+ "httpx>=0.25.0",
69
+ "litellm>=1.91.0",
70
+ "mcp>=1.28.1",
71
+ "mkdocs>=1.6.1",
72
+ "mkdocs-material>=9.7.6",
73
+ "mypy>=2.1.0",
74
+ "networkx>=3.6.1",
75
+ "pytest>=9.1.1",
76
+ "ruff>=0.15.20",
77
+ "scipy>=1.17.1",
78
+ "tree-sitter>=0.26.0",
79
+ "tree-sitter-python>=0.25.0",
80
+ "uvicorn",
81
+ ]
82
+
83
+ [tool.mypy]
84
+ python_version = "3.11"
85
+ check_untyped_defs = true
86
+ ignore_missing_imports = true
87
+ warn_unused_ignores = true
88
+ warn_unused_configs = true
89
+
90
+
rubedo-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]