Coverage for src/lilbee/app/dataset.py: 100%
83 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Surface-neutral export/import use cases over the per-page text dataset."""
3from __future__ import annotations
5import threading
6from dataclasses import dataclass
7from pathlib import Path
8from typing import TYPE_CHECKING
10from pydantic import BaseModel
12from lilbee.app.services import get_services
13from lilbee.core.config import active_config
14from lilbee.data.export import (
15 DatasetFormat,
16 build_page_dataset,
17 decode_format,
18 deserialize_dataset,
19 import_dataset,
20 load_page_dataset,
21 resolve_format,
22 serialize_dataset,
23 write_dataset,
24)
25from lilbee.data.offload import owns_ingest_pool
26from lilbee.data.store import EmbeddingModelMismatchError, PageTextRecord
27from lilbee.runtime.lock import sync_running
28from lilbee.runtime.progress import DetailedProgressCallback, noop_callback
30if TYPE_CHECKING:
31 import pyarrow as pa
34class DatasetError(Exception):
35 """User-facing export/import failure surfaces render as-is."""
38class ExportSummary(BaseModel):
39 """Result of a path-based export."""
41 command: str = "export"
42 format: str
43 output: str
44 pages: int
45 sources: int
48class ImportSummary(BaseModel):
49 """Result of an import."""
51 command: str = "import"
52 sources: list[str]
53 pages: int
54 chunks: int
57@dataclass
58class ExportPayload:
59 """In-memory export for byte transport (HTTP download)."""
61 data: bytes
62 fmt: DatasetFormat
63 pages: int
64 sources: int
67def require_format(value: str) -> DatasetFormat:
68 """Decode an explicit *value* into a format; there is no path to infer from."""
69 if not value:
70 raise DatasetError("format is required (parquet or jsonl)")
71 try:
72 return decode_format(value)
73 except ValueError as exc:
74 raise DatasetError(str(exc)) from None
77def _build_validated(source: str | None) -> pa.Table:
78 """Build the dataset table for *source* (or all), validating the request."""
79 store = get_services().store
80 if source is not None and source not in {s["filename"] for s in store.get_sources()}:
81 raise DatasetError(f"Source not found: {source}")
82 table = build_page_dataset(store, source)
83 if table.num_rows == 0:
84 raise DatasetError("Nothing to export: the store has no indexed pages.")
85 return table
88def export_to_path(
89 output: Path,
90 fmt_value: str,
91 source: str | None,
92 *,
93 cancel: threading.Event | None = None,
94) -> ExportSummary:
95 """Write the per-page dataset to *output*; format from *fmt_value* or suffix.
97 Setting *cancel* stops between row groups and removes the partial file. The
98 table build ahead of it is a single columnar scan with no boundary to poll,
99 so the stop lands on the write rather than the read.
100 """
101 try:
102 fmt = resolve_format(fmt_value, output)
103 except ValueError as exc:
104 raise DatasetError(str(exc)) from None
105 table = _build_validated(source)
106 write_dataset(table, output, fmt, cancel)
107 return ExportSummary(
108 format=str(fmt),
109 output=str(output),
110 pages=table.num_rows,
111 sources=len(table.column("source").unique()),
112 )
115def export_to_bytes(fmt_value: str, source: str | None) -> ExportPayload:
116 """Encode the per-page dataset to bytes; empty *fmt_value* defaults to parquet."""
117 fmt = require_format(fmt_value) if fmt_value else DatasetFormat.PARQUET
118 table = _build_validated(source)
119 return ExportPayload(
120 data=serialize_dataset(table, fmt),
121 fmt=fmt,
122 pages=table.num_rows,
123 sources=len(table.column("source").unique()),
124 )
127@owns_ingest_pool
128async def _run_import(
129 rows: list[PageTextRecord], on_progress: DetailedProgressCallback
130) -> ImportSummary:
131 """Re-embed *rows* into the store under the sync mark, so a reset refuses meanwhile.
133 The import has its own ingest pool, shut down when it ends.
134 """
135 if not rows:
136 raise DatasetError("Dataset has no pages to import.")
137 store = get_services().store
138 try:
139 async with sync_running(active_config().data_root):
140 result = await import_dataset(store, rows, on_progress=on_progress)
141 except EmbeddingModelMismatchError as exc:
142 raise DatasetError(str(exc)) from None
143 return ImportSummary(sources=result.sources, pages=result.pages, chunks=result.chunks)
146async def import_from_path(
147 path: Path, fmt_value: str, on_progress: DetailedProgressCallback = noop_callback
148) -> ImportSummary:
149 """Load and import a dataset file; format from *fmt_value* or suffix."""
150 try:
151 fmt = resolve_format(fmt_value, path)
152 rows = load_page_dataset(path, fmt)
153 except ValueError as exc:
154 raise DatasetError(str(exc)) from None
155 return await _run_import(rows, on_progress)
158async def import_from_bytes(
159 data: bytes, fmt_value: str, on_progress: DetailedProgressCallback = noop_callback
160) -> ImportSummary:
161 """Decode and import dataset *data*; *fmt_value* is required (no filename)."""
162 fmt = require_format(fmt_value)
163 try:
164 rows = deserialize_dataset(data, fmt)
165 except ValueError as exc:
166 raise DatasetError(str(exc)) from None
167 return await _run_import(rows, on_progress)