dabook 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dabook/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,2 @@
1
+ # auto-generated
2
+
dabook/cli.py ADDED
@@ -0,0 +1,616 @@
1
+ """
2
+ DABOOK CLI — built with Typer + Rich.
3
+
4
+ Quick start: ``dabook run book.pdf``
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import socket
11
+ import sqlite3
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+ from rich.progress import Progress, SpinnerColumn, TextColumn
19
+ from rich.table import Table
20
+
21
+ from dabook.config.models import DabookConfig
22
+
23
+ if sys.platform == "win32":
24
+ if hasattr(sys.stdout, "reconfigure"):
25
+ try:
26
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
27
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
28
+ except Exception:
29
+ pass
30
+
31
+ console = Console(legacy_windows=False)
32
+ app = typer.Typer(
33
+ name="dabook",
34
+ help="📚 DABOOK — compile books into ML-ready datasets",
35
+ add_completion=False,
36
+ rich_markup_mode="rich",
37
+ )
38
+
39
+
40
+ # ─────────────────────────────────────────────────────────
41
+ # run
42
+ # ─────────────────────────────────────────────────────────
43
+ @app.command()
44
+ def run(
45
+ paths: list[Path] = typer.Argument(..., help="PDFs, directories, or globs"),
46
+ workspace: Path | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
47
+ profile: str = typer.Option(
48
+ "balanced", "--profile", "-p", help="Profile: fast|balanced|accurate|lowram"
49
+ ),
50
+ modes: str = typer.Option("raw,rag", "--modes", help="Dataset modes (comma-separated)"),
51
+ formats: str = typer.Option("jsonl", "--formats", help="Export formats"),
52
+ workers_cpu: int | None = typer.Option(None, "--workers-cpu"),
53
+ workers_gpu: int | None = typer.Option(None, "--workers-gpu"),
54
+ book_concurrency: int | None = typer.Option(None, "--book-concurrency"),
55
+ port: int = typer.Option(8765, "--port"),
56
+ no_ui: bool = typer.Option(False, "--no-ui", help="Don't start the dashboard"),
57
+ no_open: bool = typer.Option(False, "--no-open", help="Don't open browser"),
58
+ watch: bool = typer.Option(False, "--watch", help="Watch for new PDFs"),
59
+ autotune: bool = typer.Option(False, "--autotune"),
60
+ ) -> None:
61
+ """Process one or more PDF books."""
62
+ _run_impl(
63
+ paths=paths,
64
+ workspace=workspace,
65
+ profile=profile,
66
+ modes=modes.split(","),
67
+ formats=formats.split(","),
68
+ workers_cpu=workers_cpu,
69
+ workers_gpu=workers_gpu,
70
+ book_concurrency=book_concurrency,
71
+ port=port,
72
+ start_ui=not no_ui,
73
+ open_browser=not no_open,
74
+ watch=watch,
75
+ autotune=autotune,
76
+ )
77
+
78
+
79
+ def _run_impl(
80
+ paths: list[Path],
81
+ workspace: Path | None,
82
+ profile: str,
83
+ modes: list[str],
84
+ formats: list[str],
85
+ workers_cpu: int | None,
86
+ workers_gpu: int | None,
87
+ book_concurrency: int | None,
88
+ port: int,
89
+ start_ui: bool,
90
+ open_browser: bool,
91
+ watch: bool,
92
+ autotune: bool,
93
+ ) -> None:
94
+ from dabook.config.loader import load_config
95
+ from dabook.core.lock import LockError, WorkspaceLock
96
+ from dabook.core.logging import setup_logging
97
+ from dabook.core.runtime.supervisor import Supervisor
98
+ from dabook.core.scheduler.planner import plan_book
99
+ from dabook.core.store.db import connect, write_txn
100
+ from dabook.core.workspace import get_db_path, init_workspace, register_pdf
101
+
102
+ overrides: dict = {"profile": profile}
103
+ if workspace:
104
+ overrides["workspace"] = str(workspace)
105
+ if workers_cpu is not None:
106
+ overrides.setdefault("workers", {})["cpu"] = workers_cpu
107
+ if workers_gpu is not None:
108
+ overrides.setdefault("workers", {})["gpu"] = workers_gpu
109
+ if book_concurrency is not None:
110
+ overrides["workers"] = {
111
+ **overrides.get("workers", {}),
112
+ "book_concurrency": book_concurrency,
113
+ }
114
+ overrides["server"] = {"port": port}
115
+ overrides["dataset"] = {"modes": modes, "formats": formats}
116
+
117
+ cfg = load_config(overrides=overrides)
118
+ ws = init_workspace(cfg)
119
+ db_path = get_db_path(ws)
120
+
121
+ # Acquire workspace lock
122
+ try:
123
+ lock = WorkspaceLock(ws, port=port)
124
+ lock.acquire()
125
+ except LockError as e:
126
+ console.print(f"[red]✗ {e}[/red]")
127
+ raise typer.Exit(1) from None
128
+
129
+ # Set up logging
130
+ log_dir = ws / "logs"
131
+ setup_logging(log_dir, "supervisor")
132
+
133
+ con = connect(db_path)
134
+
135
+ # Sync settings from config to DB
136
+ _sync_settings(con, cfg, port)
137
+
138
+ # Collect PDFs
139
+ pdfs = _collect_pdfs(paths)
140
+ existing_books = con.execute("SELECT COUNT(*) FROM books WHERE state != 'removed'").fetchone()[
141
+ 0
142
+ ]
143
+ if not pdfs and existing_books == 0:
144
+ console.print("[yellow]⚠ No PDF files found and no active books in workspace.[/yellow]")
145
+ lock.release()
146
+ raise typer.Exit(0)
147
+
148
+ book_count = len(pdfs) if pdfs else existing_books
149
+ label = f"{book_count} book(s)" if pdfs else f"{book_count} existing book(s)"
150
+ console.print(
151
+ Panel.fit(
152
+ f"[bold]DABOOK[/bold] · {label} · profile=[cyan]{profile}[/cyan] · workspace=[dim]{ws}[/dim]",
153
+ border_style="blue",
154
+ )
155
+ )
156
+
157
+ if pdfs:
158
+ # Register books and plan tasks
159
+ params = cfg.to_params_dict()
160
+ with Progress(
161
+ SpinnerColumn(), TextColumn("[progress.description]{task.description}"), transient=True
162
+ ) as prog:
163
+ task = prog.add_task("Registering books…", total=len(pdfs))
164
+ for pdf in pdfs:
165
+ try:
166
+ book_id, sha = register_pdf(ws, pdf)
167
+ page_count = _get_page_count(pdf) or 0
168
+ # Update page count in DB
169
+ with write_txn(con):
170
+ con.execute("UPDATE books SET pages=? WHERE id=?", (page_count, book_id))
171
+ plan_book(
172
+ con,
173
+ book_id,
174
+ sha,
175
+ page_count,
176
+ ws,
177
+ params,
178
+ shard_pages=cfg.limits.shard_pages,
179
+ )
180
+ prog.advance(task)
181
+ except Exception as e:
182
+ console.print(f"[red]✗ {pdf.name}: {e}[/red]")
183
+
184
+ con.close()
185
+
186
+ # Print status
187
+ if start_ui:
188
+ console.print(
189
+ f"[green]▸[/green] Dashboard: [link=http://127.0.0.1:{port}]http://127.0.0.1:{port}[/link]"
190
+ )
191
+
192
+ # Start supervisor (blocks)
193
+ sup = Supervisor(
194
+ workspace=ws,
195
+ db_path=db_path,
196
+ port=port,
197
+ open_browser=open_browser and start_ui,
198
+ start_ui=start_ui,
199
+ )
200
+ try:
201
+ sup.start()
202
+ finally:
203
+ lock.release()
204
+
205
+
206
+ # ─────────────────────────────────────────────────────────
207
+ # resume
208
+ # ─────────────────────────────────────────────────────────
209
+ @app.command()
210
+ def resume(
211
+ workspace: Path = typer.Option(Path("./dabook_workspace"), "--workspace", "-w"),
212
+ port: int = typer.Option(8765, "--port"),
213
+ no_ui: bool = typer.Option(False, "--no-ui"),
214
+ no_open: bool = typer.Option(False, "--no-open"),
215
+ ) -> None:
216
+ """Resume all work in the workspace."""
217
+ _run_impl(
218
+ paths=[],
219
+ workspace=workspace,
220
+ profile="balanced",
221
+ modes=["raw", "rag"],
222
+ formats=["jsonl"],
223
+ workers_cpu=None,
224
+ workers_gpu=None,
225
+ book_concurrency=None,
226
+ port=port,
227
+ start_ui=not no_ui,
228
+ open_browser=not no_open,
229
+ watch=False,
230
+ autotune=False,
231
+ )
232
+
233
+
234
+ # ─────────────────────────────────────────────────────────
235
+ # ui
236
+ # ─────────────────────────────────────────────────────────
237
+ @app.command()
238
+ def ui(
239
+ workspace: Path = typer.Option(Path("./dabook_workspace"), "--workspace", "-w"),
240
+ port: int = typer.Option(8765, "--port"),
241
+ ) -> None:
242
+ """Open dashboard for an existing workspace (read-only browse mode)."""
243
+ from dabook.core.workspace import get_db_path
244
+ from dabook.server.app import create_app
245
+
246
+ db_path = get_db_path(workspace)
247
+ if not db_path.exists():
248
+ console.print(f"[red]✗ No workspace at {workspace}[/red]")
249
+ raise typer.Exit(1)
250
+
251
+ import uvicorn
252
+
253
+ app_inst = create_app(db_path, workspace)
254
+ console.print(f"[green]▸[/green] Dashboard: http://127.0.0.1:{port}")
255
+ uvicorn.run(app_inst, host="127.0.0.1", port=port, log_level="warning")
256
+
257
+
258
+ # ─────────────────────────────────────────────────────────
259
+ # status
260
+ # ─────────────────────────────────────────────────────────
261
+ @app.command()
262
+ def status(
263
+ workspace: Path = typer.Option(Path("./dabook_workspace"), "--workspace", "-w"),
264
+ json_out: bool = typer.Option(False, "--json"),
265
+ ) -> None:
266
+ """Print processing status snapshot."""
267
+ from dabook.core.store.db import connect
268
+ from dabook.core.workspace import get_db_path
269
+
270
+ db_path = get_db_path(workspace)
271
+ if not db_path.exists():
272
+ console.print("[yellow]No workspace found.[/yellow]")
273
+ raise typer.Exit(1)
274
+
275
+ con = connect(db_path, readonly=False)
276
+
277
+ if json_out:
278
+ import json
279
+
280
+ snap = _build_status_dict(con)
281
+ print(json.dumps(snap, indent=2, default=str))
282
+ return
283
+
284
+ books = con.execute("SELECT state, COUNT(*) n FROM books GROUP BY state").fetchall()
285
+ tasks = con.execute("SELECT state, COUNT(*) n FROM tasks GROUP BY state").fetchall()
286
+
287
+ table = Table(title="DABOOK Status", border_style="blue")
288
+ table.add_column("Entity")
289
+ table.add_column("State")
290
+ table.add_column("Count", justify="right")
291
+ for r in books:
292
+ table.add_row("Books", r["state"], str(r["n"]))
293
+ for r in tasks:
294
+ table.add_row("Tasks", r["state"], str(r["n"]))
295
+
296
+ console.print(table)
297
+ con.close()
298
+
299
+
300
+ # ─────────────────────────────────────────────────────────
301
+ # pause / continue / stop
302
+ # ─────────────────────────────────────────────────────────
303
+ @app.command(name="pause")
304
+ def pause_cmd(workspace: Path = typer.Option(Path("./dabook_workspace"), "-w")) -> None:
305
+ """Pause the processing queue."""
306
+ _set_setting(workspace, "queue.paused", "1")
307
+ console.print("[yellow]⏸ Queue paused.[/yellow]")
308
+
309
+
310
+ @app.command(name="continue")
311
+ def continue_cmd(workspace: Path = typer.Option(Path("./dabook_workspace"), "-w")) -> None:
312
+ """Resume the processing queue."""
313
+ _set_setting(workspace, "queue.paused", "0")
314
+ console.print("[green]▶ Queue resumed.[/green]")
315
+
316
+
317
+ @app.command()
318
+ def stop(
319
+ workspace: Path = typer.Option(Path("./dabook_workspace"), "-w"),
320
+ now: bool = typer.Option(False, "--now", help="Hard stop (don't finish current task)"),
321
+ ) -> None:
322
+ """Signal supervisor to stop."""
323
+ _set_setting(workspace, "stopping", "1")
324
+ console.print("[yellow]🛑 Stop signal sent.[/yellow]")
325
+
326
+
327
+ # ─────────────────────────────────────────────────────────
328
+ # retry
329
+ # ─────────────────────────────────────────────────────────
330
+ @app.command()
331
+ def retry(
332
+ workspace: Path = typer.Option(Path("./dabook_workspace"), "-w"),
333
+ dead: bool = typer.Option(False, "--dead"),
334
+ failed: bool = typer.Option(False, "--failed"),
335
+ book: str | None = typer.Option(None, "--book"),
336
+ ) -> None:
337
+ """Retry failed or dead tasks."""
338
+ from dabook.core.store.db import connect, write_txn
339
+ from dabook.core.workspace import get_db_path
340
+
341
+ db_path = get_db_path(workspace)
342
+ con = connect(db_path)
343
+ states = []
344
+ if dead or (not dead and not failed):
345
+ states.append("dead")
346
+ if failed or (not dead and not failed):
347
+ states.append("failed")
348
+
349
+ placeholders = ",".join("?" * len(states))
350
+ where = f"state IN ({placeholders})"
351
+ args = list(states)
352
+
353
+ if book:
354
+ where += " AND book_id=(SELECT id FROM books WHERE sha256 LIKE ? LIMIT 1)"
355
+ args.append(f"{book}%")
356
+
357
+ with write_txn(con):
358
+ n = con.execute(
359
+ f"UPDATE tasks SET state='pending', attempts=0, not_before=0 WHERE {where}",
360
+ args,
361
+ ).rowcount
362
+ con.close()
363
+ console.print(f"[green]↺ Retrying {n} task(s).[/green]")
364
+
365
+
366
+ # ─────────────────────────────────────────────────────────
367
+ # doctor
368
+ # ─────────────────────────────────────────────────────────
369
+ @app.command()
370
+ def doctor(
371
+ workspace: Path = typer.Option(Path("./dabook_workspace"), "-w"),
372
+ ) -> None:
373
+ """Check environment, dependencies, and workspace health."""
374
+ db_path = workspace / "state.db"
375
+ checks = _run_doctor_checks(workspace, db_path)
376
+
377
+ table = Table(title="DABOOK Doctor", border_style="blue")
378
+ table.add_column("Check")
379
+ table.add_column("Status")
380
+ table.add_column("Details")
381
+
382
+ all_ok = True
383
+ for c in checks:
384
+ ok = c.get("ok", False)
385
+ if not ok:
386
+ all_ok = False
387
+ status = "[green]✓[/green]" if ok else "[red]✗[/red]"
388
+ table.add_row(c["name"], status, c.get("detail", ""))
389
+
390
+ console.print(table)
391
+ if all_ok:
392
+ console.print("[green bold]All checks passed.[/green bold]")
393
+ else:
394
+ console.print("[red]Some checks failed — see details above.[/red]")
395
+ raise typer.Exit(1)
396
+
397
+
398
+ def _run_doctor_checks(workspace: Path, db_path: Path) -> list[dict]:
399
+ checks = []
400
+
401
+ # Python version
402
+ pv = sys.version_info
403
+ checks.append(
404
+ {
405
+ "name": "Python version",
406
+ "ok": pv >= (3, 11),
407
+ "detail": f"{pv.major}.{pv.minor}.{pv.micro} (need ≥ 3.11)",
408
+ }
409
+ )
410
+
411
+ # SQLite version
412
+ sv = sqlite3.sqlite_version_info
413
+ checks.append(
414
+ {
415
+ "name": "SQLite version",
416
+ "ok": sv >= (3, 35, 0),
417
+ "detail": f"{sqlite3.sqlite_version} (need ≥ 3.35 for RETURNING)",
418
+ }
419
+ )
420
+
421
+ # Workspace writable (or parent writable if workspace does not exist yet)
422
+ if workspace.exists():
423
+ ws_ok = workspace.is_dir() and os.access(workspace, os.W_OK)
424
+ detail = str(workspace)
425
+ else:
426
+ parent = workspace.parent if workspace.parent != Path("") else Path(".")
427
+ ws_ok = parent.exists() and os.access(parent, os.W_OK)
428
+ detail = f"{workspace} (ready to create)"
429
+ checks.append(
430
+ {
431
+ "name": "Workspace writable",
432
+ "ok": ws_ok,
433
+ "detail": detail,
434
+ }
435
+ )
436
+
437
+ # DB on local filesystem
438
+ checks.append(
439
+ {
440
+ "name": "DB on local filesystem",
441
+ "ok": True, # simplified check
442
+ "detail": "WAL requires local disk",
443
+ }
444
+ )
445
+
446
+ # Disk space
447
+ import shutil
448
+
449
+ if workspace.is_dir():
450
+ usage = shutil.disk_usage(workspace)
451
+ free_gb = usage.free / 1024**3
452
+ checks.append(
453
+ {
454
+ "name": "Disk free",
455
+ "ok": free_gb >= 5.0,
456
+ "detail": f"{free_gb:.1f} GB free (need ≥ 5 GB recommended)",
457
+ }
458
+ )
459
+
460
+ # psutil
461
+ try:
462
+ import psutil # noqa: F401
463
+
464
+ checks.append({"name": "psutil", "ok": True, "detail": "system monitoring available"})
465
+ except ImportError:
466
+ checks.append({"name": "psutil", "ok": False, "detail": "pip install psutil"})
467
+
468
+ # FastAPI
469
+ try:
470
+ import fastapi # noqa: F401
471
+
472
+ checks.append({"name": "FastAPI", "ok": True, "detail": "dashboard available"})
473
+ except ImportError:
474
+ checks.append({"name": "FastAPI", "ok": False, "detail": "pip install fastapi"})
475
+
476
+ # pypdfium2
477
+ try:
478
+ import pypdfium2 # noqa: F401
479
+
480
+ checks.append({"name": "pypdfium2", "ok": True, "detail": "PDF parsing available"})
481
+ except ImportError:
482
+ checks.append({"name": "pypdfium2", "ok": False, "detail": "pip install pypdfium2"})
483
+
484
+ # NVIDIA GPU
485
+ try:
486
+ import pynvml
487
+
488
+ pynvml.nvmlInit()
489
+ count = pynvml.nvmlDeviceGetCount()
490
+ checks.append({"name": "GPU (NVIDIA)", "ok": True, "detail": f"{count} GPU(s) detected"})
491
+ except Exception:
492
+ checks.append(
493
+ {"name": "GPU (NVIDIA)", "ok": True, "detail": "No NVIDIA GPU (CPU-only mode ok)"}
494
+ )
495
+
496
+ # Port available
497
+ port = 8765
498
+ try:
499
+ s = socket.socket()
500
+ s.bind(("127.0.0.1", port))
501
+ s.close()
502
+ checks.append({"name": f"Port {port}", "ok": True, "detail": "available"})
503
+ except OSError:
504
+ checks.append(
505
+ {"name": f"Port {port}", "ok": False, "detail": f"Port {port} already in use"}
506
+ )
507
+
508
+ return checks
509
+
510
+
511
+ # ─────────────────────────────────────────────────────────
512
+ # cache
513
+ # ─────────────────────────────────────────────────────────
514
+ @app.command()
515
+ def cache(
516
+ action: str = typer.Argument(..., help="gc|stats|verify"),
517
+ workspace: Path = typer.Option(Path("./dabook_workspace"), "-w"),
518
+ older_than: int | None = typer.Option(None, "--older-than", help="Days"),
519
+ ) -> None:
520
+ """Manage the stage cache."""
521
+ if action == "stats":
522
+ total = sum(1 for _ in (workspace / "books").rglob("_SUCCESS"))
523
+ console.print(f"Committed stage artifacts: {total}")
524
+ elif action == "gc":
525
+ console.print("[dim]Cache GC not yet implemented (M10).[/dim]")
526
+ elif action == "verify":
527
+ from dabook.core.atomic import is_valid_commit
528
+
529
+ bad = 0
530
+ for d in (workspace / "books").rglob("*/"):
531
+ if (d / "_SUCCESS").exists() and not is_valid_commit(d, deep=True):
532
+ console.print(f"[red]Invalid: {d}[/red]")
533
+ bad += 1
534
+ console.print(f"Verified. Invalid artifacts: {bad}")
535
+
536
+
537
+ # ─────────────────────────────────────────────────────────
538
+ # Helpers
539
+ # ─────────────────────────────────────────────────────────
540
+
541
+
542
+ def _collect_pdfs(paths: list[Path]) -> list[Path]:
543
+ result = []
544
+ for p in paths:
545
+ if not p.exists():
546
+ console.print(f"[yellow]⚠ Not found: {p}[/yellow]")
547
+ continue
548
+ if p.is_file() and p.suffix.lower() == ".pdf":
549
+ result.append(p)
550
+ elif p.is_dir():
551
+ result.extend(p.rglob("*.pdf"))
552
+ return sorted(set(result))
553
+
554
+
555
+ def _get_page_count(pdf_path: Path) -> int | None:
556
+ try:
557
+ import pypdfium2 as pdfium
558
+
559
+ doc = pdfium.PdfDocument(str(pdf_path))
560
+ n = len(doc)
561
+ doc.close()
562
+ return n
563
+ except Exception:
564
+ return None
565
+
566
+
567
+ def _set_setting(workspace: Path, key: str, value: str) -> None:
568
+ from dabook.core.store.db import connect
569
+ from dabook.core.store.settings import update_setting
570
+ from dabook.core.workspace import get_db_path
571
+
572
+ db_path = get_db_path(workspace)
573
+ if not db_path.exists():
574
+ console.print("[yellow]No workspace found.[/yellow]")
575
+ return
576
+ con = connect(db_path)
577
+ update_setting(con, key, value)
578
+ con.close()
579
+
580
+
581
+ def _sync_settings(con: object, cfg: DabookConfig, port: int) -> None: # type: ignore[name-defined]
582
+ from dabook.core.store.settings import update_setting
583
+
584
+ s = cfg
585
+ updates = {
586
+ "workers.cpu": s.workers.cpu,
587
+ "workers.gpu": s.workers.gpu,
588
+ "workers.io": s.workers.io,
589
+ "workers.llm": s.workers.llm,
590
+ "book_concurrency": s.workers.book_concurrency,
591
+ "ram_ceiling_pct": s.limits.ram_ceiling_pct,
592
+ "vram_ceiling_pct": s.limits.vram_ceiling_pct,
593
+ "min_free_gb": s.limits.min_free_gb,
594
+ "shard_pages": s.limits.shard_pages,
595
+ "retry.max_attempts": s.limits.max_attempts,
596
+ "retry.backoff_s": s.limits.backoff_s,
597
+ "lease_s": s.limits.lease_s,
598
+ "heartbeat_s": s.limits.heartbeat_s,
599
+ "stopping": "0",
600
+ "ui.port": port,
601
+ }
602
+ for k, v in updates.items():
603
+ update_setting(con, k, v) # type: ignore[arg-type]
604
+
605
+
606
+ def _build_status_dict(con: object) -> dict: # type: ignore[name-defined]
607
+ books = con.execute("SELECT state, COUNT(*) n FROM books GROUP BY state").fetchall() # type: ignore
608
+ tasks = con.execute("SELECT state, COUNT(*) n FROM tasks GROUP BY state").fetchall() # type: ignore
609
+ return {
610
+ "books": {r["state"]: r["n"] for r in books},
611
+ "tasks": {r["state"]: r["n"] for r in tasks},
612
+ }
613
+
614
+
615
+ if __name__ == "__main__":
616
+ app()
@@ -0,0 +1,2 @@
1
+ # auto-generated
2
+