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.
- pipeloom-0.0.1/PKG-INFO +379 -0
- pipeloom-0.0.1/README.md +365 -0
- pipeloom-0.0.1/pipeloom/__init__.py +75 -0
- pipeloom-0.0.1/pipeloom/cli.py +73 -0
- pipeloom-0.0.1/pipeloom/db.py +94 -0
- pipeloom-0.0.1/pipeloom/engine.py +175 -0
- pipeloom-0.0.1/pipeloom/messages.py +82 -0
- pipeloom-0.0.1/pipeloom/progress.py +83 -0
- pipeloom-0.0.1/pipeloom/rlog.py +64 -0
- pipeloom-0.0.1/pipeloom/writer.py +194 -0
- pipeloom-0.0.1/pipeloom.egg-info/PKG-INFO +379 -0
- pipeloom-0.0.1/pipeloom.egg-info/SOURCES.txt +25 -0
- pipeloom-0.0.1/pipeloom.egg-info/dependency_links.txt +1 -0
- pipeloom-0.0.1/pipeloom.egg-info/entry_points.txt +2 -0
- pipeloom-0.0.1/pipeloom.egg-info/requires.txt +2 -0
- pipeloom-0.0.1/pipeloom.egg-info/top_level.txt +1 -0
- pipeloom-0.0.1/pyproject.toml +129 -0
- pipeloom-0.0.1/setup.cfg +4 -0
- pipeloom-0.0.1/tests/test_smoke.py +11 -0
pipeloom-0.0.1/PKG-INFO
ADDED
|
@@ -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.
|