Coverage for src/lilbee/data/extract/xberg.py: 100%
41 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"""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
10from concurrent.futures import ThreadPoolExecutor
11from dataclasses import dataclass
12from typing import TYPE_CHECKING
14if TYPE_CHECKING:
15 from collections.abc import Coroutine
17 from xberg import (
18 ExtractedDocument,
19 ExtractInput,
20 ExtractionConfig,
21 ExtractionResult,
22 OcrConfig,
23 )
26@dataclass(frozen=True)
27class BatchItem:
28 """One input for :func:`aextract_batch`, with its per-file OCR override."""
30 data: bytes
31 mime: str | None
32 filename: str | None
33 ocr: OcrConfig | None
36def _input(data: bytes, mime_type: str | None, filename: str | None) -> ExtractInput:
37 from xberg import ExtractInput, ExtractInputKind
39 return ExtractInput(
40 kind=ExtractInputKind.BYTES, bytes=data, mime_type=mime_type, filename=filename
41 )
44def _first(result: ExtractionResult) -> ExtractedDocument:
45 """Return the single extracted document, or raise on an extraction error."""
46 if result.results:
47 return result.results[0]
48 if result.errors:
49 raise RuntimeError(str(result.errors[0]))
50 raise RuntimeError("xberg extraction returned no document")
53async def aextract_document(
54 data: bytes,
55 mime_type: str | None = None,
56 *,
57 filename: str | None = None,
58 config: ExtractionConfig,
59) -> ExtractedDocument:
60 """Extract one in-memory document. For callers already on the event loop."""
61 from xberg import extract
63 return _first(await extract(_input(data, mime_type, filename), config))
66async def aextract_batch(
67 items: list[BatchItem], config: ExtractionConfig
68) -> list[ExtractedDocument | Exception]:
69 """Extract many inputs in one call, returning one document-or-error per input.
71 Each item's OCR config overrides the batch default for that file. xberg compacts
72 ``results`` to successes in input order and reports failures in ``errors`` by
73 input index; this remaps them back to one slot per input.
74 """
75 from xberg import ExtractInput, ExtractInputKind, FileExtractionConfig, extract_batch
77 inputs = [
78 ExtractInput(
79 kind=ExtractInputKind.BYTES,
80 bytes=item.data,
81 mime_type=item.mime,
82 filename=item.filename,
83 config=FileExtractionConfig(ocr=item.ocr) if item.ocr is not None else None,
84 )
85 for item in items
86 ]
87 result = await extract_batch(inputs, config)
88 failed: dict[int, Exception] = {e.index: RuntimeError(e.message) for e in result.errors}
89 success_indices = [i for i in range(len(items)) if i not in failed]
90 by_index: dict[int, ExtractedDocument | Exception] = dict(
91 zip(success_indices, result.results, strict=True)
92 )
93 by_index.update(failed)
94 return [by_index[i] for i in range(len(items))]
97def extract_document(
98 data: bytes,
99 mime_type: str | None = None,
100 *,
101 filename: str | None = None,
102 config: ExtractionConfig,
103) -> ExtractedDocument:
104 """Extract one in-memory document from synchronous code.
106 Uses ``asyncio.run``; if a loop is already running on this thread, drives the
107 coroutine on a fresh worker thread so it never re-enters that loop.
108 """
109 return _run(aextract_document(data, mime_type, filename=filename, config=config))
112def _run(coro: Coroutine[None, None, ExtractedDocument]) -> ExtractedDocument:
113 try:
114 asyncio.get_running_loop()
115 except RuntimeError:
116 return asyncio.run(coro)
117 with ThreadPoolExecutor(max_workers=1) as pool:
118 return pool.submit(asyncio.run, coro).result()