Coverage for src/lilbee/app/dataset.py: 100%
78 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +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.data.export import (
14 DatasetFormat,
15 build_page_dataset,
16 decode_format,
17 deserialize_dataset,
18 import_dataset,
19 load_page_dataset,
20 resolve_format,
21 serialize_dataset,
22 write_dataset,
23)
24from lilbee.data.store import EmbeddingModelMismatchError, PageTextRecord
25from lilbee.runtime.progress import DetailedProgressCallback, noop_callback
27if TYPE_CHECKING:
28 import pyarrow as pa
31class DatasetError(Exception):
32 """User-facing export/import failure surfaces render as-is."""
35class ExportSummary(BaseModel):
36 """Result of a path-based export."""
38 command: str = "export"
39 format: str
40 output: str
41 pages: int
42 sources: int
45class ImportSummary(BaseModel):
46 """Result of an import."""
48 command: str = "import"
49 sources: list[str]
50 pages: int
51 chunks: int
54@dataclass
55class ExportPayload:
56 """In-memory export for byte transport (HTTP download)."""
58 data: bytes
59 fmt: DatasetFormat
60 pages: int
61 sources: int
64def require_format(value: str) -> DatasetFormat:
65 """Decode an explicit *value* into a format; there is no path to infer from."""
66 if not value:
67 raise DatasetError("format is required (parquet or jsonl)")
68 try:
69 return decode_format(value)
70 except ValueError as exc:
71 raise DatasetError(str(exc)) from None
74def _build_validated(source: str | None) -> pa.Table:
75 """Build the dataset table for *source* (or all), validating the request."""
76 store = get_services().store
77 if source is not None and source not in {s["filename"] for s in store.get_sources()}:
78 raise DatasetError(f"Source not found: {source}")
79 table = build_page_dataset(store, source)
80 if table.num_rows == 0:
81 raise DatasetError("Nothing to export: the store has no indexed pages.")
82 return table
85def export_to_path(
86 output: Path,
87 fmt_value: str,
88 source: str | None,
89 *,
90 cancel: threading.Event | None = None,
91) -> ExportSummary:
92 """Write the per-page dataset to *output*; format from *fmt_value* or suffix.
94 Setting *cancel* stops between row groups and removes the partial file. The
95 table build ahead of it is a single columnar scan with no boundary to poll,
96 so the stop lands on the write rather than the read.
97 """
98 try:
99 fmt = resolve_format(fmt_value, output)
100 except ValueError as exc:
101 raise DatasetError(str(exc)) from None
102 table = _build_validated(source)
103 write_dataset(table, output, fmt, cancel)
104 return ExportSummary(
105 format=str(fmt),
106 output=str(output),
107 pages=table.num_rows,
108 sources=len(table.column("source").unique()),
109 )
112def export_to_bytes(fmt_value: str, source: str | None) -> ExportPayload:
113 """Encode the per-page dataset to bytes; empty *fmt_value* defaults to parquet."""
114 fmt = require_format(fmt_value) if fmt_value else DatasetFormat.PARQUET
115 table = _build_validated(source)
116 return ExportPayload(
117 data=serialize_dataset(table, fmt),
118 fmt=fmt,
119 pages=table.num_rows,
120 sources=len(table.column("source").unique()),
121 )
124async def _run_import(
125 rows: list[PageTextRecord], on_progress: DetailedProgressCallback
126) -> ImportSummary:
127 """Re-embed *rows* into the store, mapping the mismatch error for surfaces."""
128 if not rows:
129 raise DatasetError("Dataset has no pages to import.")
130 store = get_services().store
131 try:
132 result = await import_dataset(store, rows, on_progress=on_progress)
133 except EmbeddingModelMismatchError as exc:
134 raise DatasetError(str(exc)) from None
135 return ImportSummary(sources=result.sources, pages=result.pages, chunks=result.chunks)
138async def import_from_path(
139 path: Path, fmt_value: str, on_progress: DetailedProgressCallback = noop_callback
140) -> ImportSummary:
141 """Load and import a dataset file; format from *fmt_value* or suffix."""
142 try:
143 fmt = resolve_format(fmt_value, path)
144 rows = load_page_dataset(path, fmt)
145 except ValueError as exc:
146 raise DatasetError(str(exc)) from None
147 return await _run_import(rows, on_progress)
150async def import_from_bytes(
151 data: bytes, fmt_value: str, on_progress: DetailedProgressCallback = noop_callback
152) -> ImportSummary:
153 """Decode and import dataset *data*; *fmt_value* is required (no filename)."""
154 fmt = require_format(fmt_value)
155 try:
156 rows = deserialize_dataset(data, fmt)
157 except ValueError as exc:
158 raise DatasetError(str(exc)) from None
159 return await _run_import(rows, on_progress)