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

Potentially problematic release.


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

pipeloom/__init__.py ADDED
@@ -0,0 +1,75 @@
1
+ """
2
+ pipeloom
3
+ =======
4
+
5
+ Reusable scaffolding for ETL-style, multi-threaded pipelines with:
6
+
7
+ - One SQLite writer thread in WAL mode
8
+ - Workers publishing progress/results over a Queue
9
+ - Rich progress (sticky overall + transient per-task)
10
+ - Typer CLI
11
+
12
+ Top-level exports are provided for convenience so you can do:
13
+
14
+ from pipeloom import (
15
+ run_pipeline,
16
+ SQLiteWriter,
17
+ TaskDef,
18
+ MsgTaskStarted, # or alias: MsgTaskStart
19
+ MsgTaskProgress,
20
+ MsgTaskFinished,
21
+ SENTINEL,
22
+ make_overall_progress,
23
+ make_task_progress,
24
+ preregister_task_bars,
25
+ console,
26
+ logger
27
+ )
28
+ """
29
+
30
+ from .engine import run_pipeline
31
+ from .messages import (
32
+ SENTINEL,
33
+ MsgTaskFinished,
34
+ MsgTaskProgress,
35
+ MsgTaskStarted,
36
+ TaskDef,
37
+ )
38
+ from .progress import (
39
+ make_overall_progress,
40
+ make_task_progress,
41
+ preregister_task_bars,
42
+ )
43
+ from .rlog import (
44
+ console,
45
+ logger,
46
+ setup_logging,
47
+ ) # shared Console and logger, useful for advanced usage
48
+ from .writer import SQLiteWriter
49
+
50
+ # Ergonomic alias to match common naming used by callers
51
+ MsgTaskStart = MsgTaskStarted
52
+
53
+ __all__ = [
54
+ # Core orchestration
55
+ "run_pipeline",
56
+ # Writer
57
+ "SQLiteWriter",
58
+ # Messages
59
+ "TaskDef",
60
+ "MsgTaskStarted",
61
+ "MsgTaskStart", # alias
62
+ "MsgTaskProgress",
63
+ "MsgTaskFinished",
64
+ "SENTINEL",
65
+ # Progress helpers
66
+ "make_overall_progress",
67
+ "make_task_progress",
68
+ "preregister_task_bars",
69
+ # Advanced: shared Console
70
+ "console",
71
+ "logger",
72
+ "setup_logging",
73
+ ]
74
+
75
+ __version__ = "0.0.1"
pipeloom/cli.py ADDED
@@ -0,0 +1,73 @@
1
+ """
2
+ cli.py
3
+ ======
4
+
5
+ Typer-powered CLI that exposes a simple `pipeloom demo` command and serves as
6
+ a blueprint for wiring your own task definitions and worker functions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import os
13
+ from pathlib import Path
14
+
15
+ import typer
16
+
17
+ from .engine import run_pipeline
18
+ from .messages import TaskDef
19
+ from .rlog import setup_logging
20
+
21
+ LOG = logging.getLogger("pipeloom")
22
+
23
+ app = typer.Typer(
24
+ help="Threaded ETL scaffolding with a single-writer SQLite (WAL).",
25
+ add_completion=False,
26
+ rich_markup_mode="rich",
27
+ )
28
+
29
+
30
+ @app.command(name="demo")
31
+ def demo(
32
+ db: Path = typer.Option(Path("./wal_demo.db"), help="SQLite DB path."),
33
+ wal: bool = typer.Option(True, help="Use SQLite WAL mode."),
34
+ num_tasks: int = typer.Option(20, min=1, help="How many demo tasks to run."),
35
+ workers: int = typer.Option(
36
+ os.cpu_count() or 4, min=1, help="Max concurrent worker threads."
37
+ ),
38
+ verbose: int = typer.Option(
39
+ 1, "--verbose", "-v", count=True, help="Increase log verbosity (-v, -vv)."
40
+ ),
41
+ log_file: Path | None = typer.Option(None, help="Optional log file path."),
42
+ store_task_status: bool = typer.Option(
43
+ True,
44
+ help="Persist task status to SQLite (disable to only use DB for domain data).",
45
+ ),
46
+ ) -> None:
47
+ """
48
+ Run a demonstration pipeline that exercises the entire stack:
49
+
50
+ - Worker threads emit progress and results to a Queue
51
+ - A single writer thread updates SQLite in WAL mode
52
+ - Rich renders per-task and overall progress cleanly
53
+ """
54
+ setup_logging(verbose, log_file)
55
+ LOG.info(
56
+ "Starting demo (tasks=%s, workers=%s, WAL=%s) → DB: %s",
57
+ num_tasks,
58
+ workers,
59
+ wal,
60
+ db,
61
+ )
62
+
63
+ tasks = [
64
+ TaskDef(task_id=i, name=f"task-{i}", steps=20) for i in range(1, num_tasks + 1)
65
+ ]
66
+
67
+ run_pipeline(
68
+ db_path=db,
69
+ tasks=tasks,
70
+ workers=workers,
71
+ wal=wal,
72
+ store_task_status=store_task_status,
73
+ )
pipeloom/db.py ADDED
@@ -0,0 +1,94 @@
1
+ """
2
+ db.py
3
+ =====
4
+
5
+ Low-level, thread-bound SQLite helpers (connection + schema + checkpoint).
6
+
7
+ Design rules:
8
+ -------------
9
+ - Create **one** connection in the writer thread; never share it across threads.
10
+ - Prefer WAL for on-disk DBs to allow concurrent readers during writes.
11
+ - Keep pragmas pragmatic: good performance without wrecking durability.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sqlite3
17
+ from pathlib import Path
18
+ from typing import Literal
19
+
20
+
21
+ def connect(db_path: Path, *, wal: bool = True) -> sqlite3.Connection:
22
+ """
23
+ Create and configure a **thread-bound** SQLite connection.
24
+
25
+ Must be called from the writer thread. The connection is not safe to pass
26
+ to worker threads.
27
+
28
+ Parameters
29
+ ----------
30
+ db_path
31
+ Path to SQLite database (may be :memory:).
32
+ wal
33
+ Enable WAL mode for file-backed databases.
34
+
35
+ Returns
36
+ -------
37
+ sqlite3.Connection
38
+ """
39
+ conn = sqlite3.connect(db_path, timeout=60, check_same_thread=True)
40
+
41
+ # Journal mode:
42
+ if db_path == Path(":memory:"):
43
+ conn.execute("PRAGMA journal_mode=MEMORY")
44
+ else:
45
+ conn.execute(f"PRAGMA journal_mode={'WAL' if wal else 'DELETE'}")
46
+
47
+ # Pragmas: balanced defaults suitable for ETL-like workloads.
48
+ conn.execute("PRAGMA synchronous=NORMAL")
49
+ conn.execute("PRAGMA foreign_keys=ON")
50
+ conn.execute("PRAGMA temp_store=MEMORY")
51
+ conn.execute("PRAGMA cache_size=-100000") # ~100MB page cache (negative means KB)
52
+ conn.execute("PRAGMA wal_autocheckpoint=1000") # periodic auto-checkpoints
53
+
54
+ return conn
55
+
56
+
57
+ def init_schema(conn: sqlite3.Connection, *, store_task_status: bool) -> None:
58
+ """
59
+ Create optional tables used for observability (task_runs).
60
+
61
+ If you set `store_task_status=False`, this becomes a no-op and the DB is
62
+ entirely yours for domain tables (e.g., ETL outputs).
63
+ """
64
+ if not store_task_status:
65
+ return
66
+ conn.executescript(
67
+ """
68
+ CREATE TABLE IF NOT EXISTS task_runs (
69
+ id INTEGER PRIMARY KEY,
70
+ name TEXT NOT NULL,
71
+ status TEXT NOT NULL, -- queued|running|done|error|cancelled
72
+ progress REAL NOT NULL DEFAULT 0.0, -- fraction 0..1
73
+ message TEXT,
74
+ started_at TEXT,
75
+ finished_at TEXT,
76
+ result TEXT
77
+ );
78
+ CREATE INDEX IF NOT EXISTS idx_task_runs_status ON task_runs(status);
79
+ """
80
+ )
81
+ conn.commit()
82
+
83
+
84
+ def wal_checkpoint(
85
+ conn: sqlite3.Connection,
86
+ mode: Literal["PASSIVE", "FULL", "RESTART", "TRUNCATE"] = "TRUNCATE",
87
+ ) -> None:
88
+ """
89
+ Manually checkpoint the WAL into the main DB and (optionally) truncate -wal.
90
+
91
+ Useful at shutdown or between phases to keep the main file tidy.
92
+ """
93
+ conn.commit()
94
+ conn.execute(f"PRAGMA wal_checkpoint({mode});").fetchone()
pipeloom/engine.py ADDED
@@ -0,0 +1,175 @@
1
+ """
2
+ engine.py
3
+ =========
4
+
5
+ Pipeline orchestration:
6
+
7
+ - Builds two Rich progress managers (overall sticky / per-task transient).
8
+ - Pre-registers per-task bars to avoid UI races.
9
+ - Starts the SQLite writer thread.
10
+ - Submits work to a ThreadPoolExecutor.
11
+ - Signals the writer with SENTINEL and joins while the transient progress
12
+ is still live, so bars vanish before the final frame is rendered.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import queue
19
+ import random
20
+ import signal
21
+ import threading
22
+ import time
23
+ from collections.abc import Callable, Iterable
24
+ from concurrent.futures import ThreadPoolExecutor, as_completed
25
+ from datetime import UTC, datetime
26
+ from pathlib import Path
27
+
28
+ from .messages import (
29
+ SENTINEL,
30
+ MsgTaskFinished,
31
+ MsgTaskProgress,
32
+ MsgTaskStarted,
33
+ TaskDef,
34
+ )
35
+ from .progress import make_overall_progress, make_task_progress, preregister_task_bars
36
+ from .writer import SQLiteWriter
37
+
38
+ LOG = logging.getLogger("pipeloom.engine")
39
+
40
+
41
+ def default_worker(task: TaskDef, msg_q: queue.Queue[object]) -> None:
42
+ """
43
+ Demo worker function.
44
+ Replace this in your projects with real work (ETL steps, API calls, etc.).
45
+ """
46
+ started = datetime.now(UTC).isoformat()
47
+ msg_q.put(MsgTaskStarted(task_id=task.task_id, name=task.name, started_at=started))
48
+ try:
49
+ for step in range(1, task.steps + 1):
50
+ time.sleep(0.05 + random.random() * 0.05) # simulate variable work
51
+ msg_q.put(
52
+ MsgTaskProgress(
53
+ task_id=task.task_id,
54
+ step=step,
55
+ total=task.steps,
56
+ message=f"step {step}/{task.steps}",
57
+ )
58
+ )
59
+ finished = datetime.now(UTC).isoformat()
60
+ msg_q.put(
61
+ MsgTaskFinished(
62
+ task_id=task.task_id,
63
+ status="done",
64
+ finished_at=finished,
65
+ result=f"ok:{task.name}",
66
+ message="completed",
67
+ )
68
+ )
69
+ except Exception as e:
70
+ finished = datetime.now(UTC).isoformat()
71
+ msg_q.put(
72
+ MsgTaskFinished(
73
+ task_id=task.task_id,
74
+ status="error",
75
+ finished_at=finished,
76
+ result=None,
77
+ message=str(e),
78
+ )
79
+ )
80
+
81
+
82
+ def run_pipeline(
83
+ db_path: Path,
84
+ tasks: Iterable[TaskDef],
85
+ *,
86
+ workers: int,
87
+ wal: bool = True,
88
+ store_task_status: bool = True,
89
+ worker_fn: Callable[[TaskDef, queue.Queue[object]], None] = default_worker,
90
+ ) -> None:
91
+ """
92
+ Execute an ETL-style workload using a single-writer SQLite backend.
93
+
94
+ Parameters
95
+ ----------
96
+ db_path
97
+ Target SQLite database path.
98
+ tasks
99
+ Iterable of TaskDef instances (one per unit of work).
100
+ workers
101
+ Maximum number of concurrent worker threads.
102
+ wal
103
+ Enable WAL mode for the writer (recommended for file-backed DBs).
104
+ store_task_status
105
+ If True, create and maintain the `task_runs` table for observability.
106
+ worker_fn
107
+ Callable invoked for each TaskDef (signature: (task, queue) -> None).
108
+
109
+ Notes
110
+ -----
111
+ The writer thread is shut down *before* the per-task progress context exits,
112
+ guaranteeing that all bars are removed and the final frame shows only the
113
+ “All tasks 100%” line.
114
+ """
115
+ all_tasks = list(tasks)
116
+ n = len(all_tasks)
117
+
118
+ # Workers → Queue → Writer
119
+ msg_q: queue.Queue[object] = queue.Queue(maxsize=max(64, workers * 8))
120
+
121
+ # Gentle shutdown: we don't hard-kill threads; we let in-flight tasks finish.
122
+ stop_event = threading.Event()
123
+
124
+ def handle_sigint(signum, frame): # type: ignore[override]
125
+ LOG.warning("SIGINT received; finishing in-flight tasks, then exiting…")
126
+ stop_event.set()
127
+
128
+ signal.signal(signal.SIGINT, handle_sigint)
129
+
130
+ # Build progress managers that share the same Console (see rlog.py).
131
+ overall_p = make_overall_progress()
132
+ task_p = make_task_progress()
133
+
134
+ with overall_p:
135
+ overall = overall_p.add_task("[cyan]All tasks", total=n)
136
+ with task_p:
137
+ # Pre-register bars to avoid creation races.
138
+ bar_map = preregister_task_bars(task_p, n)
139
+
140
+ # Start the writer *after* bars are created, passing the mapping.
141
+ writer = SQLiteWriter(
142
+ db_path=db_path,
143
+ msg_q=msg_q,
144
+ wal=wal,
145
+ store_task_status=store_task_status,
146
+ task_progress=task_p,
147
+ task_bar_map=bar_map,
148
+ )
149
+ writer.start()
150
+
151
+ start = time.time()
152
+
153
+ # Submit work to a thread pool.
154
+ with ThreadPoolExecutor(
155
+ max_workers=workers, thread_name_prefix="worker"
156
+ ) as ex:
157
+ futures = [ex.submit(worker_fn, t, msg_q) for t in all_tasks]
158
+ try:
159
+ for fut in as_completed(futures):
160
+ _ = fut.result() # surface exceptions
161
+ overall_p.advance(
162
+ overall, 1
163
+ ) # advance only when a task finishes
164
+ if stop_event.is_set():
165
+ break
166
+ except KeyboardInterrupt:
167
+ LOG.warning("KeyboardInterrupt; letting in-flight tasks finish…")
168
+
169
+ # Shut down writer while the transient progress is still alive
170
+ # so the last bar removal appears before the final frame.
171
+ msg_q.put(SENTINEL)
172
+ writer.join(timeout=30)
173
+ task_p.refresh()
174
+
175
+ LOG.info("Elapsed: %.2fs", time.time() - start)
pipeloom/messages.py ADDED
@@ -0,0 +1,82 @@
1
+ """
2
+ messages.py
3
+ ===========
4
+
5
+ Typed message contracts used on the in-memory **Queue** between worker threads
6
+ and the single SQLite writer thread.
7
+
8
+ Why messages?
9
+ -------------
10
+ Workers should **never** touch SQLite directly (SQLite connections are not
11
+ thread-safe across threads). Instead, workers publish *intent* via small,
12
+ immutable dataclasses. The writer is the only component that owns a SQLite
13
+ connection and it executes the requested changes in a single, serialized place.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class TaskDef:
23
+ """
24
+ Static definition of a unit of work.
25
+
26
+ Attributes
27
+ ----------
28
+ task_id:
29
+ Stable numeric identifier for the task. Used as the primary key in
30
+ SQLite and as the key for progress bar lookups.
31
+ name:
32
+ Display-friendly name shown in logs and progress UI.
33
+ steps:
34
+ Number of progress steps the *demo* worker will simulate.
35
+ In real code, you can ignore this and emit MsgTaskProgress at your own cadence.
36
+ """
37
+
38
+ task_id: int
39
+ name: str
40
+ steps: int = 20
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class MsgTaskStarted:
45
+ """
46
+ Signal that a task has begun.
47
+ Posted by a worker thread as soon as the task is admitted for work.
48
+ """
49
+
50
+ task_id: int
51
+ name: str
52
+ started_at: str # ISO 8601 UTC string (avoid tz-naive datetimes over queues)
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class MsgTaskProgress:
57
+ """
58
+ Incremental progress signal.
59
+ The writer translates this into a fractional progress column and updates the Rich bar.
60
+ """
61
+
62
+ task_id: int
63
+ step: int
64
+ total: int
65
+ message: str = ""
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class MsgTaskFinished:
70
+ """
71
+ Final status + optional result payload for a completed or failed task.
72
+ """
73
+
74
+ task_id: int
75
+ status: str # "done" | "error" | "cancelled"
76
+ finished_at: str # ISO 8601 UTC string
77
+ result: str | None = None
78
+ message: str = ""
79
+
80
+
81
+ # Special object placed on the queue to request the writer to shut down cleanly.
82
+ SENTINEL: object = object()
pipeloom/progress.py ADDED
@@ -0,0 +1,83 @@
1
+ """
2
+ progress.py
3
+ ===========
4
+
5
+ Factory helpers for Rich progress managers.
6
+
7
+ We intentionally separate **overall** progress (sticky, remains visible at 100%)
8
+ from **per-task** progress (transient, disappears once all tasks complete).
9
+ Both share the same Console (see rlog.console).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from rich.progress import (
15
+ BarColumn,
16
+ MofNCompleteColumn,
17
+ Progress,
18
+ SpinnerColumn,
19
+ TaskID,
20
+ TaskProgressColumn,
21
+ TimeElapsedColumn,
22
+ TimeRemainingColumn,
23
+ )
24
+
25
+ from .rlog import console
26
+
27
+
28
+ def make_overall_progress() -> Progress:
29
+ """
30
+ Create the overall persistent progress manager.
31
+
32
+ This remains visible after completion and is ideal for a single “All tasks: 100%”
33
+ summary line once per-task bars disappear.
34
+ """
35
+ return Progress(
36
+ SpinnerColumn(),
37
+ "[progress.description]{task.description}",
38
+ BarColumn(),
39
+ TaskProgressColumn(),
40
+ MofNCompleteColumn(),
41
+ "•",
42
+ TimeElapsedColumn(),
43
+ "•",
44
+ TimeRemainingColumn(),
45
+ transient=False, # keep final frame visible
46
+ console=console,
47
+ refresh_per_second=8, # throttle repaints to avoid log spam jitter
48
+ disable=not console.is_terminal,
49
+ )
50
+
51
+
52
+ def make_task_progress() -> Progress:
53
+ """
54
+ Create the transient per-task progress manager.
55
+
56
+ This hides when the context exits so your terminal remains tidy.
57
+ """
58
+ return Progress(
59
+ SpinnerColumn(),
60
+ "[progress.description]{task.description}",
61
+ BarColumn(),
62
+ TaskProgressColumn(),
63
+ TimeElapsedColumn(),
64
+ transient=True, # auto-hide on exit
65
+ console=console,
66
+ refresh_per_second=8,
67
+ disable=not console.is_terminal,
68
+ )
69
+
70
+
71
+ def preregister_task_bars(task_progress: Progress, num_tasks: int) -> dict[int, TaskID]:
72
+ """
73
+ Pre-create one Rich bar per task and return a mapping {task_id -> TaskID}.
74
+
75
+ Why up-front?
76
+ -------------
77
+ If a worker publishes a MsgTaskProgress before the writer has created the bar,
78
+ updates can be lost. Pre-registering eliminates this race completely.
79
+ """
80
+ mapping: dict[int, TaskID] = {}
81
+ for i in range(1, num_tasks + 1):
82
+ mapping[i] = task_progress.add_task(f"[bold]task-{i}", total=100)
83
+ return mapping
pipeloom/rlog.py ADDED
@@ -0,0 +1,64 @@
1
+ """
2
+ rlog.py
3
+ =======
4
+
5
+ Rich-aware logging. We create **one Console** and share it between RichHandler
6
+ (logging) and Rich Progress (live bars). Without a shared console, Rich would
7
+ render duplicate live regions and your terminal would flicker or show
8
+ repeated “All tasks” bars.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from pathlib import Path
15
+
16
+ from rich.console import Console
17
+ from rich.logging import RichHandler
18
+
19
+ # Single shared console for logs and progress.
20
+ console = Console(log_path=False)
21
+
22
+ logger = logging.getLogger("pipeloom")
23
+
24
+
25
+ def setup_logging(verbose: int, log_file: Path | None = None) -> None:
26
+ """
27
+ Configure logging.
28
+
29
+ Parameters
30
+ ----------
31
+ verbose
32
+ 0 = WARNING, 1 = INFO, 2+ = DEBUG
33
+ log_file
34
+ If provided, a plain (non-Rich) file handler is added for CI/grep.
35
+ """
36
+ level = (
37
+ logging.WARNING
38
+ if verbose <= 0
39
+ else logging.INFO if verbose == 1 else logging.DEBUG
40
+ )
41
+ handlers: list[logging.Handler] = [
42
+ RichHandler(
43
+ rich_tracebacks=True,
44
+ show_time=False,
45
+ show_level=True,
46
+ show_path=False,
47
+ console=console, # IMPORTANT: share with Progress
48
+ markup=True,
49
+ )
50
+ ]
51
+
52
+ if log_file:
53
+ fh = logging.FileHandler(log_file, encoding="utf-8")
54
+ fh.setLevel(level)
55
+ fh.setFormatter(
56
+ logging.Formatter(
57
+ fmt="%(asctime)s %(levelname)s [%(name)s] %(message)s",
58
+ datefmt="%Y-%m-%d %H:%M:%S",
59
+ )
60
+ )
61
+ handlers.append(fh)
62
+
63
+ logging.basicConfig(level=level, format="%(message)s", handlers=handlers)
64
+ logger.setLevel(level)
pipeloom/writer.py ADDED
@@ -0,0 +1,194 @@
1
+ """
2
+ writer.py
3
+ =========
4
+
5
+ The **single** SQLite writer thread. It:
6
+
7
+ - Opens and owns the only SQLite connection (configured for WAL).
8
+ - Consumes message objects from a thread-safe Queue.
9
+ - Writes task status/progress to SQLite (optional).
10
+ - Updates/removes Rich per-task progress bars safely.
11
+
12
+ Why a single writer?
13
+ --------------------
14
+ SQLite allows multiple writers *serially*, but a single connection and a single
15
+ owning thread is the least surprising, highest-reliability approach. It also
16
+ avoids cross-thread connection misuse, which is a common source of bugs.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import queue
23
+ import sqlite3
24
+ import threading
25
+
26
+ from rich.progress import Progress, TaskID
27
+
28
+ from . import db as dbmod
29
+ from .messages import SENTINEL, MsgTaskFinished, MsgTaskProgress, MsgTaskStarted
30
+
31
+ LOG = logging.getLogger("pipeloom.writer")
32
+
33
+
34
+ class SQLiteWriter(threading.Thread):
35
+ """
36
+ Dedicated thread that exclusively writes to SQLite and manages per-task bars.
37
+
38
+ Parameters
39
+ ----------
40
+ db_path
41
+ Path to the SQLite database file (or ':memory:').
42
+ msg_q
43
+ Thread-safe queue from which this writer consumes message objects.
44
+ wal
45
+ Whether to enable WAL on file-backed databases.
46
+ store_task_status
47
+ Toggle persistence of task status into the `task_runs` table.
48
+ task_progress
49
+ Rich Progress manager used for per-task bars (transient).
50
+ task_bar_map
51
+ Pre-registered mapping: task_id -> Rich TaskID (prevents render races).
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ db_path,
57
+ msg_q: queue.Queue[object],
58
+ *,
59
+ wal: bool = True,
60
+ store_task_status: bool = True,
61
+ task_progress: Progress | None = None,
62
+ task_bar_map: dict[int, TaskID] | None = None,
63
+ ) -> None:
64
+ super().__init__(daemon=True)
65
+ self.db_path = db_path
66
+ self.msg_q = msg_q
67
+ self._use_wal = wal
68
+ self._store = store_task_status
69
+ self._progress = task_progress
70
+ self._progress_tasks: dict[int, TaskID] = dict(task_bar_map or {})
71
+ self._conn: sqlite3.Connection | None = None
72
+ self._stop = threading.Event()
73
+
74
+ # --- message handlers --------------------------------------------------------------
75
+ def _on_started(self, m: MsgTaskStarted) -> None:
76
+ """Persist that a task has started (if enabled)."""
77
+ assert self._conn is not None
78
+ if self._store:
79
+ self._conn.execute(
80
+ """
81
+ INSERT INTO task_runs (id,name,status,progress,started_at,message)
82
+ VALUES (?,?, 'running', 0.0, ?, '')
83
+ ON CONFLICT(id) DO UPDATE SET
84
+ name=excluded.name, status='running', progress=0.0, started_at=excluded.started_at, message=''
85
+ """,
86
+ (m.task_id, m.name, m.started_at),
87
+ )
88
+ self._conn.commit()
89
+
90
+ def _on_progress(self, m: MsgTaskProgress) -> None:
91
+ """Update DB progress as fraction and drive the Rich bar."""
92
+ assert self._conn is not None
93
+ pct = round(100.0 * (m.step / max(1, m.total)), 2)
94
+
95
+ if self._store:
96
+ self._conn.execute(
97
+ "UPDATE task_runs SET progress=?, message=? WHERE id=?",
98
+ (pct / 100.0, m.message, m.task_id),
99
+ )
100
+ self._conn.commit()
101
+
102
+ # IMPORTANT: Rich TaskID can be 0; do not use `if tid:` or walrus in condition.
103
+ if self._progress:
104
+ tid = self._progress_tasks.get(m.task_id)
105
+ if tid is not None:
106
+ self._progress.update(tid, completed=int(pct))
107
+
108
+ def _on_finished(self, m: MsgTaskFinished) -> None:
109
+ """Write final status and remove the per-task bar cleanly."""
110
+ assert self._conn is not None
111
+ if self._store:
112
+ final = 1.0 if m.status == "done" else 0.0
113
+ self._conn.execute(
114
+ """
115
+ UPDATE task_runs SET status=?, finished_at=?, progress=?, result=?, message=? WHERE id=?
116
+ """,
117
+ (m.status, m.finished_at, final, m.result, m.message, m.task_id),
118
+ )
119
+ self._conn.commit()
120
+
121
+ if self._progress:
122
+ tid = self._progress_tasks.pop(m.task_id, None)
123
+ if tid is not None:
124
+ self._progress.update(tid, completed=100)
125
+ self._progress.remove_task(tid)
126
+ self._progress.refresh()
127
+
128
+ # --- thread run loop ---------------------------------------------------------------
129
+ def run(self) -> None:
130
+ """
131
+ Lifecycle:
132
+ - open connection
133
+ - initialize schema (optional)
134
+ - consume messages until SENTINEL arrives
135
+ - checkpoint and close
136
+ """
137
+ try:
138
+ self._conn = dbmod.connect(self.db_path, wal=self._use_wal)
139
+ dbmod.init_schema(self._conn, store_task_status=self._store)
140
+ LOG.info("DB writer started → %s (WAL=%s)", self.db_path, self._use_wal)
141
+
142
+ while not self._stop.is_set():
143
+ try:
144
+ item = self.msg_q.get(
145
+ timeout=0.5
146
+ ) # small timeout to enable graceful exit
147
+ except queue.Empty:
148
+ continue
149
+
150
+ if item is SENTINEL:
151
+ self.msg_q.task_done()
152
+ break
153
+
154
+ if isinstance(item, MsgTaskStarted):
155
+ self._on_started(item)
156
+ elif isinstance(item, MsgTaskProgress):
157
+ self._on_progress(item)
158
+ elif isinstance(item, MsgTaskFinished):
159
+ self._on_finished(item)
160
+ else:
161
+ LOG.warning("Unknown message: %r", type(item))
162
+
163
+ self.msg_q.task_done()
164
+
165
+ finally:
166
+ # Best-effort cleanup & checkpoint
167
+ try:
168
+ if self._conn:
169
+ self._conn.execute("ANALYZE;")
170
+ except Exception:
171
+ LOG.debug("ANALYZE failed", exc_info=True)
172
+
173
+ if self._conn:
174
+ with _Suppress(sqlite3.OperationalError):
175
+ dbmod.wal_checkpoint(self._conn, "TRUNCATE")
176
+ self._conn.close()
177
+ self._conn = None
178
+
179
+ LOG.info("DB writer stopped")
180
+
181
+
182
+ class _Suppress:
183
+ """Minimal suppress helper to avoid importing contextlib."""
184
+
185
+ def __init__(self, *exc_types):
186
+ self.exc_types = exc_types
187
+
188
+ def __enter__(self):
189
+ return None
190
+
191
+ def __exit__(self, exc_type, exc, tb):
192
+ return exc_type is not None and issubclass(
193
+ exc_type, self.exc_types or (Exception,)
194
+ )
@@ -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.
@@ -0,0 +1,13 @@
1
+ pipeloom/__init__.py,sha256=QWvMcKQvllkUkHYpwfEwldWvs4DGSm4ib26udHjqCE0,1591
2
+ pipeloom/cli.py,sha256=ab4EAHymYQd7sowkb58iKT8SeqPkelOMFokbvQZNLFA,1998
3
+ pipeloom/db.py,sha256=7J2TaR4JTfqLtlHeEJSMJG7bmRMkUtjbShEccPgSi4w,2801
4
+ pipeloom/engine.py,sha256=kQKSWl7lkhpz876twLeycep905sijaZmZtgnuj23Z28,5652
5
+ pipeloom/messages.py,sha256=TYDhOYBs6TMjStvIYYaNsitwXhPatCVR1LHKQJ2MjAA,2071
6
+ pipeloom/progress.py,sha256=4HzPLMnvLm2WDUK3oBV9F1rPi4FgwsVqPc-btYRGCsU,2302
7
+ pipeloom/rlog.py,sha256=bQymOyvChTA_XQX80K6fE0vEtsQw5y5zDczlR1C1qBg,1690
8
+ pipeloom/writer.py,sha256=toDXDsInvNho0CU2ct8joUGQCpTFGCH4uAPi-RBKmHk,6676
9
+ pipeloom-0.0.1.dist-info/METADATA,sha256=C7e-X_qLVwUDvHlOZKu8JzutLA33h7VKGJqY_5pW3mU,12712
10
+ pipeloom-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ pipeloom-0.0.1.dist-info/entry_points.txt,sha256=KswZgdPH8r_naw3yTUhzyL9CLY8wow4LQ6mB_R_N_0U,46
12
+ pipeloom-0.0.1.dist-info/top_level.txt,sha256=FpG3qrEqzP846AnHvqAhvdeLosylZrsUJsi7KjtKmaM,9
13
+ pipeloom-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pipeloom = pipeloom.cli:app
@@ -0,0 +1 @@
1
+ pipeloom