Coverage for src/lilbee/data/extract/xberg.py: 100%
49 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
1"""Bridge to xberg's async-only ``extract`` for lilbee's call sites.
3xberg exposes one ``extract(input, config)`` coroutine; lilbee extracts a single
4in-memory document at a time, from both async and sync callers.
5"""
7from __future__ import annotations
9import asyncio
10import contextvars
11from concurrent.futures import ThreadPoolExecutor
12from dataclasses import dataclass, replace
13from typing import TYPE_CHECKING
15from lilbee.core.config import active_config
16from lilbee.runtime.cpu import cpu_quota
18if TYPE_CHECKING:
19 from collections.abc import Coroutine
21 from xberg import (
22 ConcurrencyConfig,
23 ExtractedDocument,
24 ExtractInput,
25 ExtractionConfig,
26 ExtractionResult,
27 OcrConfig,
28 )
31@dataclass(frozen=True)
32class BatchItem:
33 """One input for :func:`aextract_batch`, with its per-file OCR override."""
35 data: bytes
36 mime: str | None
37 filename: str | None
38 ocr: OcrConfig | None
41def _input(data: bytes, mime_type: str | None, filename: str | None) -> ExtractInput:
42 from xberg import ExtractInput, ExtractInputKind
44 return ExtractInput(
45 kind=ExtractInputKind.BYTES, bytes=data, mime_type=mime_type, filename=filename
46 )
49def _concurrency_config() -> ConcurrencyConfig:
50 """xberg's thread budget from the active config."""
51 from xberg import ConcurrencyConfig
53 return ConcurrencyConfig(max_threads=active_config().extraction_threads or cpu_quota())
56def _with_concurrency(config: ExtractionConfig) -> ExtractionConfig:
57 """*config* carrying the active concurrency; xberg latches the first call's pools."""
58 return replace(config, concurrency=_concurrency_config())
61def _first(result: ExtractionResult) -> ExtractedDocument:
62 """Return the single extracted document, or raise on an extraction error.
64 The error item carries the reason in ``message``; it has no ``__str__``, so
65 formatting the item itself would hand the caller an object repr instead of
66 the timeout or unsupported-format it is reporting.
67 """
68 if result.results:
69 return result.results[0]
70 if result.errors:
71 raise RuntimeError(result.errors[0].message)
72 raise RuntimeError("xberg extraction returned no document")
75async def aextract_document(
76 data: bytes,
77 mime_type: str | None = None,
78 *,
79 filename: str | None = None,
80 config: ExtractionConfig,
81) -> ExtractedDocument:
82 """Extract one in-memory document. For callers already on the event loop."""
83 from xberg import extract
85 return _first(await extract(_input(data, mime_type, filename), _with_concurrency(config)))
88async def aextract_batch(
89 items: list[BatchItem], config: ExtractionConfig
90) -> list[ExtractedDocument | Exception]:
91 """Extract many inputs in one call, returning one document-or-error per input.
93 Each item's OCR config overrides the batch default for that file. xberg compacts
94 ``results`` to successes in input order and reports failures in ``errors`` by
95 input index; this remaps them back to one slot per input.
96 """
97 from xberg import ExtractInput, ExtractInputKind, FileExtractionConfig, extract_batch
99 inputs = [
100 ExtractInput(
101 kind=ExtractInputKind.BYTES,
102 bytes=item.data,
103 mime_type=item.mime,
104 filename=item.filename,
105 config=FileExtractionConfig(ocr=item.ocr) if item.ocr is not None else None,
106 )
107 for item in items
108 ]
109 result = await extract_batch(inputs, _with_concurrency(config))
110 failed: dict[int, Exception] = {e.index: RuntimeError(e.message) for e in result.errors}
111 success_indices = [i for i in range(len(items)) if i not in failed]
112 by_index: dict[int, ExtractedDocument | Exception] = dict(
113 zip(success_indices, result.results, strict=True)
114 )
115 by_index.update(failed)
116 return [by_index[i] for i in range(len(items))]
119def extract_document(
120 data: bytes,
121 mime_type: str | None = None,
122 *,
123 filename: str | None = None,
124 config: ExtractionConfig,
125) -> ExtractedDocument:
126 """Extract one in-memory document from synchronous code.
128 Uses ``asyncio.run``; if a loop is already running on this thread, drives the
129 coroutine on a fresh worker thread so it never re-enters that loop.
130 """
131 return _run(aextract_document(data, mime_type, filename=filename, config=config))
134def _run(coro: Coroutine[None, None, ExtractedDocument]) -> ExtractedDocument:
135 try:
136 asyncio.get_running_loop()
137 except RuntimeError:
138 return asyncio.run(coro)
139 with ThreadPoolExecutor(max_workers=1) as pool:
140 return pool.submit(contextvars.copy_context().run, asyncio.run, coro).result()