Source code for aeat.adapters.inbound.justificante._parsers

"""Private text-backend dispatch for the inbound justificante adapter.

Callers outside :mod:`adapters.inbound.justificante` must never import
from here. Backends expose a common ``extract_text`` entry point that
returns the raw concatenated text of a justificante PDF; all field-level
extraction happens in :mod:`adapters.inbound.justificante._extract`.

The dispatch is keyed on
:class:`domain.justificante.JustificanteParserBackend`, and parse
failures are normalised as
:class:`domain.justificante.JustificanteParseError`. The path entry point
caches concatenated text by source digest, backend, size, and mtime; the bytes
entry point stays uncached so secure-storage callers do not need to materialise
or persist plaintext files.
"""

from __future__ import annotations

from collections import OrderedDict
from pathlib import Path

from .....domain.justificante import JustificanteParseError, JustificanteParserBackend
from ...pdf import sha256_file

_INPUT_PDF_SOURCE_LABEL = "<input-pdf>"
_TEXT_CACHE_MAXSIZE = 256
_TEXT_CACHE: OrderedDict[tuple[str, str, int, int], str] = OrderedDict()


[docs] def extract_text(pdf_path: Path, backend: JustificanteParserBackend) -> str: """Extract concatenated text from ``pdf_path`` using ``backend``. The path result is cached for stable file revisions so repeated corpus parses do not reopen the same PDF. Args: pdf_path: Absolute path to the PDF to read. backend: Which backend to dispatch to. Returns: The concatenated text of every page in the PDF, joined by newlines. Raises: JustificanteParseError: If the path cannot be read or the selected backend is not implemented. """ try: resolved = pdf_path.expanduser().resolve() stat = resolved.stat() source_digest = sha256_file(resolved) except OSError as exc: raise JustificanteParseError( f"justificante PDF could not be read: {_INPUT_PDF_SOURCE_LABEL}", context={"path": _INPUT_PDF_SOURCE_LABEL}, translated_message="adapters.inbound.justificante.errors.parse_failed", missing=("source_pdf",), ) from exc backend_value = backend.value if hasattr(backend, "value") else str(backend) cache_key = (source_digest, backend_value, stat.st_size, stat.st_mtime_ns) cached = _TEXT_CACHE.get(cache_key) if cached is not None: _TEXT_CACHE.move_to_end(cache_key) return cached text = _extract_text_uncached(resolved, backend_value) _TEXT_CACHE[cache_key] = text _TEXT_CACHE.move_to_end(cache_key) if len(_TEXT_CACHE) > _TEXT_CACHE_MAXSIZE: _TEXT_CACHE.popitem(last=False) return text
[docs] def extract_text_from_bytes(pdf_bytes: bytes, backend: JustificanteParserBackend) -> str: """Extract concatenated text from in-memory PDF bytes using ``backend``. Bytes are sent directly to the selected backend and are not cached, which keeps secure-storage and live-capture parsing free of plaintext filesystem artefacts. """ backend_value = backend.value if hasattr(backend, "value") else str(backend) return _extract_text_from_bytes_uncached(pdf_bytes, backend_value)
def _extract_text_uncached(pdf_path: Path, backend_value: str) -> str: """Dispatch one uncached filesystem parse to the selected backend.""" normalized_backend = backend_value.lower() if normalized_backend == JustificanteParserBackend.PDFPLUMBER.value.lower(): from ._pdfplumber_backend import extract_text_pdfplumber return extract_text_pdfplumber(pdf_path) raise JustificanteParseError(f"unknown parser backend: {backend_value!r}") def _extract_text_from_bytes_uncached(pdf_bytes: bytes, backend_value: str) -> str: """Dispatch one in-memory parse to the selected backend.""" normalized_backend = backend_value.lower() if normalized_backend == JustificanteParserBackend.PDFPLUMBER.value.lower(): from ._pdfplumber_backend import extract_text_pdfplumber_bytes return extract_text_pdfplumber_bytes(pdf_bytes) raise JustificanteParseError(f"unknown parser backend: {backend_value!r}")