pipeloom 0.0.1__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.

Potentially problematic release.


This version of pipeloom might be problematic. Click here for more details.

@@ -0,0 +1,379 @@
1
+ Metadata-Version: 2.4
2
+ Name: pipeloom
3
+ Version: 0.0.1
4
+ Summary: Threaded ETL scaffolding with a single-writer SQLite (WAL) backend, Rich progress, and a Typer CLI.
5
+ Author-email: Caleb Grant <grantcaleb22@gmail.com>
6
+ License-Expression: GPL-3.0-or-later
7
+ Project-URL: Homepage, https://github.com/geocoug/pipeloom
8
+ Project-URL: Repository, https://github.com/geocoug/pipeloom
9
+ Project-URL: Issues, https://github.com/geocoug/pipeloom/issues
10
+ Requires-Python: ==3.13.*
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: rich>=14.1.0
13
+ Requires-Dist: typer>=0.16.0
14
+
15
+ # pipeloom
16
+
17
+ Threaded ETL scaffolding with a **single-writer SQLite (WAL)** backend, **Rich** progress, and a **Typer** CLI.
18
+
19
+ ## Why
20
+
21
+ - **SQLite WAL**: readers don’t block while the writer appends; perfect for local ETL and small services.
22
+ - **Single writer**: one connection, one thread → no cross-thread SQLite bugs.
23
+ - **Structured messaging**: workers never touch the DB; they put typed messages on a queue.
24
+ - **Clean UX**: sticky overall bar, transient per-task bars, shared Console to prevent flicker.
25
+
26
+ ## Quickstart
27
+
28
+ ```bash
29
+ uv run pipeloom --db ./wal_demo.db --num-tasks 10 -vv
30
+ ```
31
+
32
+ ## Use in your ETL
33
+
34
+ 1. Define tasks (`TaskDef(...)`).
35
+ 2. Provide a `worker_fn(task, msg_q)` that emits:
36
+ - `MsgTaskStarted`
37
+ - `MsgTaskProgress` (at your own cadence)
38
+ - `MsgTaskFinished`
39
+ 3. Call `run_pipeline(db_path, tasks, workers=..., wal=True, store_task_status=True)`
40
+
41
+ > If you don’t want the task_runs table, pass `store_task_status=False`.
42
+
43
+ ## Design Notes
44
+
45
+ - All SQLite access happens in SQLiteWriter (one thread, one connection).
46
+ - Two progress managers:
47
+ - overall: remains after completion
48
+ - per-task: disappears when done
49
+ - We pre-register per-task bars to avoid races.
50
+ - Rich TaskID can be 0 (falsy!) — always check tid is not None.
51
+
52
+ ## Components
53
+
54
+ ```mermaid
55
+ flowchart LR
56
+ subgraph App["Your App / CLI (Typer)"]
57
+ CLI["CLI (Typer)"]
58
+ Engine["Engine (orchestrator)"]
59
+ end
60
+
61
+ subgraph Work["Worker Threads (N)"]
62
+ W1["Worker #1"]
63
+ Wn["Worker #N"]
64
+ end
65
+
66
+ subgraph Writer["SQLite Writer Thread (single owner)"]
67
+ Q["Queue[object]"]
68
+ SW["SQLiteWriter (owns one sqlite3.Connection)"]
69
+ end
70
+
71
+ subgraph DB["SQLite Database"]
72
+ WAL["db-wal / db-shm"]
73
+ Main["main.db file"]
74
+ end
75
+
76
+ CLI --> Engine
77
+ Engine -->|ThreadPoolExecutor| Work
78
+ Work -->|MsgTaskStarted/Progress/Finished| Q
79
+ Engine -->|start| SW
80
+ SW -->|consume| Q
81
+ SW -->|INSERT/UPDATE| DB
82
+ DB <--> WAL
83
+ ```
84
+
85
+ ### Why this shape works
86
+
87
+ - Only the writer thread touches SQLite — no cross-thread connections.
88
+ - WAL makes reads cheap during writes (readers don’t block while the writer appends).
89
+ - Workers talk to the writer via a simple Queue using typed messages.
90
+
91
+ ## Lifecycle
92
+
93
+ ```mermaid
94
+ sequenceDiagram
95
+ participant CLI as CLI (Typer)
96
+ participant ENG as Engine
97
+ participant TPool as ThreadPoolExecutor
98
+ participant W as Workers (N)
99
+ participant Q as Queue
100
+ participant WR as SQLiteWriter
101
+ participant DB as SQLite (WAL)
102
+
103
+ CLI->>ENG: parse args, build tasks
104
+ ENG->>WR: start (creates connection + schema)
105
+ ENG->>TPool: submit N workers
106
+ loop each task
107
+ W->>Q: MsgTaskStarted
108
+ W->>Q: MsgTaskProgress (many)
109
+ W->>Q: MsgTaskFinished
110
+ end
111
+ WR->>DB: write status/progress (optional)
112
+ ENG->>WR: put SENTINEL (shutdown request)
113
+ WR->>DB: WAL checkpoint(TRUNCATE), close
114
+ WR-->>ENG: join()
115
+ ENG-->>CLI: exit (overall bar stays at 100%)
116
+ ```
117
+
118
+ ## Writer state machine
119
+
120
+ ```mermaid
121
+ stateDiagram-v2
122
+ [*] --> Boot
123
+ Boot --> Running: open conn + init schema
124
+ Running --> Running: handle message
125
+ Running --> ShutdownRequested: receive SENTINEL
126
+ ShutdownRequested --> Teardown: ANALYZE + checkpoint + close
127
+ Teardown --> [*]
128
+ ```
129
+
130
+ ## How to extend
131
+
132
+ You’ve got two knobs:
133
+
134
+ 1. What work gets done (your worker function).
135
+ 2. What gets stored (domain tables and, optionally, task_runs).
136
+
137
+ Below are pragmatic patterns.
138
+
139
+ 1. Use your own worker function
140
+
141
+ Workers never touch SQLite. They publish messages to the queue. Replace the default with your own.
142
+
143
+ ```py
144
+ # my_pipeline.py
145
+ from pipeloom.messages import TaskDef, MsgTaskStarted, MsgTaskProgress, MsgTaskFinished
146
+ from datetime import UTC, datetime
147
+ import time
148
+
149
+ def my_worker(task: TaskDef, q) -> None:
150
+ started = datetime.now(UTC).isoformat()
151
+ q.put(MsgTaskStarted(task_id=task.task_id, name=task.name, started_at=started))
152
+
153
+ try:
154
+ # Do real work here: extract → transform → load
155
+ total = 3
156
+ # Extract
157
+ data = extract(task) # your code
158
+ q.put(MsgTaskProgress(task.task_id, 1, total, "extracted"))
159
+
160
+ # Transform
161
+ records = transform(data) # your code
162
+ q.put(MsgTaskProgress(task.task_id, 2, total, "transformed"))
163
+
164
+ # Load (NOT via SQLite here — emit a domain message instead; see below)
165
+ q.put(MsgTaskProgress(task.task_id, 3, total, "loaded"))
166
+
167
+ finished = datetime.now(UTC).isoformat()
168
+ q.put(MsgTaskFinished(task.task_id, "done", finished, result=f"ok:{task.name}"))
169
+
170
+ except Exception as e:
171
+ finished = datetime.now(UTC).isoformat()
172
+ q.put(MsgTaskFinished(task.task_id, "error", finished, message=str(e)))
173
+ ```
174
+
175
+ Then run:
176
+
177
+ ```py
178
+ from pathlib import Path
179
+ from pipeloom.engine import run_pipeline
180
+ from pipeloom.messages import TaskDef
181
+
182
+ tasks = [TaskDef(i, f"etl-{i}") for i in range(1, 11)]
183
+ run_pipeline(
184
+ db_path=Path("etl.db"),
185
+ tasks=tasks,
186
+ workers=4,
187
+ wal=True,
188
+ store_task_status=True, # keep it on for observability
189
+ worker_fn=my_worker, # <- your worker
190
+ )
191
+ ```
192
+
193
+ 2. Store your own data (domain tables)
194
+
195
+ Right now, the writer only persists task_runs (toggle with store_task_status). To persist domain data, add new message types and handle them in the writer. Keep DB writes centralized.
196
+
197
+ Define a domain message:
198
+
199
+ ```py
200
+ # my_messages.py
201
+ from dataclasses import dataclass
202
+
203
+ @dataclass(frozen=True)
204
+ class MsgUpsertRecord:
205
+ table: str
206
+ key: str
207
+ payload: dict # or a typed dataclass you marshal to a tuple
208
+ ```
209
+
210
+ Emit from your worker:
211
+
212
+ ```py
213
+ q.put(MsgUpsertRecord(table="users", key="abc123", payload={"name": "Caleb", "active": 1}))
214
+ ```
215
+
216
+ Handle in the writer:
217
+
218
+ ```py
219
+ # in your SQLiteWriter.run loop
220
+ from my_messages import MsgUpsertRecord
221
+
222
+ elif isinstance(item, MsgUpsertRecord):
223
+ self._on_upsert(item)
224
+
225
+ # and implement:
226
+ def _on_upsert(self, m: MsgUpsertRecord) -> None:
227
+ assert self._conn is not None
228
+ # Example UPSERT using a prepared SQL per table; avoid dynamic SQL when possible.
229
+ self._conn.execute(
230
+ "INSERT INTO users(key, name, active) VALUES (?, ?, ?) "
231
+ "ON CONFLICT(key) DO UPDATE SET name=excluded.name, active=excluded.active",
232
+ (m.key, m.payload["name"], m.payload["active"]),
233
+ )
234
+ self._conn.commit()
235
+ ```
236
+
237
+ Create your domain schema in a small helper or migration step:
238
+
239
+ ```sql
240
+ CREATE TABLE IF NOT EXISTS users (
241
+ key TEXT PRIMARY KEY,
242
+ name TEXT NOT NULL,
243
+ active INTEGER NOT NULL
244
+ );
245
+ ```
246
+
247
+ > [!NOTE]
248
+ > Keep schema creation in a dedicated module (like db_schema.py) and call it from the writer just after init_schema(...).
249
+
250
+ 3. Idempotency & retries (don’t overthink it)
251
+
252
+ - Use natural keys + ON CONFLICT DO UPDATE to make “retries” cheap.
253
+ - For large loads, prefer batch messages (e.g., MsgUpsertBatch) to reduce commit overhead.
254
+ - If a worker fails, you’ll still get a MsgTaskFinished(..., status="error"). You can query task_runs later and re-enqueue specific tasks.
255
+
256
+ 4. Backpressure & throughput
257
+
258
+ - The queue is bounded: maxsize=max(64, workers * 8). If you push heavy domain data, consider:
259
+ - Using batching (MsgUpsertBatch), say 1,000–10,000 rows per message.
260
+ - Increasing maxsize if writer keeps up, or reducing if you want memory pressure to be explicit.
261
+ - Keep wal_autocheckpoint=1000. For huge ingest, raise it; for tiny writes, it’s fine.
262
+
263
+ 5. Tuning SQLite (safe defaults vs. speed)
264
+
265
+ Current pragmas are conservative for ETL:
266
+
267
+ - journal_mode=WAL
268
+ - synchronous=NORMAL (fewer fsyncs; still safe enough)
269
+ - temp_store=MEMORY
270
+ - cache_size=-100000 (~100 MB)
271
+ - wal_autocheckpoint=1000
272
+
273
+ Tradeoffs:
274
+
275
+ - For maximum durability, bump to synchronous=FULL.
276
+ - For speed on scratch DBs, you can use PRAGMA synchronous=OFF (⚠️ crash can corrupt last transaction).
277
+
278
+ 6. Customize the progress UI
279
+
280
+ - Per-task bars are pre-registered to avoid races.
281
+ - You can change columns or add text with task_progress.update(tid, description="…").
282
+ - To group tasks, give names like "extract:users", "transform:users", etc.
283
+
284
+ 7. Multiprocessing or asyncio (when to switch)
285
+
286
+ - Multiprocessing: if your work is CPU-bound and the GIL bites. You’ll need:
287
+ - multiprocessing.Queue for messages.
288
+ - A small shim to update Rich progress from the main process only.
289
+ - Asyncio: if your work is network-bound and you want extreme concurrency.
290
+ - Keep the same “single-writer” idea — but the writer becomes an asyncio.Task with aiosqlite.
291
+
292
+ For most local ETL, threaded workers + single writer are enough.
293
+
294
+ 8. Observability (recommended: keep task_runs)
295
+
296
+ You’ll thank yourself later. Handy queries:
297
+
298
+ ```sql
299
+ -- Stuck or failed tasks in the last 24h
300
+ SELECT id, name, status, progress, message, started_at, finished_at
301
+ FROM task_runs
302
+ WHERE (status <> 'done' OR progress < 1.0)
303
+ AND (datetime(started_at) > datetime('now','-1 day'));
304
+
305
+ -- Histogram of statuses
306
+ SELECT status, COUNT(*) FROM task_runs GROUP BY status;
307
+ ```
308
+
309
+ If a project really doesn’t need it, set --store-task-status false (or store_task_status=False in code).
310
+
311
+ 9. Example: replace the demo with a real ETL
312
+
313
+ Worker: extract from API → transform rows → send a batch to writer.
314
+
315
+ ```py
316
+ from dataclasses import dataclass
317
+ from pipeloom.messages import MsgTaskStarted, MsgTaskProgress, MsgTaskFinished
318
+ from datetime import UTC, datetime
319
+ import time
320
+
321
+ @dataclass(frozen=True)
322
+ class MsgUpsertUsersBatch:
323
+ rows: list[tuple[str, str, int]] # key, name, active
324
+
325
+ def user_worker(task, q):
326
+ q.put(MsgTaskStarted(task.task_id, task.name, datetime.now(UTC).isoformat()))
327
+ try:
328
+ users = fetch_users_page(task.task_id) # your I/O
329
+ rows = [(u["id"], u["name"], int(u["active"])) for u in users]
330
+ q.put(MsgTaskProgress(task.task_id, 1, 2, "downloaded"))
331
+ q.put(MsgUpsertUsersBatch(rows))
332
+ q.put(MsgTaskProgress(task.task_id, 2, 2, "stored"))
333
+ q.put(MsgTaskFinished(task.task_id, "done", datetime.now(UTC).isoformat()))
334
+ except Exception as e:
335
+ q.put(MsgTaskFinished(task.task_id, "error", datetime.now(UTC).isoformat(), message=str(e)))
336
+ ```
337
+
338
+ Writer handler:
339
+
340
+ ```py
341
+ def _on_upsert_users_batch(self, m: MsgUpsertUsersBatch) -> None:
342
+ assert self._conn is not None
343
+ self._conn.executemany(
344
+ "INSERT INTO users(key, name, active) VALUES (?, ?, ?) "
345
+ "ON CONFLICT(key) DO UPDATE SET name=excluded.name, active=excluded.active",
346
+ m.rows,
347
+ )
348
+ self._conn.commit()
349
+
350
+ Wire it in the writer’s dispatch:
351
+
352
+ elif isinstance(item, MsgUpsertUsersBatch):
353
+ self._on_upsert_users_batch(item)
354
+ ```
355
+
356
+ 10. Safety checklist
357
+
358
+ - ✅ Never share a SQLite connection across threads.
359
+ - ✅ Always pre-register per-task bars; check tid is not None (TaskID 0 is valid).
360
+ - ✅ Keep one Console for Logging + Progress.
361
+ - ✅ Use UPSERT for idempotency; batch inserts when possible.
362
+ - ✅ Shut down writer before transient progress exits (to remove bars).
363
+ - ✅ Keep task_runs on unless you have a strong reason not to.
364
+
365
+ ## How to Extend
366
+
367
+ The core design is message-driven. To add new features:
368
+
369
+ 1. Add a new message type
370
+ - Create a @dataclass in messages.py representing the event.
371
+ - Example: MsgTaskError to log errors per task.
372
+ 2. Handle it in SQLiteWriter.run()
373
+ - Add a new elif isinstance(m, MsgTaskError): self._on_error(m).
374
+ 3. Update the database schema
375
+ - Modify the SQLite initialization in SQLiteWriter._init_db() to add new columns/tables.
376
+ - Consider migrations if you want backward compatibility.
377
+ 4. Update your workers to emit the new message type when appropriate.
378
+
379
+ Because the SQLiteWriter owns all database writes, you keep DB operations safe and consistent no matter how many worker threads/processes you spawn.