diff --git a/Dockerfile b/Dockerfile
index 5ebedb8..dcc209c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -76,8 +76,25 @@ ENV PYTHONUNBUFFERED=1 \
UV_PROJECT_ENVIRONMENT=/app/.venv \
PATH="/app/.venv/bin:${PATH}"
+# System libs required by the binary normalizer's image adapters:
+# * ``libheif1`` -- pillow-heif (HEIC / HEIF / AVIF)
+# * ``libcairo2`` / ``libpango*`` / ``libgdk-pixbuf-2.0-0`` -- cairosvg (SVG)
+#
+# Office conversion (DOCX/XLSX/PPTX/RTF/HTML) goes through the Gotenberg
+# sidecar by default (``FLYDESK_IDP_OFFICE_CONVERTER=gotenberg``), so
+# ``soffice`` is intentionally NOT installed here -- it would bloat the
+# image by ~700MB and is not needed when running against the canonical
+# compose stack. Operators who want the in-container subprocess path
+# (``FLYDESK_IDP_OFFICE_CONVERTER=libreoffice``) extend this Dockerfile
+# with ``libreoffice-core`` + ``fonts-noto-cjk`` etc. on their side.
RUN apt-get update \
- && apt-get install -y --no-install-recommends curl \
+ && apt-get install -y --no-install-recommends \
+ curl \
+ libheif1 \
+ libcairo2 \
+ libpango-1.0-0 \
+ libpangocairo-1.0-0 \
+ libgdk-pixbuf-2.0-0 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --uid 10001 --shell /usr/sbin/nologin --no-create-home idp
diff --git a/docker-compose.yml b/docker-compose.yml
index ac4f0d6..2a840fc 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -45,6 +45,27 @@ services:
timeout: 3s
retries: 30
+ # Office-doc conversion sidecar. Owns headless LibreOffice + Chromium
+ # internally so the API + worker containers stay distroless-friendly
+ # (no ``soffice`` binary, no writable filesystem, no shell). The
+ # ``flydesk-idp`` containers POST DOCX/XLSX/PPTX/RTF/HTML bytes here
+ # and get back the rendered PDF -- selected by
+ # ``FLYDESK_IDP_OFFICE_CONVERTER=gotenberg`` (the default).
+ gotenberg:
+ image: gotenberg/gotenberg:8
+ container_name: flydesk-idp-gotenberg
+ ports:
+ - "${GOTENBERG_PORT:-3000}:3000"
+ healthcheck:
+ test: ["CMD", "curl", "--fail", "http://localhost:3000/health"]
+ interval: 5s
+ timeout: 3s
+ retries: 30
+ command:
+ - "gotenberg"
+ - "--api-timeout=60s"
+ - "--libreoffice-restart-after=10"
+
api:
build: *service-build
image: flydesk-idp:latest
@@ -63,6 +84,8 @@ services:
FLYDESK_IDP_REDIS_URL: redis://redis:6379/0
FLYDESK_IDP_EDA_ADAPTER: postgres
FLYDESK_IDP_MODEL: ${FLYDESK_IDP_MODEL:-anthropic:claude-sonnet-4-6}
+ FLYDESK_IDP_OFFICE_CONVERTER: ${FLYDESK_IDP_OFFICE_CONVERTER:-gotenberg}
+ FLYDESK_IDP_GOTENBERG_URL: http://gotenberg:3000
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
RUN_MIGRATIONS: "true"
@@ -71,6 +94,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
+ gotenberg:
+ condition: service_healthy
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8400/actuator/health/readiness"]
interval: 5s
@@ -90,12 +115,16 @@ services:
FLYDESK_IDP_REDIS_URL: redis://redis:6379/0
FLYDESK_IDP_EDA_ADAPTER: postgres
FLYDESK_IDP_MODEL: ${FLYDESK_IDP_MODEL:-anthropic:claude-sonnet-4-6}
+ FLYDESK_IDP_OFFICE_CONVERTER: ${FLYDESK_IDP_OFFICE_CONVERTER:-gotenberg}
+ FLYDESK_IDP_GOTENBERG_URL: http://gotenberg:3000
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
RUN_MIGRATIONS: "false"
depends_on:
api:
condition: service_healthy
+ gotenberg:
+ condition: service_healthy
volumes:
postgres_data:
diff --git a/docs/overview.md b/docs/overview.md
index f909526..77ac0d3 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -13,8 +13,14 @@ flydesk-idp turns documents into structured, validated, audit-ready
decisions. A single HTTP call does five things that operations teams
normally have to glue together themselves:
-1. **Read** the document — any layout, any of the formats a multimodal
- LLM accepts (PDF, PNG, JPEG, WebP, TIFF, …).
+1. **Read** the document — any layout, any binary the **binary
+ normalizer** can resolve to LLM-renderable bytes: PDFs and provider-
+ native rasters (PNG, JPEG, GIF, WebP) pass straight through; Office
+ docs (DOCX/XLSX/PPTX/RTF/ODT/HTML) go to PDF via a Gotenberg sidecar
+ (or in-container LibreOffice as fallback); images the providers
+ don't read (HEIC/HEIF/AVIF, multi-frame TIFF, SVG, BMP) convert via
+ Pillow + cairosvg; archives + email bundles (ZIP/7z/TAR/EML/MSG)
+ fan out into multiple per-attachment requests.
2. **Extract** the fields you asked for — each one with a value, a
page number, a normalised bounding box, and a confidence score.
3. **Validate** every field with deterministic checkers (IBAN
diff --git a/pyproject.toml b/pyproject.toml
index e915fa3..0a02252 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,11 +19,29 @@ dependencies = [
# over pydantic-ai. Pulls in the OpenAI / Anthropic / Bedrock providers via pydantic-ai-slim.
"fireflyframework-agentic[rest,security,redis]>=26.5.11",
- # Document handling -- pypdf only for cheap page counting; the document
- # bytes themselves are shipped straight to the multimodal LLM (no local
- # rasterisation, no Pillow), so we stay format-agnostic (PDF, PNG, JPEG,
- # WebP, TIFF, DOCX, ...).
+ # Document handling.
+ #
+ # ``pypdf`` -- cheap page counting + encrypted-PDF detection in the
+ # binary normalizer (raw bytes still ship straight to the multimodal
+ # LLM, no local rasterisation for the extraction path).
+ #
+ # ``Pillow`` + ``pillow-heif`` -- the binary normalizer uses Pillow
+ # to canonicalise raster inputs the LLM provider can't natively read
+ # (HEIC/HEIF iPhone photos, multi-frame TIFF fax scans, animated GIFs,
+ # SVG via cairosvg) into PNG or multi-page PDF.
+ #
+ # ``cairosvg`` -- vector SVG rasterisation; the providers don't accept
+ # SVG so we render to PNG before shipping.
+ #
+ # ``py7zr`` -- 7-Zip archive expansion (.zip and .tar are stdlib).
+ #
+ # ``extract-msg`` -- Outlook .msg parsing; .eml is stdlib ``email``.
"pypdf>=4.3.0",
+ "Pillow>=11.0",
+ "pillow-heif>=0.18",
+ "cairosvg>=2.7",
+ "py7zr>=0.22",
+ "extract-msg>=0.51",
# Pydantic AI models for multimodal/vision LLMs.
"pydantic-ai-slim[anthropic,openai,bedrock]>=1.56.0",
@@ -65,6 +83,7 @@ dev = [
"respx>=0.21",
"httpx>=0.28",
"reportlab>=4.2", # synthesise sample PDFs in fixtures
+ "python-docx>=1.1", # synthesise sample DOCX in fixtures
]
[build-system]
diff --git a/src/flydesk_idp/config.py b/src/flydesk_idp/config.py
index 98751ff..180b46f 100644
--- a/src/flydesk_idp/config.py
+++ b/src/flydesk_idp/config.py
@@ -76,6 +76,66 @@ class IDPSettings(BaseSettings):
webhook_max_attempts: int = 5
webhook_hmac_secret: str | None = None
+ # -- Binary normalization ------------------------------------------
+ # The binary normalizer (``core/services/binary``) turns any caller-
+ # supplied binary into one or more LLM-renderable inputs (PDF or
+ # PNG/JPG/GIF/WebP). It expands archives + email attachments,
+ # converts Office docs via headless LibreOffice, rasterises HEIC /
+ # multi-frame TIFF / SVG via Pillow + cairosvg, and rejects
+ # encrypted / corrupt PDFs with a typed error.
+ binary_normalize_enabled: bool = Field(
+ default=True,
+ description=(
+ "Master kill-switch for the binary normalizer. When False the "
+ "loader passes raw bytes through as before — useful for debugging "
+ "or for deployments that pre-normalise upstream."
+ ),
+ )
+ binary_max_recursion_depth: int = Field(
+ default=3,
+ ge=0,
+ description=(
+ "Max nesting depth for archives / emails. A ZIP containing a ZIP "
+ "containing a PDF is depth 3. Prevents zip-bomb style recursion."
+ ),
+ )
+ binary_max_expanded_files: int = Field(
+ default=50,
+ ge=1,
+ description="Hard cap on expanded files per inbound binary.",
+ )
+ office_converter: str = Field(
+ default="gotenberg",
+ description=(
+ "Adapter used by the binary normalizer for Office → PDF "
+ "conversion. ``gotenberg`` (HTTP sidecar, distroless-friendly, "
+ "default) or ``libreoffice`` (in-container subprocess; requires "
+ "``soffice`` + multilingual font packs in the runtime image)."
+ ),
+ )
+ gotenberg_url: str = Field(
+ default="http://gotenberg:3000",
+ description=(
+ "Base URL of the Gotenberg sidecar. Used only when ``office_converter == 'gotenberg'``."
+ ),
+ )
+ gotenberg_timeout_s: int = Field(
+ default=60,
+ ge=1,
+ description="Per-call HTTP timeout against the Gotenberg sidecar.",
+ )
+ binary_libreoffice_path: str = Field(
+ default="soffice",
+ description=(
+ "Path to the headless LibreOffice binary. Used only when ``office_converter == 'libreoffice'``."
+ ),
+ )
+ binary_libreoffice_timeout_s: int = Field(
+ default=60,
+ ge=1,
+ description=("Per-call subprocess timeout when ``office_converter == 'libreoffice'``."),
+ )
+
# -- Security -------------------------------------------------------
api_keys: str | None = Field(
default=None,
diff --git a/src/flydesk_idp/core/configuration.py b/src/flydesk_idp/core/configuration.py
index d32726b..1fcb3d5 100644
--- a/src/flydesk_idp/core/configuration.py
+++ b/src/flydesk_idp/core/configuration.py
@@ -37,6 +37,16 @@
VisualAuthenticityChecker,
)
from flydesk_idp.core.services.bbox import BboxValidator
+from flydesk_idp.core.services.binary import (
+ ArchiveUnpacker,
+ BinaryNormalizer,
+ EmailUnpacker,
+ GotenbergConverter,
+ ImageNormalizer,
+ LibreOfficeConverter,
+ OfficeConverter,
+ PdfGuard,
+)
from flydesk_idp.core.services.classification import DocumentClassifier
from flydesk_idp.core.services.escalation import JudgeEscalator
from flydesk_idp.core.services.extraction.extractor import MultimodalExtractor
@@ -132,6 +142,52 @@ def request_validator(self) -> RequestValidator:
"""Pre-flight semantic checker on the public ExtractionRequest."""
return RequestValidator()
+ # ------------------------------------------------------------------
+ # Binary normalization
+ #
+ # Office conversion is pluggable behind the OfficeConverter
+ # protocol. The default ``gotenberg`` adapter keeps the runtime
+ # container distroless-friendly by delegating to a Gotenberg
+ # sidecar; ``libreoffice`` falls back to an in-container subprocess
+ # for slim/dev images that bundle ``soffice``.
+ # ------------------------------------------------------------------
+
+ @bean
+ def office_converter(self, settings: IDPSettings) -> OfficeConverter:
+ kind = (settings.office_converter or "gotenberg").lower()
+ if kind == "libreoffice":
+ return LibreOfficeConverter(settings=settings)
+ if kind == "gotenberg":
+ return GotenbergConverter(settings=settings)
+ raise ValueError(
+ f"unknown FLYDESK_IDP_OFFICE_CONVERTER={settings.office_converter!r}; "
+ "expected 'gotenberg' or 'libreoffice'"
+ )
+
+ @bean
+ def binary_normalizer(
+ self,
+ settings: IDPSettings,
+ pdf_guard: PdfGuard,
+ image_normalizer: ImageNormalizer,
+ office_converter: OfficeConverter,
+ archive_unpacker: ArchiveUnpacker,
+ email_unpacker: EmailUnpacker,
+ ) -> BinaryNormalizer:
+ return BinaryNormalizer(
+ settings=settings,
+ pdf_guard=pdf_guard,
+ image=image_normalizer,
+ office=office_converter,
+ archive=archive_unpacker,
+ email_=email_unpacker,
+ )
+
+ # PdfGuard / ImageNormalizer / ArchiveUnpacker / EmailUnpacker carry
+ # ``@service`` decorators -- pyfly autoscan picks them up via the
+ # ``flydesk_idp.core`` scan_packages entry. They are listed here only
+ # so the dependency graph is auditable from this single file.
+
@bean
def visual_checker(self, settings: IDPSettings, prompts: PromptCatalog) -> VisualAuthenticityChecker:
return VisualAuthenticityChecker(template=prompts.visual_authenticity, model=settings.model)
@@ -175,6 +231,7 @@ def orchestrator(
classifier: DocumentClassifier,
field_validator: FieldValidator,
bbox_validator: BboxValidator,
+ binary_normalizer: BinaryNormalizer,
visual_checker: VisualAuthenticityChecker,
content_checker: ContentAuthenticityChecker,
judge: Judge,
@@ -188,6 +245,7 @@ def orchestrator(
classifier=classifier,
field_validator=field_validator,
bbox_validator=bbox_validator,
+ binary_normalizer=binary_normalizer,
visual_checker=visual_checker,
content_checker=content_checker,
judge=judge,
diff --git a/src/flydesk_idp/core/services/binary/__init__.py b/src/flydesk_idp/core/services/binary/__init__.py
new file mode 100644
index 0000000..71a0c55
--- /dev/null
+++ b/src/flydesk_idp/core/services/binary/__init__.py
@@ -0,0 +1,61 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""Binary normalization -- turn any caller-supplied binary into LLM-renderable bytes.
+
+The multimodal LLM providers we ship against (Anthropic, OpenAI, Bedrock)
+only natively read PDF + a small set of raster image formats. Real
+callers send everything: DOCX, XLSX, PPTX, RTF, ODT, HTML, EML/MSG email
+with attachments, ZIP / 7z / TAR bundles, HEIC iPhone photos, multi-frame
+TIFF fax scans, SVG, encrypted PDFs.
+
+This package normalises every inbound binary into one or more
+:class:`NormalisedBinary` rows -- each carrying ready-to-ship bytes plus
+the resolved media type. A single inbound ZIP can fan out to many rows;
+a born-digital PDF or a clean PNG is a one-row passthrough.
+
+The normalizer is wired through pyfly DI -- :class:`BinaryNormalizer`
+is the entry point; the per-format adapters (:class:`LibreOfficeConverter`,
+:class:`EmailUnpacker`, :class:`ArchiveUnpacker`, :class:`ImageNormalizer`,
+:class:`PdfGuard`) are autoscanned ``@service`` beans injected into it.
+
+Errors raise typed :class:`BinaryNormalizationError` subclasses so
+:class:`ExceptionAdvice` can map them to RFC 7807 problem-details.
+"""
+
+from __future__ import annotations
+
+from flydesk_idp.core.services.binary.archive import ArchiveUnpacker
+from flydesk_idp.core.services.binary.email import EmailUnpacker
+from flydesk_idp.core.services.binary.errors import (
+ ArchiveExtractionError,
+ BinaryNormalizationError,
+ EncryptedPdfError,
+ ImageConversionError,
+ OfficeConversionError,
+ UnsupportedBinaryError,
+)
+from flydesk_idp.core.services.binary.gotenberg import GotenbergConverter
+from flydesk_idp.core.services.binary.image import ImageNormalizer
+from flydesk_idp.core.services.binary.libreoffice import LibreOfficeConverter
+from flydesk_idp.core.services.binary.normalizer import BinaryNormalizer, NormalisedBinary
+from flydesk_idp.core.services.binary.office_converter import OfficeConverter
+from flydesk_idp.core.services.binary.pdf_guard import PdfGuard
+from flydesk_idp.core.services.binary.sniffer import sniff_media_type
+
+__all__ = [
+ "ArchiveExtractionError",
+ "ArchiveUnpacker",
+ "BinaryNormalizationError",
+ "BinaryNormalizer",
+ "EmailUnpacker",
+ "EncryptedPdfError",
+ "GotenbergConverter",
+ "ImageConversionError",
+ "ImageNormalizer",
+ "LibreOfficeConverter",
+ "NormalisedBinary",
+ "OfficeConversionError",
+ "OfficeConverter",
+ "PdfGuard",
+ "UnsupportedBinaryError",
+ "sniff_media_type",
+]
diff --git a/src/flydesk_idp/core/services/binary/archive.py b/src/flydesk_idp/core/services/binary/archive.py
new file mode 100644
index 0000000..e4930d8
--- /dev/null
+++ b/src/flydesk_idp/core/services/binary/archive.py
@@ -0,0 +1,212 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""ZIP / 7z / TAR / GZIP expansion.
+
+Returns one ``(filename, bytes)`` tuple per member. Compressed-only
+formats (gzip/bz2 of a single file) yield one tuple; container formats
+(zip/7z/tar) yield as many as they hold.
+
+Per-call hard limits guard against zip-bombs:
+
+* recursion depth (handled by the normalizer above us, not here)
+* total expanded files per archive (enforced here)
+* total uncompressed bytes per archive (enforced here)
+"""
+
+from __future__ import annotations
+
+import gzip
+import io
+import logging
+import tarfile
+import time
+import zipfile
+from collections.abc import Iterator
+
+from pyfly.container import service
+
+from flydesk_idp.config import IDPSettings
+from flydesk_idp.core.observability import log_outbound
+from flydesk_idp.core.services.binary.errors import ArchiveExtractionError
+
+logger = logging.getLogger(__name__)
+
+# Bytes-per-archive ceiling. Independent of the per-input expansion
+# limit so a single ZIP can't exhaust memory by holding one giant member
+# under the file-count limit.
+_MAX_UNCOMPRESSED_BYTES = 256 * 1024 * 1024 # 256 MiB
+
+_ARCHIVE_TYPES = {
+ "application/zip",
+ "application/x-7z-compressed",
+ "application/x-tar",
+ "application/gzip",
+}
+
+
+@service
+class ArchiveUnpacker:
+ """Yield one (path, bytes) per archive member."""
+
+ def __init__(self, settings: IDPSettings) -> None:
+ self._max_files = settings.binary_max_expanded_files
+
+ @staticmethod
+ def supports(media_type: str) -> bool:
+ return media_type in _ARCHIVE_TYPES
+
+ def unpack(
+ self,
+ data: bytes,
+ *,
+ media_type: str,
+ filename: str | None = None,
+ ) -> list[tuple[str, bytes]]:
+ """Return every member as ``(path, bytes)``.
+
+ Skips directories and zero-byte members. Raises
+ :class:`ArchiveExtractionError` on corrupt archives, password-
+ protected members, or fan-out / size limit breach.
+ """
+ started = time.monotonic()
+ try:
+ members = list(self._iter_members(data, media_type, filename))
+ except ArchiveExtractionError:
+ raise
+ except Exception as exc: # noqa: BLE001
+ raise ArchiveExtractionError(f"archive could not be opened: {exc}", filename=filename) from exc
+ log_outbound(
+ "archive",
+ op=f"unpack.{media_type.split('/')[-1]}",
+ status="ok",
+ latency_ms=(time.monotonic() - started) * 1000,
+ members=len(members),
+ )
+ return members
+
+ # ------------------------------------------------------------------
+
+ def _iter_members(
+ self,
+ data: bytes,
+ media_type: str,
+ filename: str | None,
+ ) -> Iterator[tuple[str, bytes]]:
+ if media_type == "application/zip":
+ yield from self._iter_zip(data, filename)
+ elif media_type == "application/x-7z-compressed":
+ yield from self._iter_7z(data, filename)
+ elif media_type == "application/x-tar":
+ yield from self._iter_tar(data, filename)
+ elif media_type == "application/gzip":
+ yield from self._iter_gz(data, filename)
+ else:
+ raise ArchiveExtractionError(f"unsupported archive type: {media_type}", filename=filename)
+
+ def _iter_zip(self, data: bytes, filename: str | None) -> Iterator[tuple[str, bytes]]:
+ try:
+ zf = zipfile.ZipFile(io.BytesIO(data))
+ except zipfile.BadZipFile as exc:
+ raise ArchiveExtractionError(f"corrupt ZIP: {exc}", filename=filename) from exc
+ with zf:
+ total_bytes = 0
+ yielded = 0
+ for info in zf.infolist():
+ if info.is_dir():
+ continue
+ if info.flag_bits & 0x1: # password bit
+ raise ArchiveExtractionError(
+ f"ZIP member {info.filename!r} is password-protected",
+ filename=filename,
+ )
+ if info.file_size <= 0:
+ continue
+ total_bytes += info.file_size
+ self._enforce_limits(yielded + 1, total_bytes, filename)
+ try:
+ member_bytes = zf.read(info.filename)
+ except (RuntimeError, zipfile.BadZipFile) as exc:
+ raise ArchiveExtractionError(
+ f"ZIP member {info.filename!r} could not be read: {exc}",
+ filename=filename,
+ ) from exc
+ yielded += 1
+ yield info.filename, member_bytes
+
+ def _iter_7z(self, data: bytes, filename: str | None) -> Iterator[tuple[str, bytes]]:
+ try:
+ import py7zr # pyright: ignore[reportMissingImports]
+ except ImportError as exc: # pragma: no cover -- runtime dep guard
+ raise ArchiveExtractionError("py7zr is required for 7-Zip input", filename=filename) from exc
+ try:
+ with py7zr.SevenZipFile(io.BytesIO(data), mode="r") as sf:
+ if sf.needs_password():
+ raise ArchiveExtractionError("7z archive is password-protected", filename=filename)
+ # py7zr returns a dict[str, BytesIO]; ``readall`` is the
+ # documented API, type stubs in some releases miss it.
+ contents: dict[str, io.BytesIO] = sf.readall() or {} # pyright: ignore[reportAttributeAccessIssue]
+ except ArchiveExtractionError:
+ raise
+ except Exception as exc: # noqa: BLE001
+ # py7zr.exceptions.PasswordRequired isn't always exported via
+ # ``py7zr.exceptions``; fall back to substring check on the
+ # exception message which is stable across releases.
+ if "password" in str(exc).lower():
+ raise ArchiveExtractionError("7z archive is password-protected", filename=filename) from exc
+ raise ArchiveExtractionError(f"7z extraction failed: {exc}", filename=filename) from exc
+
+ total_bytes = 0
+ yielded = 0
+ for path, buf in contents.items():
+ payload = buf.getvalue()
+ if not payload:
+ continue
+ total_bytes += len(payload)
+ self._enforce_limits(yielded + 1, total_bytes, filename)
+ yielded += 1
+ yield path, payload
+
+ def _iter_tar(self, data: bytes, filename: str | None) -> Iterator[tuple[str, bytes]]:
+ try:
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tf: # noqa: SIM117
+ total_bytes = 0
+ yielded = 0
+ for info in tf:
+ if not info.isfile() or info.size <= 0:
+ continue
+ total_bytes += info.size
+ self._enforce_limits(yielded + 1, total_bytes, filename)
+ fp = tf.extractfile(info)
+ if fp is None:
+ continue
+ yielded += 1
+ yield info.name, fp.read()
+ except ArchiveExtractionError:
+ raise
+ except tarfile.TarError as exc:
+ raise ArchiveExtractionError(f"corrupt tar: {exc}", filename=filename) from exc
+
+ def _iter_gz(self, data: bytes, filename: str | None) -> Iterator[tuple[str, bytes]]:
+ # gzip wraps a single file. Keep the original member name when present.
+ try:
+ decoded = gzip.decompress(data)
+ except OSError as exc:
+ raise ArchiveExtractionError(f"gzip decompression failed: {exc}", filename=filename) from exc
+ if len(decoded) > _MAX_UNCOMPRESSED_BYTES:
+ raise ArchiveExtractionError(
+ f"gzip exceeds {_MAX_UNCOMPRESSED_BYTES} byte ceiling",
+ filename=filename,
+ )
+ member_name = (filename or "payload").rstrip(".gz") or "payload"
+ yield member_name, decoded
+
+ def _enforce_limits(self, count: int, total_bytes: int, filename: str | None) -> None:
+ if count > self._max_files:
+ raise ArchiveExtractionError(
+ f"archive expansion exceeds max {self._max_files} files",
+ filename=filename,
+ )
+ if total_bytes > _MAX_UNCOMPRESSED_BYTES:
+ raise ArchiveExtractionError(
+ f"archive expansion exceeds {_MAX_UNCOMPRESSED_BYTES} byte ceiling",
+ filename=filename,
+ )
diff --git a/src/flydesk_idp/core/services/binary/email.py b/src/flydesk_idp/core/services/binary/email.py
new file mode 100644
index 0000000..a4009f9
--- /dev/null
+++ b/src/flydesk_idp/core/services/binary/email.py
@@ -0,0 +1,152 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""Email unpacking -- EML (RFC 822) and Outlook MSG.
+
+Returns one ``(filename, bytes)`` tuple per attachment. Plain-text /
+HTML email bodies are surfaced as ``body.html`` / ``body.txt`` so the
+caller can extract from them too if they want; the normalizer above us
+treats them as text/html (LibreOffice path) or text/plain (passthrough).
+
+The header block itself is never returned -- callers care about the
+content, not the routing metadata.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import email
+import email.message
+import email.policy
+import io
+import logging
+import time
+from collections.abc import Iterator
+
+from pyfly.container import service
+
+from flydesk_idp.core.observability import log_outbound
+from flydesk_idp.core.services.binary.errors import BinaryNormalizationError
+
+logger = logging.getLogger(__name__)
+
+_EMAIL_TYPES = {
+ "message/rfc822",
+ "application/vnd.ms-outlook",
+}
+
+
+@service
+class EmailUnpacker:
+ """Extract attachments + bodies from an email envelope."""
+
+ @staticmethod
+ def supports(media_type: str) -> bool:
+ return media_type in _EMAIL_TYPES
+
+ def unpack(
+ self,
+ data: bytes,
+ *,
+ media_type: str,
+ filename: str | None = None,
+ ) -> list[tuple[str, bytes]]:
+ """Return every attachment + the email body as ``(name, bytes)``.
+
+ For MSG (Outlook) files, requires the ``extract-msg`` package
+ bundled in the default deps.
+ """
+ started = time.monotonic()
+ try:
+ if media_type == "message/rfc822":
+ items = list(self._iter_eml(data, filename))
+ else:
+ items = list(self._iter_msg(data, filename))
+ except BinaryNormalizationError:
+ raise
+ except Exception as exc: # noqa: BLE001
+ raise BinaryNormalizationError(f"email could not be parsed: {exc}", filename=filename) from exc
+ log_outbound(
+ "email",
+ op=f"unpack.{media_type.split('/')[-1]}",
+ status="ok",
+ latency_ms=(time.monotonic() - started) * 1000,
+ items=len(items),
+ )
+ return items
+
+ # ------------------------------------------------------------------
+
+ def _iter_eml(self, data: bytes, filename: str | None) -> Iterator[tuple[str, bytes]]:
+ msg = email.message_from_bytes(data, policy=email.policy.default)
+ for part in msg.walk():
+ if part.is_multipart():
+ continue
+ disposition = (part.get_content_disposition() or "").lower()
+ ctype = part.get_content_type()
+ raw_payload = part.get_payload(decode=True)
+ # ``decode=True`` returns ``bytes`` for non-multipart parts.
+ # The stub union with ``Message`` covers nested-message parts
+ # which is_multipart() already filtered out -- the runtime
+ # value is bytes for everything that reaches here.
+ if not isinstance(raw_payload, (bytes, bytearray)):
+ continue
+ payload: bytes = bytes(raw_payload)
+ if not payload:
+ continue
+ if disposition == "attachment" or part.get_filename():
+ name = part.get_filename() or _default_name(ctype)
+ yield name, payload
+ continue
+ # Inline body parts (text/plain, text/html). Surface so callers
+ # can extract from them too.
+ if ctype == "text/plain":
+ yield "body.txt", payload
+ elif ctype == "text/html":
+ yield "body.html", payload
+
+ def _iter_msg(self, data: bytes, filename: str | None) -> Iterator[tuple[str, bytes]]:
+ try:
+ import extract_msg # pyright: ignore[reportMissingImports]
+ except ImportError as exc: # pragma: no cover -- runtime dep guard
+ raise BinaryNormalizationError(
+ "extract-msg is required for Outlook .msg input",
+ filename=filename,
+ ) from exc
+
+ try:
+ msg = extract_msg.openMsg(io.BytesIO(data))
+ except Exception as exc: # noqa: BLE001
+ raise BinaryNormalizationError(f"MSG could not be opened: {exc}", filename=filename) from exc
+
+ try:
+ for att in msg.attachments:
+ payload = att.data
+ if not isinstance(payload, (bytes, bytearray)):
+ continue
+ name = att.longFilename or att.shortFilename or "attachment"
+ yield name, bytes(payload)
+ # ``body`` / ``htmlBody`` are documented MSGFile attributes;
+ # extract-msg's type stubs miss them in the version we ship.
+ raw_body: object = getattr(msg, "body", None)
+ raw_html: object = getattr(msg, "htmlBody", None)
+ body = (
+ (raw_body or "").encode("utf-8", errors="replace")
+ if isinstance(raw_body, str)
+ else (bytes(raw_body) if isinstance(raw_body, (bytes, bytearray)) else b"")
+ )
+ html = (
+ raw_html
+ if isinstance(raw_html, bytes)
+ else (raw_html.encode("utf-8", errors="replace") if isinstance(raw_html, str) else b"")
+ )
+ if body.strip():
+ yield "body.txt", body
+ if html.strip():
+ yield "body.html", html
+ finally:
+ with contextlib.suppress(Exception):
+ msg.close()
+
+
+def _default_name(content_type: str) -> str:
+ suffix = content_type.split("/")[-1] if "/" in content_type else "bin"
+ return f"attachment.{suffix}"
diff --git a/src/flydesk_idp/core/services/binary/errors.py b/src/flydesk_idp/core/services/binary/errors.py
new file mode 100644
index 0000000..aead4bb
--- /dev/null
+++ b/src/flydesk_idp/core/services/binary/errors.py
@@ -0,0 +1,77 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""Typed exceptions raised by the binary normalizer.
+
+All subclasses of :class:`BinaryNormalizationError` are mapped to RFC 7807
+problem-details by :class:`ExceptionAdvice` so callers receive a
+``application/problem+json`` body with a stable ``code`` they can branch on.
+"""
+
+from __future__ import annotations
+
+
+class BinaryNormalizationError(ValueError):
+ """Base class -- a caller-supplied binary cannot be made LLM-renderable.
+
+ Subclassing :class:`ValueError` so the existing
+ ``ExceptionAdvice.invalid_request`` 400 handler still catches it as
+ a fallback when no more specific handler matches.
+ """
+
+ code: str = "binary_normalization_error"
+ http_status: int = 422
+
+ def __init__(self, message: str, *, filename: str | None = None) -> None:
+ super().__init__(message)
+ self.filename = filename
+
+
+class UnsupportedBinaryError(BinaryNormalizationError):
+ """The MIME type / magic bytes are recognised but no adapter handles them.
+
+ Examples: a video file, a raw audio sample, a proprietary CAD format.
+ The caller needs to convert client-side or pick a different file.
+ """
+
+ code = "unsupported_binary"
+
+
+class EncryptedPdfError(BinaryNormalizationError):
+ """The inbound PDF is password-protected and no password was provided.
+
+ The normalizer never attempts to brute-force; if a future request
+ surface adds a ``password`` field, the PDF guard will accept it.
+ """
+
+ code = "encrypted_pdf"
+
+
+class OfficeConversionError(BinaryNormalizationError):
+ """LibreOffice failed to convert an Office document to PDF.
+
+ Wraps the subprocess failure -- the ``detail`` carries the trimmed
+ stderr so callers can see why (e.g. corrupt document, unsupported
+ macro, missing language pack).
+ """
+
+ code = "office_conversion_failed"
+
+
+class ArchiveExtractionError(BinaryNormalizationError):
+ """A ZIP / 7z / TAR archive could not be expanded.
+
+ Covers: corrupt archive, password-protected member, exceeding the
+ configured fan-out / depth limits (zip-bomb guard).
+ """
+
+ code = "archive_extraction_failed"
+
+
+class ImageConversionError(BinaryNormalizationError):
+ """Pillow / pillow-heif / cairosvg refused the inbound image.
+
+ Usually means the file is truncated, claims a MIME type its bytes
+ don't match, or uses a codec the runtime image lacks (e.g. HEIC
+ without ``libheif`` installed).
+ """
+
+ code = "image_conversion_failed"
diff --git a/src/flydesk_idp/core/services/binary/gotenberg.py b/src/flydesk_idp/core/services/binary/gotenberg.py
new file mode 100644
index 0000000..8f48c28
--- /dev/null
+++ b/src/flydesk_idp/core/services/binary/gotenberg.py
@@ -0,0 +1,128 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""Gotenberg HTTP adapter -- distroless-friendly Office → PDF.
+
+Gotenberg (https://gotenberg.dev) is a Go service that wraps headless
+LibreOffice + Chromium and exposes an HTTP API for document conversion.
+By delegating to the sidecar we keep the application container truly
+distroless: no ``soffice`` binary, no writable filesystem, no shell.
+
+Endpoints used:
+
+* ``POST /forms/libreoffice/convert`` -- DOC, DOCX, XLS, XLSX, PPT,
+ PPTX, ODT, ODS, ODP, RTF.
+* ``POST /forms/chromium/convert/html`` -- HTML.
+
+A single multipart request returns the PDF in the response body. We
+treat any non-2xx as a typed :class:`OfficeConversionError`.
+"""
+
+from __future__ import annotations
+
+import logging
+import time
+from typing import Final
+
+import httpx
+
+from flydesk_idp.config import IDPSettings
+from flydesk_idp.core.observability import log_outbound
+from flydesk_idp.core.services.binary.errors import OfficeConversionError
+from flydesk_idp.core.services.binary.office_converter import (
+ OFFICE_MEDIA_TYPES,
+ OfficeConverter,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# Filename extension by MIME -- Gotenberg picks the right LibreOffice
+# import filter from the upload's filename, so the extension matters.
+_EXT: Final[dict[str, str]] = {
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
+ "application/msword": "doc",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
+ "application/vnd.ms-excel": "xls",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
+ "application/vnd.ms-powerpoint": "ppt",
+ "application/vnd.oasis.opendocument.text": "odt",
+ "application/vnd.oasis.opendocument.spreadsheet": "ods",
+ "application/vnd.oasis.opendocument.presentation": "odp",
+ "application/rtf": "rtf",
+ "text/rtf": "rtf",
+ "text/html": "html",
+}
+
+
+class GotenbergConverter(OfficeConverter):
+ """``OfficeConverter`` backed by a Gotenberg HTTP sidecar.
+
+ Not decorated ``@service`` -- :class:`IDPCoreConfiguration` registers
+ it (or :class:`LibreOfficeConverter`) as the ``OfficeConverter`` bean
+ based on ``IDPSettings.office_converter``.
+ """
+
+ def __init__(self, settings: IDPSettings) -> None:
+ self._base_url = settings.gotenberg_url.rstrip("/")
+ self._timeout_s = settings.gotenberg_timeout_s
+
+ @staticmethod
+ def supports(media_type: str) -> bool:
+ return media_type in OFFICE_MEDIA_TYPES
+
+ async def convert(
+ self,
+ data: bytes,
+ *,
+ media_type: str,
+ filename: str | None = None,
+ ) -> bytes:
+ ext = _EXT.get(media_type)
+ if ext is None:
+ raise OfficeConversionError(
+ f"unsupported office media type: {media_type}",
+ filename=filename,
+ )
+ url = (
+ f"{self._base_url}/forms/chromium/convert/html"
+ if ext == "html"
+ else f"{self._base_url}/forms/libreoffice/convert"
+ )
+ upload_name = (filename or "document").rsplit("/", 1)[-1]
+ if not upload_name.lower().endswith("." + ext):
+ stem = upload_name.rsplit(".", 1)[0] if "." in upload_name else upload_name
+ upload_name = f"{stem}.{ext}"
+ files = {"files": (upload_name, data, media_type)}
+ # The HTML endpoint expects a single ``index.html`` upload key.
+ if ext == "html":
+ files = {"files": ("index.html", data, "text/html")}
+
+ started = time.monotonic()
+ try:
+ async with httpx.AsyncClient(timeout=self._timeout_s) as client:
+ resp = await client.post(url, files=files)
+ except httpx.HTTPError as exc:
+ raise OfficeConversionError(f"Gotenberg request failed: {exc}", filename=filename) from exc
+ latency_ms = (time.monotonic() - started) * 1000
+ if resp.status_code >= 400:
+ err = resp.text[:500]
+ log_outbound(
+ "gotenberg",
+ op=f"convert.{ext}",
+ status="error",
+ latency_ms=latency_ms,
+ http_status=resp.status_code,
+ error=err[:120],
+ )
+ raise OfficeConversionError(
+ f"Gotenberg returned HTTP {resp.status_code}: {err or 'no body'}",
+ filename=filename,
+ )
+ log_outbound(
+ "gotenberg",
+ op=f"convert.{ext}",
+ status="ok",
+ latency_ms=latency_ms,
+ in_bytes=len(data),
+ out_bytes=len(resp.content),
+ )
+ return resp.content
diff --git a/src/flydesk_idp/core/services/binary/image.py b/src/flydesk_idp/core/services/binary/image.py
new file mode 100644
index 0000000..7e4f694
--- /dev/null
+++ b/src/flydesk_idp/core/services/binary/image.py
@@ -0,0 +1,219 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""Raster + vector image normalization.
+
+The multimodal LLM providers we ship against accept PNG, JPEG, GIF, WebP
+natively. Everything else has to be converted on our side:
+
+* **HEIC / HEIF** -- iPhone / iPad photos. Converted to PNG via
+ Pillow + pillow-heif.
+* **AVIF** -- modern web format. Same path (pillow-heif).
+* **Multi-frame TIFF** -- common for fax-scanned documents. Each frame
+ becomes a page in a single output PDF so the
+ downstream pipeline keeps a single
+ ``LoadedDocument`` per input.
+* **BMP** -- legacy Windows bitmap. PNG.
+* **Single-frame TIFF** -- usually fine for the LLM but normalising to
+ PNG keeps the per-tile decode predictable.
+* **SVG** -- vector. Rasterised to PNG via cairosvg.
+* **Animated GIF** -- first frame only (the LLM treats it as a still).
+"""
+
+from __future__ import annotations
+
+import io
+import logging
+import time
+from dataclasses import dataclass
+
+from pyfly.container import service
+
+from flydesk_idp.core.observability import log_outbound
+from flydesk_idp.core.services.binary.errors import ImageConversionError
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(slots=True, frozen=True)
+class NormalisedImage:
+ """Output of :class:`ImageNormalizer.convert`.
+
+ ``page_count`` is > 1 only for multi-frame TIFF that has been folded
+ into a multi-page PDF; for everything else it is 1.
+ """
+
+ bytes: bytes
+ media_type: str
+ page_count: int
+
+
+@service
+class ImageNormalizer:
+ """Pillow-driven converter for image formats the LLM can't read natively."""
+
+ # Formats Pillow's autodetect names; used to pick the conversion path.
+ _HEIC_TYPES = {"image/heic", "image/heif", "image/avif"}
+ _PASSTHROUGH = {"image/png", "image/jpeg", "image/gif", "image/webp"}
+
+ def convert(self, data: bytes, *, media_type: str, filename: str | None = None) -> NormalisedImage:
+ """Return a renderable PNG / PDF / passthrough image.
+
+ Raises :class:`ImageConversionError` on any decode / encode
+ failure. Pillow / pillow-heif must be installed at runtime.
+ """
+ if not data:
+ raise ImageConversionError("image bytes are empty", filename=filename)
+
+ if media_type in self._PASSTHROUGH:
+ return NormalisedImage(bytes=data, media_type=media_type, page_count=1)
+
+ if media_type == "image/svg+xml":
+ return self._svg_to_png(data, filename)
+ if media_type in self._HEIC_TYPES:
+ return self._heic_to_png(data, filename)
+ if media_type == "image/tiff":
+ return self._tiff_to_pdf(data, filename)
+ if media_type == "image/bmp":
+ return self._raster_to_png(data, "BMP", filename)
+
+ # Catch-all: try Pillow's autodetect + PNG re-encode. Covers exotic
+ # raster formats (PCX, TGA, etc.) without us having to enumerate.
+ return self._raster_to_png(data, fmt=None, filename=filename)
+
+ # ------------------------------------------------------------------
+
+ def _heic_to_png(self, data: bytes, filename: str | None) -> NormalisedImage:
+ # pillow-heif registers HEIF/HEIC/AVIF openers on import. Side-effect
+ # registration is the library's published API.
+ try:
+ import pillow_heif # pyright: ignore[reportMissingImports] # noqa: F401
+ except ImportError as exc: # pragma: no cover -- runtime dep guard
+ raise ImageConversionError(
+ "pillow-heif is required for HEIC/HEIF/AVIF input",
+ filename=filename,
+ ) from exc
+ return self._raster_to_png(data, fmt=None, filename=filename, library="pillow-heif")
+
+ def _raster_to_png(
+ self,
+ data: bytes,
+ fmt: str | None,
+ filename: str | None,
+ *,
+ library: str = "pillow",
+ ) -> NormalisedImage:
+ from PIL import Image, UnidentifiedImageError
+
+ started = time.monotonic()
+ try:
+ with Image.open(io.BytesIO(data)) as img:
+ if fmt and (img.format or "").upper() != fmt:
+ # Pillow autodetected something else; trust autodetect over
+ # the caller's MIME hint -- mismatched MIME is common.
+ pass
+ # Convert to RGB(A) so PNG re-encode never falls into a mode
+ # we can't write (e.g. CMYK from a TIFF).
+ if img.mode not in ("RGB", "RGBA", "L", "LA"):
+ img = img.convert("RGBA")
+ buf = io.BytesIO()
+ img.save(buf, format="PNG", optimize=True)
+ except UnidentifiedImageError as exc:
+ raise ImageConversionError(
+ f"image bytes are not a recognised raster format: {exc}",
+ filename=filename,
+ ) from exc
+ except Exception as exc: # noqa: BLE001
+ raise ImageConversionError(f"image conversion failed: {exc}", filename=filename) from exc
+ log_outbound(
+ library,
+ op="image.png",
+ status="ok",
+ latency_ms=(time.monotonic() - started) * 1000,
+ in_bytes=len(data),
+ out_bytes=buf.tell(),
+ )
+ return NormalisedImage(bytes=buf.getvalue(), media_type="image/png", page_count=1)
+
+ def _tiff_to_pdf(self, data: bytes, filename: str | None) -> NormalisedImage:
+ """Multi-frame TIFF → multi-page PDF; single-frame TIFF → PNG.
+
+ We bundle multi-frame scans into one PDF so the downstream
+ ``page_count`` matches what the document actually carries.
+ """
+ from PIL import Image, UnidentifiedImageError
+
+ started = time.monotonic()
+ try:
+ with Image.open(io.BytesIO(data)) as img:
+ frames: list[Image.Image] = []
+ try:
+ while True:
+ frames.append(img.copy().convert("RGB"))
+ img.seek(img.tell() + 1)
+ except EOFError:
+ pass
+ except UnidentifiedImageError as exc:
+ raise ImageConversionError(f"TIFF bytes are not parseable: {exc}", filename=filename) from exc
+ except Exception as exc: # noqa: BLE001
+ raise ImageConversionError(f"TIFF conversion failed: {exc}", filename=filename) from exc
+
+ if not frames:
+ raise ImageConversionError("TIFF contains no frames", filename=filename)
+
+ if len(frames) == 1:
+ buf = io.BytesIO()
+ frames[0].save(buf, format="PNG", optimize=True)
+ log_outbound(
+ "pillow",
+ op="tiff.png",
+ status="ok",
+ latency_ms=(time.monotonic() - started) * 1000,
+ frames=1,
+ out_bytes=buf.tell(),
+ )
+ return NormalisedImage(bytes=buf.getvalue(), media_type="image/png", page_count=1)
+
+ buf = io.BytesIO()
+ frames[0].save(
+ buf,
+ format="PDF",
+ save_all=True,
+ append_images=frames[1:],
+ resolution=200.0,
+ )
+ log_outbound(
+ "pillow",
+ op="tiff.pdf",
+ status="ok",
+ latency_ms=(time.monotonic() - started) * 1000,
+ frames=len(frames),
+ out_bytes=buf.tell(),
+ )
+ return NormalisedImage(
+ bytes=buf.getvalue(),
+ media_type="application/pdf",
+ page_count=len(frames),
+ )
+
+ def _svg_to_png(self, data: bytes, filename: str | None) -> NormalisedImage:
+ try:
+ import cairosvg # pyright: ignore[reportMissingImports]
+ except ImportError as exc: # pragma: no cover -- runtime dep guard
+ raise ImageConversionError("cairosvg is required for SVG input", filename=filename) from exc
+
+ started = time.monotonic()
+ try:
+ raw = cairosvg.svg2png(bytestring=data, output_width=2048)
+ except Exception as exc: # noqa: BLE001
+ raise ImageConversionError(f"SVG rasterisation failed: {exc}", filename=filename) from exc
+ if not raw:
+ raise ImageConversionError("SVG rasterisation produced no output", filename=filename)
+ png_bytes: bytes = raw if isinstance(raw, bytes) else bytes(raw)
+ log_outbound(
+ "cairosvg",
+ op="svg.png",
+ status="ok",
+ latency_ms=(time.monotonic() - started) * 1000,
+ in_bytes=len(data),
+ out_bytes=len(png_bytes),
+ )
+ return NormalisedImage(bytes=png_bytes, media_type="image/png", page_count=1)
diff --git a/src/flydesk_idp/core/services/binary/libreoffice.py b/src/flydesk_idp/core/services/binary/libreoffice.py
new file mode 100644
index 0000000..e81fa19
--- /dev/null
+++ b/src/flydesk_idp/core/services/binary/libreoffice.py
@@ -0,0 +1,173 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""Office document → PDF conversion via headless LibreOffice.
+
+Covers DOCX, DOC, XLSX, XLS, PPTX, PPT, ODT, ODS, ODP, RTF, HTML.
+Runs ``soffice --headless --convert-to pdf`` in a temporary directory
+so concurrent conversions don't collide on a shared user profile.
+
+For multilingual rendering the runtime image must ship the matching
+font packs (``fonts-noto-cjk``, ``fonts-noto-color-emoji``,
+``fonts-dejavu``, ``fonts-liberation``) -- otherwise non-Latin text
+falls back to ``.notdef`` glyphs and the resulting PDF has no text
+layer for those runs, breaking the bbox refiner downstream.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import shutil
+import tempfile
+import time
+import uuid
+from pathlib import Path
+
+from flydesk_idp.config import IDPSettings
+from flydesk_idp.core.observability import log_outbound
+from flydesk_idp.core.services.binary.errors import OfficeConversionError
+from flydesk_idp.core.services.binary.office_converter import (
+ OFFICE_MEDIA_TYPES,
+ OfficeConverter,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# Supported MIME → LibreOffice extension. The keys are a subset of
+# OFFICE_MEDIA_TYPES; the values are what soffice expects on disk so it
+# picks the right import filter.
+_OFFICE_TYPES: dict[str, str] = {
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
+ "application/msword": "doc",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
+ "application/vnd.ms-excel": "xls",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
+ "application/vnd.ms-powerpoint": "ppt",
+ "application/vnd.oasis.opendocument.text": "odt",
+ "application/vnd.oasis.opendocument.spreadsheet": "ods",
+ "application/vnd.oasis.opendocument.presentation": "odp",
+ "application/rtf": "rtf",
+ "text/rtf": "rtf",
+ "text/html": "html",
+}
+
+
+class LibreOfficeConverter(OfficeConverter):
+ """``OfficeConverter`` that shells out to local ``soffice``.
+
+ Used when the runtime image bundles LibreOffice (the slim/dev
+ Dockerfile path). For distroless deployments use
+ :class:`GotenbergConverter` instead.
+
+ Not decorated ``@service`` -- :class:`IDPCoreConfiguration` registers
+ it (or :class:`GotenbergConverter`) as the ``OfficeConverter`` bean
+ based on ``IDPSettings.office_converter``.
+ """
+
+ def __init__(self, settings: IDPSettings) -> None:
+ self._binary = settings.binary_libreoffice_path
+ self._timeout_s = settings.binary_libreoffice_timeout_s
+
+ @staticmethod
+ def supports(media_type: str) -> bool:
+ return media_type in OFFICE_MEDIA_TYPES
+
+ async def convert(
+ self,
+ data: bytes,
+ *,
+ media_type: str,
+ filename: str | None = None,
+ ) -> bytes:
+ """Return the converted PDF bytes.
+
+ Raises :class:`OfficeConversionError` on subprocess failure,
+ timeout, or missing binary.
+ """
+ ext = _OFFICE_TYPES.get(media_type)
+ if ext is None:
+ raise OfficeConversionError(
+ f"unsupported office media type: {media_type}",
+ filename=filename,
+ )
+
+ if shutil.which(self._binary) is None:
+ raise OfficeConversionError(
+ f"LibreOffice binary {self._binary!r} not found on PATH",
+ filename=filename,
+ )
+
+ # Per-call workdir so concurrent conversions don't share state.
+ # LibreOffice writes a user profile alongside; sharing it across
+ # concurrent runs deadlocks soffice.
+ workdir = Path(tempfile.mkdtemp(prefix="flydesk-soffice-"))
+ try:
+ stem = uuid.uuid4().hex
+ input_path = workdir / f"{stem}.{ext}"
+ output_path = workdir / f"{stem}.pdf"
+ input_path.write_bytes(data)
+
+ profile_dir = workdir / "profile"
+ profile_dir.mkdir()
+
+ cmd = [
+ self._binary,
+ "--headless",
+ "--norestore",
+ "--nologo",
+ "--nofirststartwizard",
+ f"-env:UserInstallation=file://{profile_dir}",
+ "--convert-to",
+ "pdf",
+ "--outdir",
+ str(workdir),
+ str(input_path),
+ ]
+ started = time.monotonic()
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ *cmd,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ try:
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=self._timeout_s)
+ except TimeoutError as exc:
+ proc.kill()
+ await proc.wait()
+ raise OfficeConversionError(
+ f"LibreOffice timed out after {self._timeout_s}s",
+ filename=filename,
+ ) from exc
+ except FileNotFoundError as exc:
+ raise OfficeConversionError(
+ f"LibreOffice binary not executable: {exc}", filename=filename
+ ) from exc
+ latency_ms = (time.monotonic() - started) * 1000
+
+ if proc.returncode != 0 or not output_path.exists():
+ err = (stderr or b"").decode("utf-8", errors="replace").strip()[-500:]
+ log_outbound(
+ "libreoffice",
+ op=f"convert.{ext}",
+ status="error",
+ latency_ms=latency_ms,
+ rc=proc.returncode,
+ error=err[:120],
+ )
+ raise OfficeConversionError(
+ f"LibreOffice failed (rc={proc.returncode}): {err or 'no stderr'}",
+ filename=filename,
+ )
+ pdf_bytes = output_path.read_bytes()
+ log_outbound(
+ "libreoffice",
+ op=f"convert.{ext}",
+ status="ok",
+ latency_ms=latency_ms,
+ in_bytes=len(data),
+ out_bytes=len(pdf_bytes),
+ )
+ return pdf_bytes
+ finally:
+ shutil.rmtree(workdir, ignore_errors=True)
diff --git a/src/flydesk_idp/core/services/binary/normalizer.py b/src/flydesk_idp/core/services/binary/normalizer.py
new file mode 100644
index 0000000..a3af7c5
--- /dev/null
+++ b/src/flydesk_idp/core/services/binary/normalizer.py
@@ -0,0 +1,339 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""``BinaryNormalizer`` -- the entry point for the binary normalization stage.
+
+Takes ``(bytes, declared_media_type, filename)`` and returns one or more
+:class:`NormalisedBinary` rows that downstream stages (extractor, bbox
+refiner, judge, etc.) can ship straight to the multimodal LLM.
+
+Routing rules (per inbound binary):
+
+* PDF -> :class:`PdfGuard` integrity check, passthrough.
+* PNG / JPEG / GIF / WebP -> passthrough.
+* HEIC / HEIF / AVIF / TIFF /
+ BMP / SVG -> :class:`ImageNormalizer` (PNG or PDF).
+* DOC / DOCX / XLS / XLSX /
+ PPT / PPTX / ODT / ODS /
+ ODP / RTF / HTML -> :class:`LibreOfficeConverter` (PDF).
+* ZIP / 7z / TAR / GZ -> :class:`ArchiveUnpacker`, recurse on each member.
+* EML / MSG -> :class:`EmailUnpacker`, recurse on each item.
+* Plain text / Markdown / CSV -> wrapped as text/html via a tiny
+ envelope, then through LibreOffice. (No
+ built-in renderer to keep deps tight.)
+* Anything else -> :class:`UnsupportedBinaryError`.
+
+Recursion depth and total fan-out are bounded by
+:class:`IDPSettings.binary_max_recursion_depth` and
+:class:`IDPSettings.binary_max_expanded_files`.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+
+from pyfly.container import service
+
+from flydesk_idp.config import IDPSettings
+from flydesk_idp.core.observability import log_outbound
+from flydesk_idp.core.services.binary.archive import ArchiveUnpacker
+from flydesk_idp.core.services.binary.email import EmailUnpacker
+from flydesk_idp.core.services.binary.errors import (
+ ArchiveExtractionError,
+ BinaryNormalizationError,
+ UnsupportedBinaryError,
+)
+from flydesk_idp.core.services.binary.image import ImageNormalizer
+from flydesk_idp.core.services.binary.office_converter import OfficeConverter
+from flydesk_idp.core.services.binary.pdf_guard import PdfGuard
+from flydesk_idp.core.services.binary.sniffer import sniff_media_type
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(slots=True, frozen=True)
+class NormalisedBinary:
+ """One LLM-renderable row produced by the normalizer.
+
+ A ZIP fan-out yields N rows with ``derived_from`` chaining to the
+ original archive name; a one-step PDF passthrough yields one row
+ with ``derived_from = ()``.
+ """
+
+ bytes: bytes
+ media_type: str
+ filename: str
+ page_count: int
+ derived_from: tuple[str, ...] = ()
+
+
+# Formats the LLM accepts natively -- straight passthrough.
+_LLM_NATIVE = {"image/png", "image/jpeg", "image/gif", "image/webp"}
+# Plain-text-ish formats wrapped through LibreOffice via a synthetic HTML
+# envelope. Keeps prose-extraction working without bringing in pandoc.
+_TEXT_WRAPPED = {"text/plain", "text/markdown", "text/csv"}
+
+
+@service
+class BinaryNormalizer:
+ """Turn any caller-supplied binary into LLM-renderable bytes."""
+
+ def __init__(
+ self,
+ *,
+ settings: IDPSettings,
+ pdf_guard: PdfGuard,
+ image: ImageNormalizer,
+ office: OfficeConverter,
+ archive: ArchiveUnpacker,
+ email_: EmailUnpacker,
+ ) -> None:
+ self._settings = settings
+ self._pdf_guard = pdf_guard
+ self._image = image
+ self._office = office
+ self._archive = archive
+ self._email = email_
+
+ async def normalise(
+ self,
+ data: bytes,
+ *,
+ declared_media_type: str | None = None,
+ filename: str | None = None,
+ ) -> list[NormalisedBinary]:
+ """Entry point. Returns one or more rows; never returns empty."""
+ if not data:
+ raise BinaryNormalizationError("inbound bytes are empty", filename=filename)
+ if not self._settings.binary_normalize_enabled:
+ media_type = sniff_media_type(data, default=declared_media_type, filename=filename)
+ return [
+ NormalisedBinary(
+ bytes=data,
+ media_type=media_type,
+ filename=filename or "document",
+ page_count=_page_count_for(data, media_type),
+ )
+ ]
+
+ rows = await self._dispatch(
+ data,
+ declared_media_type=declared_media_type,
+ filename=filename or "document",
+ depth=0,
+ ancestry=(),
+ )
+ if not rows:
+ raise BinaryNormalizationError(
+ "normalization produced no LLM-renderable output",
+ filename=filename,
+ )
+ log_outbound(
+ "binary",
+ op="normalise",
+ status="ok",
+ latency_ms=0.0,
+ in_filename=filename,
+ out_rows=len(rows),
+ )
+ return rows
+
+ # ------------------------------------------------------------------
+
+ async def _dispatch(
+ self,
+ data: bytes,
+ *,
+ declared_media_type: str | None,
+ filename: str,
+ depth: int,
+ ancestry: tuple[str, ...],
+ ) -> list[NormalisedBinary]:
+ if depth > self._settings.binary_max_recursion_depth:
+ raise ArchiveExtractionError(
+ f"binary nesting exceeds depth {self._settings.binary_max_recursion_depth}",
+ filename=filename,
+ )
+
+ media_type = sniff_media_type(data, default=declared_media_type, filename=filename)
+
+ # --- LLM-renderable passthroughs ---
+ if media_type in _LLM_NATIVE:
+ return [
+ NormalisedBinary(
+ bytes=data,
+ media_type=media_type,
+ filename=filename,
+ page_count=1,
+ derived_from=ancestry,
+ )
+ ]
+ if media_type == "application/pdf":
+ self._pdf_guard.check(data, filename=filename)
+ return [
+ NormalisedBinary(
+ bytes=data,
+ media_type=media_type,
+ filename=filename,
+ page_count=_page_count_for(data, media_type),
+ derived_from=ancestry,
+ )
+ ]
+
+ # --- Image conversions ---
+ if media_type in {
+ "image/heic",
+ "image/heif",
+ "image/avif",
+ "image/tiff",
+ "image/bmp",
+ "image/svg+xml",
+ }:
+ converted = self._image.convert(data, media_type=media_type, filename=filename)
+ new_name = _swap_extension(filename, converted.media_type)
+ return [
+ NormalisedBinary(
+ bytes=converted.bytes,
+ media_type=converted.media_type,
+ filename=new_name,
+ page_count=converted.page_count,
+ derived_from=(*ancestry, filename),
+ )
+ ]
+
+ # --- Office documents ---
+ if self._office.supports(media_type):
+ pdf_bytes = await self._office.convert(data, media_type=media_type, filename=filename)
+ return [
+ NormalisedBinary(
+ bytes=pdf_bytes,
+ media_type="application/pdf",
+ filename=_swap_extension(filename, "application/pdf"),
+ page_count=_page_count_for(pdf_bytes, "application/pdf"),
+ derived_from=(*ancestry, filename),
+ )
+ ]
+
+ # --- Archives (recurse) ---
+ if self._archive.supports(media_type):
+ members = self._archive.unpack(data, media_type=media_type, filename=filename)
+ rows: list[NormalisedBinary] = []
+ for path, member_bytes in members:
+ child_rows = await self._dispatch(
+ member_bytes,
+ declared_media_type=None,
+ filename=path.rsplit("/", 1)[-1],
+ depth=depth + 1,
+ ancestry=(*ancestry, filename),
+ )
+ rows.extend(child_rows)
+ self._enforce_total(rows, filename)
+ if not rows:
+ raise ArchiveExtractionError(
+ f"archive {filename!r} expanded to zero usable members",
+ filename=filename,
+ )
+ return rows
+
+ # --- Email (recurse) ---
+ if self._email.supports(media_type):
+ items = self._email.unpack(data, media_type=media_type, filename=filename)
+ rows = []
+ for path, item_bytes in items:
+ child_rows = await self._dispatch(
+ item_bytes,
+ declared_media_type=None,
+ filename=path,
+ depth=depth + 1,
+ ancestry=(*ancestry, filename),
+ )
+ rows.extend(child_rows)
+ self._enforce_total(rows, filename)
+ if not rows:
+ raise BinaryNormalizationError(
+ f"email {filename!r} carries no extractable content",
+ filename=filename,
+ )
+ return rows
+
+ # --- Plain-text-ish: wrap in + go through OfficeConverter ---
+ if media_type in _TEXT_WRAPPED:
+ wrapped = _wrap_text_as_html(data, media_type)
+ pdf_bytes = await self._office.convert(wrapped, media_type="text/html", filename=filename)
+ return [
+ NormalisedBinary(
+ bytes=pdf_bytes,
+ media_type="application/pdf",
+ filename=_swap_extension(filename, "application/pdf"),
+ page_count=_page_count_for(pdf_bytes, "application/pdf"),
+ derived_from=(*ancestry, filename),
+ )
+ ]
+
+ raise UnsupportedBinaryError(
+ f"no normalisation adapter for media type {media_type!r}",
+ filename=filename,
+ )
+
+ def _enforce_total(self, rows: list[NormalisedBinary], filename: str) -> None:
+ if len(rows) > self._settings.binary_max_expanded_files:
+ raise ArchiveExtractionError(
+ f"expansion of {filename!r} exceeds max {self._settings.binary_max_expanded_files} files",
+ filename=filename,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _swap_extension(filename: str, media_type: str) -> str:
+ """Return ``filename`` with its extension swapped for the new media type."""
+ ext_for = {
+ "application/pdf": "pdf",
+ "image/png": "png",
+ "image/jpeg": "jpg",
+ "image/gif": "gif",
+ "image/webp": "webp",
+ }
+ new_ext = ext_for.get(media_type, "bin")
+ stem = filename.rsplit(".", 1)[0] if "." in filename else filename
+ return f"{stem}.{new_ext}"
+
+
+def _page_count_for(data: bytes, media_type: str) -> int:
+ if media_type != "application/pdf":
+ return 1
+ import io as _io
+
+ import pypdf
+
+ try:
+ reader = pypdf.PdfReader(_io.BytesIO(data), strict=False)
+ return max(1, len(reader.pages))
+ except Exception: # noqa: BLE001
+ return 1
+
+
+def _wrap_text_as_html(data: bytes, media_type: str) -> bytes:
+ """Wrap plain text / markdown / CSV in a tiny HTML envelope.
+
+ Escapes the content so pre-formatted layout survives LibreOffice's
+ HTML import. Charset hint set to UTF-8; Pillow / cairosvg / Office
+ are byte-clean elsewhere so this is the one place we declare it.
+ """
+ import html as _html
+
+ text = data.decode("utf-8", errors="replace")
+ body = _html.escape(text)
+ if media_type == "text/csv":
+ # Render as a monospace block; LibreOffice will treat it as text.
+ body = f"{body} "
+ elif media_type == "text/markdown":
+ # Conservative: render as preformatted text. We don't ship a
+ # markdown -> HTML converter to keep the dep surface tight.
+ body = f"{body} "
+ else:
+ body = f"{body} "
+ html = f'\n {body}'
+ return html.encode("utf-8")
diff --git a/src/flydesk_idp/core/services/binary/office_converter.py b/src/flydesk_idp/core/services/binary/office_converter.py
new file mode 100644
index 0000000..43931f7
--- /dev/null
+++ b/src/flydesk_idp/core/services/binary/office_converter.py
@@ -0,0 +1,69 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""Office → PDF conversion behind a single ``OfficeConverter`` protocol.
+
+Two concrete adapters ship in-tree:
+
+* :class:`GotenbergConverter` -- HTTP client against a Gotenberg sidecar.
+ The canonical choice for distroless deployments: the API + worker
+ containers carry no ``soffice`` binary at all and just POST bytes to
+ Gotenberg, which owns LibreOffice + headless Chromium internally in
+ its own container.
+* :class:`LibreOfficeConverter` -- in-container subprocess wrapper.
+ Suitable for the slim/dev image (``python:3.13-slim`` + LibreOffice
+ installed via apt). Kept as a drop-in fallback for development without
+ the sidecar.
+
+The active adapter is picked by ``IDPSettings.office_converter`` and
+exposed as the ``OfficeConverter`` bean by
+:class:`IDPCoreConfiguration`. Every consumer (today only
+:class:`BinaryNormalizer`) injects the protocol -- the concrete classes
+stay private to this package.
+"""
+
+from __future__ import annotations
+
+from typing import Protocol, runtime_checkable
+
+# Re-export the supported MIME set so the normalizer's ``supports`` check
+# stays adapter-agnostic.
+OFFICE_MEDIA_TYPES: frozenset[str] = frozenset(
+ {
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/msword",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/vnd.ms-excel",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "application/vnd.ms-powerpoint",
+ "application/vnd.oasis.opendocument.text",
+ "application/vnd.oasis.opendocument.spreadsheet",
+ "application/vnd.oasis.opendocument.presentation",
+ "application/rtf",
+ "text/rtf",
+ "text/html",
+ }
+)
+
+
+@runtime_checkable
+class OfficeConverter(Protocol):
+ """Convert Office bytes to PDF bytes.
+
+ Implementations raise :class:`OfficeConversionError` on any failure.
+ The contract is async because every realistic adapter is I/O-bound
+ (subprocess wait or HTTP round-trip).
+ """
+
+ @staticmethod
+ def supports(media_type: str) -> bool: ...
+
+ async def convert(
+ self,
+ data: bytes,
+ *,
+ media_type: str,
+ filename: str | None = None,
+ ) -> bytes: ...
+
+
+def _supports(media_type: str) -> bool:
+ return media_type in OFFICE_MEDIA_TYPES
diff --git a/src/flydesk_idp/core/services/binary/pdf_guard.py b/src/flydesk_idp/core/services/binary/pdf_guard.py
new file mode 100644
index 0000000..34a9b6d
--- /dev/null
+++ b/src/flydesk_idp/core/services/binary/pdf_guard.py
@@ -0,0 +1,60 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""PDF integrity + encryption guard.
+
+Runs as the very first step of the normalizer for every ``application/pdf``
+input -- so encrypted / corrupt PDFs fail fast with a typed error
+instead of crashing PyMuPDF / pypdf later in the pipeline.
+"""
+
+from __future__ import annotations
+
+import io
+import logging
+
+from pyfly.container import service
+
+from flydesk_idp.core.services.binary.errors import (
+ BinaryNormalizationError,
+ EncryptedPdfError,
+)
+
+logger = logging.getLogger(__name__)
+
+
+@service
+class PdfGuard:
+ """Reject encrypted PDFs and confirm the file is parseable."""
+
+ def check(self, data: bytes, *, filename: str | None = None) -> None:
+ """Raise on encrypted / truncated / corrupt PDFs; return on success.
+
+ Cheap: opens the PDF with pypdf in non-strict mode, peeks at the
+ encryption flag and the page tree. No content streams parsed.
+ """
+ import pypdf
+ from pypdf.errors import PdfReadError
+
+ try:
+ reader = pypdf.PdfReader(io.BytesIO(data), strict=False)
+ except PdfReadError as exc:
+ raise BinaryNormalizationError(f"PDF cannot be parsed: {exc}", filename=filename) from exc
+ except Exception as exc: # noqa: BLE001
+ raise BinaryNormalizationError(f"PDF cannot be parsed: {exc}", filename=filename) from exc
+
+ if reader.is_encrypted:
+ # We never attempt empty-password decrypt -- if the document
+ # really has no password, the caller can re-export it
+ # client-side. (A future request surface may carry a
+ # ``password`` field; this guard would consume it here.)
+ raise EncryptedPdfError(
+ "PDF is encrypted; supply an unprotected copy.",
+ filename=filename,
+ )
+
+ # Touch the page tree so a corrupt xref bubbles up here, not later.
+ try:
+ page_count = len(reader.pages)
+ except Exception as exc: # noqa: BLE001
+ raise BinaryNormalizationError(f"PDF page tree is corrupt: {exc}", filename=filename) from exc
+ if page_count <= 0:
+ raise BinaryNormalizationError("PDF reports zero pages.", filename=filename)
diff --git a/src/flydesk_idp/core/services/binary/sniffer.py b/src/flydesk_idp/core/services/binary/sniffer.py
new file mode 100644
index 0000000..a1b3a91
--- /dev/null
+++ b/src/flydesk_idp/core/services/binary/sniffer.py
@@ -0,0 +1,174 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""Magic-byte sniffer covering every format the binary normalizer handles.
+
+Kept as a free function rather than a service: it has no dependencies,
+no state, and is called from both the normalizer and the legacy loader.
+The same return values double as the routing key -- the normalizer
+dispatches per sniffed type to the appropriate adapter.
+"""
+
+from __future__ import annotations
+
+# Format magic bytes. Sources: file(1) magic database, RFC 1952 (gzip),
+# RFC 1950 (zlib), Microsoft OOXML / OLE / EML specs.
+_PDF = b"%PDF-"
+_PNG = b"\x89PNG\r\n\x1a\n"
+_JPEG = b"\xff\xd8\xff"
+_GIF87 = b"GIF87a"
+_GIF89 = b"GIF89a"
+_RIFF = b"RIFF"
+_WEBP = b"WEBP"
+_TIFF_LE = b"II*\x00"
+_TIFF_BE = b"MM\x00*"
+_BMP = b"BM"
+_ZIP = b"PK\x03\x04"
+_ZIP_EMPTY = b"PK\x05\x06"
+_ZIP_SPANNED = b"PK\x07\x08"
+_GZ = b"\x1f\x8b"
+_7Z = b"7z\xbc\xaf\x27\x1c"
+_TAR_USTAR = b"ustar" # at offset 257
+_OLE_CFB = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" # legacy .doc/.xls/.ppt and .msg
+_HEIF_FTYP_OFFSET = 4
+_HEIF_BRANDS = {
+ b"heic",
+ b"heix",
+ b"hevc",
+ b"hevx",
+ b"heim",
+ b"heis",
+ b"hevm",
+ b"hevs",
+ b"mif1",
+ b"msf1",
+ b"avif",
+}
+# Plain-text-ish formats sniffed by content rather than magic.
+_SVG_HINTS = (b" str:
+ """Best-effort content-type detection from magic bytes + filename hint.
+
+ The filename hint disambiguates ZIP-based formats (DOCX, XLSX, PPTX,
+ ODT all share the ZIP magic) and picks up text-ish formats whose
+ bytes don't have unique magic.
+
+ Returns the *normalized* MIME (lowercased, no parameters). Falls back
+ to ``default`` (also normalized) or ``application/octet-stream``.
+ """
+ if not data:
+ return _normalize(default)
+
+ # Binary magic, longest-prefix-first to avoid false positives.
+ if data.startswith(_PDF):
+ return "application/pdf"
+ if data.startswith(_PNG):
+ return "image/png"
+ if data.startswith(_JPEG):
+ return "image/jpeg"
+ if data.startswith(_GIF87) or data.startswith(_GIF89):
+ return "image/gif"
+ if data[:4] == _RIFF and data[8:12] == _WEBP:
+ return "image/webp"
+ if data.startswith(_TIFF_LE) or data.startswith(_TIFF_BE):
+ return "image/tiff"
+ if data.startswith(_BMP):
+ return "image/bmp"
+ if data.startswith(_7Z):
+ return "application/x-7z-compressed"
+ if data.startswith(_GZ):
+ return "application/gzip"
+ if len(data) >= 12 and data[_HEIF_FTYP_OFFSET : _HEIF_FTYP_OFFSET + 4] == b"ftyp":
+ brand = data[8:12].lower()
+ if brand in _HEIF_BRANDS:
+ return "image/avif" if brand == b"avif" else "image/heic"
+ if data.startswith(_OLE_CFB):
+ # Legacy .doc/.xls/.ppt or Outlook .msg. Filename disambiguates.
+ if filename:
+ ext = _ext(filename)
+ if ext == "msg":
+ return "application/vnd.ms-outlook"
+ if ext == "doc":
+ return "application/msword"
+ if ext == "xls":
+ return "application/vnd.ms-excel"
+ if ext == "ppt":
+ return "application/vnd.ms-powerpoint"
+ return "application/x-ole-compound"
+ if data.startswith(_ZIP) or data.startswith(_ZIP_EMPTY) or data.startswith(_ZIP_SPANNED):
+ # Distinguish OOXML / ODF / EPUB / plain ZIP via filename ext.
+ ext = _ext(filename) if filename else ""
+ ooxml = {
+ "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "odt": "application/vnd.oasis.opendocument.text",
+ "ods": "application/vnd.oasis.opendocument.spreadsheet",
+ "odp": "application/vnd.oasis.opendocument.presentation",
+ "epub": "application/epub+zip",
+ }
+ if ext in ooxml:
+ return ooxml[ext]
+ return "application/zip"
+ # TAR magic lives at offset 257.
+ if len(data) >= 265 and data[257:262] == _TAR_USTAR:
+ return "application/x-tar"
+
+ # Text-ish heuristics on the first kilobyte.
+ head = data[:1024].lstrip()
+ head_lower = head.lower()
+ if any(head_lower.startswith(h.lower()) for h in _SVG_HINTS) and b" str:
+ if not value:
+ return "application/octet-stream"
+ return value.lower().split(";")[0].strip() or "application/octet-stream"
+
+
+def _ext(filename: str | None) -> str:
+ if not filename or "." not in filename:
+ return ""
+ return filename.rsplit(".", 1)[1].lower()
diff --git a/src/flydesk_idp/core/services/pipeline/orchestrator.py b/src/flydesk_idp/core/services/pipeline/orchestrator.py
index 57a2c21..22af6d2 100644
--- a/src/flydesk_idp/core/services/pipeline/orchestrator.py
+++ b/src/flydesk_idp/core/services/pipeline/orchestrator.py
@@ -52,6 +52,7 @@
VisualAuthenticityChecker,
)
from flydesk_idp.core.services.bbox import BboxValidator
+from flydesk_idp.core.services.binary import BinaryNormalizer
from flydesk_idp.core.services.classification import (
UNMATCHED,
ClassificationResult,
@@ -59,7 +60,6 @@
)
from flydesk_idp.core.services.escalation import JudgeEscalator
from flydesk_idp.core.services.extraction.extractor import MultimodalExtractor
-from flydesk_idp.core.services.extraction.loader import load_document
from flydesk_idp.core.services.extraction.pdf_slicer import PageRange, slice_pdf
from flydesk_idp.core.services.judge import Judge
from flydesk_idp.core.services.rules import RuleEngine
@@ -204,6 +204,7 @@ def __init__(
classifier: DocumentClassifier,
field_validator: FieldValidator,
bbox_validator: BboxValidator,
+ binary_normalizer: BinaryNormalizer,
visual_checker: VisualAuthenticityChecker,
content_checker: ContentAuthenticityChecker,
judge: Judge,
@@ -216,6 +217,7 @@ def __init__(
self._classifier = classifier
self._field_validator = field_validator
self._bbox_validator = bbox_validator
+ self._binary_normalizer = binary_normalizer
self._visual_checker = visual_checker
self._content_checker = content_checker
self._judge = judge
@@ -339,49 +341,67 @@ async def _execute_inner(self, request: ExtractionRequest, started: float) -> Ex
# ------------------------------------------------------------------
async def _step_load(self, ctx: PipelineContext, _inputs: dict[str, Any]) -> Any:
- """Sniff media type + page count for every input file.
-
- Each file becomes a ``_FileSlot`` carrying one default segment
- that covers the whole file. The discover stage may replace the
- segment list with finer-grained ones; the classifier may later
- fill in each segment's ``resolved_doctype``. Pinned files keep
- the single default segment with ``resolved_doctype`` set from
- the pin.
+ """Normalise every input binary and emit one ``_FileSlot`` per row.
+
+ Each inbound :class:`DocumentInput` flows through
+ :class:`BinaryNormalizer`. A born-digital PDF or a clean PNG
+ passes through 1:1; a DOCX is converted to PDF; a HEIC photo
+ becomes PNG; a multi-frame TIFF becomes a multi-page PDF; a ZIP
+ / EML+attachments fan out into multiple slots. ``derived_from``
+ chains let the response trace each output back to the original
+ upload.
+
+ The doctype pin survives only when normalisation produced
+ exactly one row -- a multi-row expansion (ZIP, email) makes the
+ original pin ambiguous, so the classifier is asked to decide
+ per-row.
"""
request: ExtractionRequest = ctx.metadata["request"]
files: list[_FileSlot] = []
- for i, file in enumerate(request.files):
+ # Slot index is monotonic across the expansion of all inputs.
+ slot_index = 0
+ for file in request.files:
document_bytes = file.decoded_bytes()
- loaded = load_document(
+ normalised = await self._binary_normalizer.normalise(
document_bytes,
declared_media_type=request.options.declared_media_type or file.content_type,
- )
- slot = _FileSlot(
- file_index=i,
filename=file.filename,
- media_type=loaded.media_type,
- page_count=loaded.page_count,
- document_bytes=document_bytes,
- declared_doctype=file.document_type,
)
- # Default: one segment per file covering everything. The
- # discover stage may replace this with finer-grained segments.
- slot.segments = [
- _Segment(
- file_index=i,
- filename=slot.filename,
- media_type=slot.media_type,
- page_start=1,
- page_end=slot.page_count,
- file_page_count=slot.page_count,
- segmentation_confidence=1.0,
- resolved_doctype=slot.declared_doctype,
- pinned=slot.declared_doctype is not None,
+ multi_row = len(normalised) > 1
+ for row in normalised:
+ effective_doctype = file.document_type if not multi_row else None
+ slot_filename = (
+ "/".join((*row.derived_from, row.filename)) if row.derived_from else row.filename
)
- ]
- files.append(slot)
+ slot = _FileSlot(
+ file_index=slot_index,
+ filename=slot_filename,
+ media_type=row.media_type,
+ page_count=row.page_count,
+ document_bytes=row.bytes,
+ declared_doctype=effective_doctype,
+ )
+ slot.segments = [
+ _Segment(
+ file_index=slot_index,
+ filename=slot.filename,
+ media_type=slot.media_type,
+ page_start=1,
+ page_end=slot.page_count,
+ file_page_count=slot.page_count,
+ segmentation_confidence=1.0,
+ resolved_doctype=slot.declared_doctype,
+ pinned=slot.declared_doctype is not None,
+ )
+ ]
+ files.append(slot)
+ slot_index += 1
+ # ``is_multi_file`` is recomputed after expansion so a single
+ # uploaded ZIP that fans out to N members surfaces as multi-file.
+ is_multi_file = len(files) > 1 or any(s.declared_doctype for s in files)
+ ctx.metadata["is_multi_file"] = is_multi_file
ctx.metadata["files_data"] = files
- return {"file_count": len(files), "is_multi_file": ctx.metadata["is_multi_file"]}
+ return {"file_count": len(files), "is_multi_file": is_multi_file}
async def _step_discover(self, ctx: PipelineContext, _inputs: dict[str, Any]) -> Any:
"""Per-file splitter: enumerate every distinct sub-document.
diff --git a/src/flydesk_idp/web/advice/exception_advice.py b/src/flydesk_idp/web/advice/exception_advice.py
index b9eb43b..a547836 100644
--- a/src/flydesk_idp/web/advice/exception_advice.py
+++ b/src/flydesk_idp/web/advice/exception_advice.py
@@ -12,6 +12,7 @@
from pyfly.web import controller_advice, exception_handler
+from flydesk_idp.core.services.binary import BinaryNormalizationError
from flydesk_idp.core.services.jobs.cancel_job_handler import JobNotCancellable
from flydesk_idp.core.services.jobs.get_job_result_handler import JobNotReady
from flydesk_idp.interfaces.dtos.error import ProblemDetails
@@ -42,6 +43,29 @@ async def job_not_cancellable(self, exc: JobNotCancellable) -> dict[str, Any]:
)
return problem.model_dump(exclude_none=True)
+ @exception_handler(BinaryNormalizationError)
+ async def binary_normalization_failed(self, exc: BinaryNormalizationError) -> dict[str, Any]:
+ """Map every BinaryNormalizationError subclass to a 422 problem-detail.
+
+ ``code`` carries the subclass-specific stable identifier
+ (``encrypted_pdf``, ``office_conversion_failed``, ...) so callers
+ can branch on the failure mode without parsing ``detail``.
+ Registered BEFORE the generic ``ValueError`` handler so the more
+ specific one wins.
+ """
+ extensions: dict[str, Any] = {}
+ if getattr(exc, "filename", None):
+ extensions["filename"] = exc.filename
+ problem = ProblemDetails(
+ type=f"https://flydesk.dev/problems/{exc.code.replace('_', '-')}",
+ title="Binary could not be normalised",
+ status=exc.http_status,
+ code=exc.code,
+ detail=str(exc),
+ extensions=extensions or None,
+ )
+ return problem.model_dump(exclude_none=True)
+
@exception_handler(ValueError)
async def invalid_request(self, exc: ValueError) -> dict[str, Any]:
problem = ProblemDetails(
diff --git a/tests/unit/test_binary_archive.py b/tests/unit/test_binary_archive.py
new file mode 100644
index 0000000..60b9fa2
--- /dev/null
+++ b/tests/unit/test_binary_archive.py
@@ -0,0 +1,99 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""ArchiveUnpacker -- ZIP, TAR, GZIP expansion + limits."""
+
+from __future__ import annotations
+
+import gzip
+import io
+import tarfile
+import zipfile
+
+import pytest
+
+from flydesk_idp.config import IDPSettings
+from flydesk_idp.core.services.binary.archive import ArchiveUnpacker
+from flydesk_idp.core.services.binary.errors import ArchiveExtractionError
+
+
+def _settings(max_files: int = 50) -> IDPSettings:
+ return IDPSettings(binary_max_expanded_files=max_files)
+
+
+def _zip(members: dict[str, bytes]) -> bytes:
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ for name, payload in members.items():
+ zf.writestr(name, payload)
+ return buf.getvalue()
+
+
+def _tar(members: dict[str, bytes]) -> bytes:
+ buf = io.BytesIO()
+ with tarfile.open(fileobj=buf, mode="w") as tf:
+ for name, payload in members.items():
+ info = tarfile.TarInfo(name=name)
+ info.size = len(payload)
+ tf.addfile(info, io.BytesIO(payload))
+ return buf.getvalue()
+
+
+def test_unpacks_zip() -> None:
+ members = ArchiveUnpacker(_settings()).unpack(
+ _zip({"a.pdf": b"%PDF-1.4\nbody", "b.png": b"\x89PNG\r\n\x1a\ndata"}),
+ media_type="application/zip",
+ )
+ paths = sorted(p for p, _ in members)
+ assert paths == ["a.pdf", "b.png"]
+
+
+def test_unpacks_tar() -> None:
+ members = ArchiveUnpacker(_settings()).unpack(
+ _tar({"x.txt": b"hello", "y.pdf": b"%PDF-1.4\nx"}),
+ media_type="application/x-tar",
+ )
+ assert {p for p, _ in members} == {"x.txt", "y.pdf"}
+
+
+def test_unpacks_gzip_single_file() -> None:
+ raw = b"%PDF-1.4\nhello"
+ gz_bytes = gzip.compress(raw)
+ members = ArchiveUnpacker(_settings()).unpack(
+ gz_bytes, media_type="application/gzip", filename="report.pdf.gz"
+ )
+ assert len(members) == 1
+ name, payload = members[0]
+ assert payload == raw
+ assert name == "report.pdf"
+
+
+def test_zip_password_protected_member_raises() -> None:
+ # ``zipfile`` cannot write encrypted ZIPs; flip the encryption bit
+ # post-hoc on the local file header + central directory header so the
+ # bytes look encrypted to a reader without us actually encrypting.
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr("locked.txt", b"clear bytes")
+ raw = bytearray(buf.getvalue())
+ # Local file header: signature PK\x03\x04, then version (2 bytes),
+ # then general purpose flag (2 bytes) at offset 6.
+ raw[6] |= 0x01
+ # Central directory header: signature PK\x01\x02, then version (4 bytes),
+ # then general purpose flag (2 bytes) at offset 8 from PK\x01\x02.
+ cd_sig = raw.find(b"PK\x01\x02")
+ if cd_sig != -1:
+ raw[cd_sig + 8] |= 0x01
+ with pytest.raises(ArchiveExtractionError) as ei:
+ ArchiveUnpacker(_settings()).unpack(bytes(raw), media_type="application/zip")
+ assert "password" in str(ei.value).lower()
+
+
+def test_zip_corrupt_raises() -> None:
+ with pytest.raises(ArchiveExtractionError):
+ ArchiveUnpacker(_settings()).unpack(b"PK\x03\x04junk", media_type="application/zip")
+
+
+def test_zip_fan_out_limit_enforced() -> None:
+ members = {f"f{i}.txt": b"x" for i in range(10)}
+ with pytest.raises(ArchiveExtractionError) as ei:
+ ArchiveUnpacker(_settings(max_files=3)).unpack(_zip(members), media_type="application/zip")
+ assert "max" in str(ei.value)
diff --git a/tests/unit/test_binary_email.py b/tests/unit/test_binary_email.py
new file mode 100644
index 0000000..551d8f4
--- /dev/null
+++ b/tests/unit/test_binary_email.py
@@ -0,0 +1,43 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""EmailUnpacker -- EML attachments + body."""
+
+from __future__ import annotations
+
+from email.message import EmailMessage
+
+from flydesk_idp.core.services.binary.email import EmailUnpacker
+
+
+def _eml_with_attachment() -> bytes:
+ msg = EmailMessage()
+ msg["From"] = "a@b.com"
+ msg["To"] = "c@d.com"
+ msg["Subject"] = "test"
+ msg.set_content("body text here")
+ msg.add_attachment(
+ b"%PDF-1.4\nfake pdf",
+ maintype="application",
+ subtype="pdf",
+ filename="passport.pdf",
+ )
+ return msg.as_bytes()
+
+
+def test_eml_yields_attachment_and_text_body() -> None:
+ items = EmailUnpacker().unpack(_eml_with_attachment(), media_type="message/rfc822")
+ by_name = {name: payload for name, payload in items}
+ assert "passport.pdf" in by_name
+ assert by_name["passport.pdf"].startswith(b"%PDF-")
+ assert "body.txt" in by_name
+ assert b"body text here" in by_name["body.txt"]
+
+
+def test_eml_without_attachments_yields_body_only() -> None:
+ msg = EmailMessage()
+ msg["From"] = "a@b.com"
+ msg["Subject"] = "x"
+ msg.set_content("body only")
+ items = EmailUnpacker().unpack(msg.as_bytes(), media_type="message/rfc822")
+ names = [name for name, _ in items]
+ assert "body.txt" in names
+ assert all(name != "passport.pdf" for name in names)
diff --git a/tests/unit/test_binary_gotenberg.py b/tests/unit/test_binary_gotenberg.py
new file mode 100644
index 0000000..23eb542
--- /dev/null
+++ b/tests/unit/test_binary_gotenberg.py
@@ -0,0 +1,63 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""``GotenbergConverter`` -- HTTP adapter mocked via respx."""
+
+from __future__ import annotations
+
+import httpx
+import pytest
+import respx
+
+from flydesk_idp.config import IDPSettings
+from flydesk_idp.core.services.binary.errors import OfficeConversionError
+from flydesk_idp.core.services.binary.gotenberg import GotenbergConverter
+
+_DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+
+
+def _settings() -> IDPSettings:
+ return IDPSettings(gotenberg_url="http://gotenberg:3000", gotenberg_timeout_s=5)
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_posts_to_libreoffice_endpoint_for_docx() -> None:
+ route = respx.post("http://gotenberg:3000/forms/libreoffice/convert").mock(
+ return_value=httpx.Response(200, content=b"%PDF-fake")
+ )
+ out = await GotenbergConverter(_settings()).convert(
+ b"docx-bytes", media_type=_DOCX, filename="report.docx"
+ )
+ assert out == b"%PDF-fake"
+ assert route.called
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_posts_to_chromium_endpoint_for_html() -> None:
+ route = respx.post("http://gotenberg:3000/forms/chromium/convert/html").mock(
+ return_value=httpx.Response(200, content=b"%PDF-html")
+ )
+ out = await GotenbergConverter(_settings()).convert(
+ b"", media_type="text/html", filename="page.html"
+ )
+ assert out == b"%PDF-html"
+ assert route.called
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_4xx_raises_typed_error() -> None:
+ respx.post("http://gotenberg:3000/forms/libreoffice/convert").mock(
+ return_value=httpx.Response(400, text="bad input")
+ )
+ with pytest.raises(OfficeConversionError) as ei:
+ await GotenbergConverter(_settings()).convert(b"x", media_type=_DOCX, filename="r.docx")
+ assert "400" in str(ei.value)
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_network_error_raises_typed_error() -> None:
+ respx.post("http://gotenberg:3000/forms/libreoffice/convert").mock(side_effect=httpx.ConnectError("nope"))
+ with pytest.raises(OfficeConversionError):
+ await GotenbergConverter(_settings()).convert(b"x", media_type=_DOCX, filename="r.docx")
diff --git a/tests/unit/test_binary_image.py b/tests/unit/test_binary_image.py
new file mode 100644
index 0000000..1e8af7c
--- /dev/null
+++ b/tests/unit/test_binary_image.py
@@ -0,0 +1,94 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""ImageNormalizer paths -- passthrough, BMP/TIFF/SVG/multi-frame TIFF."""
+
+from __future__ import annotations
+
+import io
+
+import pytest
+from PIL import Image
+
+from flydesk_idp.core.services.binary.errors import ImageConversionError
+from flydesk_idp.core.services.binary.image import ImageNormalizer
+
+
+def _png_bytes(size: tuple[int, int] = (32, 32), color: str = "red") -> bytes:
+ buf = io.BytesIO()
+ Image.new("RGB", size, color).save(buf, format="PNG")
+ return buf.getvalue()
+
+
+def _bmp_bytes() -> bytes:
+ buf = io.BytesIO()
+ Image.new("RGB", (16, 16), "blue").save(buf, format="BMP")
+ return buf.getvalue()
+
+
+def _tiff_bytes(frames: int = 1) -> bytes:
+ buf = io.BytesIO()
+ images = [Image.new("RGB", (16, 16), color) for color in (["red", "green", "blue"][:frames])]
+ images[0].save(buf, format="TIFF", save_all=True, append_images=images[1:])
+ return buf.getvalue()
+
+
+def _svg_bytes() -> bytes:
+ return (
+ b''
+ b''
+ b' '
+ )
+
+
+def test_png_passthrough_keeps_bytes() -> None:
+ data = _png_bytes()
+ out = ImageNormalizer().convert(data, media_type="image/png")
+ assert out.bytes is data
+ assert out.media_type == "image/png"
+ assert out.page_count == 1
+
+
+def test_bmp_converts_to_png() -> None:
+ out = ImageNormalizer().convert(_bmp_bytes(), media_type="image/bmp")
+ assert out.media_type == "image/png"
+ assert out.page_count == 1
+ # Output is decodable PNG.
+ Image.open(io.BytesIO(out.bytes)).verify()
+
+
+def test_single_frame_tiff_becomes_png() -> None:
+ out = ImageNormalizer().convert(_tiff_bytes(frames=1), media_type="image/tiff")
+ assert out.media_type == "image/png"
+ assert out.page_count == 1
+
+
+def test_multi_frame_tiff_becomes_pdf() -> None:
+ out = ImageNormalizer().convert(_tiff_bytes(frames=3), media_type="image/tiff")
+ assert out.media_type == "application/pdf"
+ assert out.page_count == 3
+ assert out.bytes.startswith(b"%PDF-")
+
+
+def test_svg_rasterises_to_png() -> None:
+ # cairosvg dlopens libcairo at import time. Local dev machines without
+ # libcairo installed (macOS, distroless base) skip this -- the runtime
+ # Docker image bundles libcairo2 so the path is exercised end-to-end
+ # via integration tests.
+ pytest.importorskip(
+ "cairosvg",
+ reason="libcairo not available in this environment (installed in the runtime Docker image)",
+ exc_type=(ImportError, OSError),
+ )
+ out = ImageNormalizer().convert(_svg_bytes(), media_type="image/svg+xml")
+ assert out.media_type == "image/png"
+ assert out.page_count == 1
+ Image.open(io.BytesIO(out.bytes)).verify()
+
+
+def test_empty_bytes_raises() -> None:
+ with pytest.raises(ImageConversionError):
+ ImageNormalizer().convert(b"", media_type="image/png")
+
+
+def test_garbage_image_raises() -> None:
+ with pytest.raises(ImageConversionError):
+ ImageNormalizer().convert(b"random not image bytes", media_type="image/bmp")
diff --git a/tests/unit/test_binary_normalizer.py b/tests/unit/test_binary_normalizer.py
new file mode 100644
index 0000000..cb2a90a
--- /dev/null
+++ b/tests/unit/test_binary_normalizer.py
@@ -0,0 +1,223 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""``BinaryNormalizer`` orchestration -- routing + ZIP/EML fan-out + limits."""
+
+from __future__ import annotations
+
+import gzip
+import io
+import zipfile
+from email.message import EmailMessage
+
+import pytest
+from PIL import Image
+from reportlab.pdfgen import canvas
+
+from flydesk_idp.config import IDPSettings
+from flydesk_idp.core.services.binary.archive import ArchiveUnpacker
+from flydesk_idp.core.services.binary.email import EmailUnpacker
+from flydesk_idp.core.services.binary.errors import (
+ ArchiveExtractionError,
+ OfficeConversionError,
+ UnsupportedBinaryError,
+)
+from flydesk_idp.core.services.binary.image import ImageNormalizer
+from flydesk_idp.core.services.binary.normalizer import BinaryNormalizer
+from flydesk_idp.core.services.binary.office_converter import (
+ OFFICE_MEDIA_TYPES,
+ OfficeConverter,
+)
+from flydesk_idp.core.services.binary.pdf_guard import PdfGuard
+
+
+class _StubOffice(OfficeConverter):
+ """Office converter that records calls and returns a fake PDF."""
+
+ def __init__(self, *, fail: bool = False) -> None:
+ self.calls: list[tuple[str, str | None]] = []
+ self.fail = fail
+
+ @staticmethod
+ def supports(media_type: str) -> bool:
+ return media_type in OFFICE_MEDIA_TYPES
+
+ async def convert(self, data: bytes, *, media_type: str, filename: str | None = None) -> bytes:
+ self.calls.append((media_type, filename))
+ if self.fail:
+ raise OfficeConversionError("stub failure", filename=filename)
+ return _build_pdf()
+
+
+def _build_pdf() -> bytes:
+ buf = io.BytesIO()
+ c = canvas.Canvas(buf)
+ c.drawString(100, 750, "hello")
+ c.showPage()
+ c.save()
+ return buf.getvalue()
+
+
+def _png() -> bytes:
+ buf = io.BytesIO()
+ Image.new("RGB", (16, 16), "red").save(buf, format="PNG")
+ return buf.getvalue()
+
+
+def _heic_like() -> bytes:
+ # Synthesise a HEIC magic header so the sniffer routes correctly. The
+ # ImageNormalizer.convert path is exercised with a mocked converter
+ # in the relevant test rather than relying on libheif being present.
+ return b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00mif1heic" + b"x" * 64
+
+
+def _settings(**overrides: object) -> IDPSettings:
+ return IDPSettings(**overrides) # type: ignore[arg-type]
+
+
+def _normalizer(office: OfficeConverter | None = None, **settings_overrides: object) -> BinaryNormalizer:
+ settings = _settings(**settings_overrides)
+ return BinaryNormalizer(
+ settings=settings,
+ pdf_guard=PdfGuard(),
+ image=ImageNormalizer(),
+ office=office or _StubOffice(),
+ archive=ArchiveUnpacker(settings=settings),
+ email_=EmailUnpacker(),
+ )
+
+
+# -------------------------------------------------------------------- passthroughs
+
+
+@pytest.mark.asyncio
+async def test_pdf_passes_through_after_guard_check() -> None:
+ rows = await _normalizer().normalise(_build_pdf(), filename="x.pdf")
+ assert len(rows) == 1
+ assert rows[0].media_type == "application/pdf"
+ assert rows[0].filename == "x.pdf"
+ assert rows[0].derived_from == ()
+
+
+@pytest.mark.asyncio
+async def test_png_passes_through() -> None:
+ rows = await _normalizer().normalise(_png(), filename="x.png")
+ assert len(rows) == 1
+ assert rows[0].media_type == "image/png"
+ assert rows[0].derived_from == ()
+
+
+# --------------------------------------------------------------------- conversions
+
+
+@pytest.mark.asyncio
+async def test_docx_routed_through_office_converter() -> None:
+ office = _StubOffice()
+ nz = _normalizer(office=office)
+ docx = b"PK\x03\x04stub-docx" # ZIP magic + filename hint disambiguates
+ rows = await nz.normalise(docx, filename="report.docx")
+ assert len(rows) == 1
+ assert rows[0].media_type == "application/pdf"
+ assert rows[0].filename.endswith(".pdf")
+ assert rows[0].derived_from == ("report.docx",)
+ assert office.calls and office.calls[0][1] == "report.docx"
+
+
+@pytest.mark.asyncio
+async def test_office_failure_propagates_typed_exception() -> None:
+ nz = _normalizer(office=_StubOffice(fail=True))
+ with pytest.raises(OfficeConversionError):
+ await nz.normalise(b"PK\x03\x04stub", filename="r.docx")
+
+
+# ---------------------------------------------------------------------- archives
+
+
+@pytest.mark.asyncio
+async def test_zip_fans_out_to_per_member_rows() -> None:
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr("passport.pdf", _build_pdf())
+ zf.writestr("photo.png", _png())
+ rows = await _normalizer().normalise(buf.getvalue(), filename="bundle.zip")
+ by_name = {r.filename: r for r in rows}
+ assert "passport.pdf" in by_name
+ assert "photo.png" in by_name
+ assert by_name["passport.pdf"].media_type == "application/pdf"
+ assert by_name["photo.png"].media_type == "image/png"
+ assert by_name["passport.pdf"].derived_from == ("bundle.zip",)
+
+
+@pytest.mark.asyncio
+async def test_gzip_unwraps_to_inner_format() -> None:
+ inner_pdf = _build_pdf()
+ gz_bytes = gzip.compress(inner_pdf)
+ rows = await _normalizer().normalise(gz_bytes, filename="report.pdf.gz")
+ assert len(rows) == 1
+ assert rows[0].media_type == "application/pdf"
+ assert rows[0].derived_from == ("report.pdf.gz",)
+
+
+@pytest.mark.asyncio
+async def test_recursion_depth_limit_enforced() -> None:
+ inner = _build_pdf()
+ # Three levels of gzip wrap a single PDF.
+ g1 = gzip.compress(inner)
+ g2 = gzip.compress(g1)
+ g3 = gzip.compress(g2)
+ g4 = gzip.compress(g3)
+ nz = _normalizer(binary_max_recursion_depth=2)
+ with pytest.raises(ArchiveExtractionError):
+ await nz.normalise(g4, filename="x.gz")
+
+
+@pytest.mark.asyncio
+async def test_total_fan_out_limit_enforced() -> None:
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ for i in range(5):
+ zf.writestr(f"f{i}.pdf", _build_pdf())
+ nz = _normalizer(binary_max_expanded_files=2)
+ with pytest.raises(ArchiveExtractionError):
+ await nz.normalise(buf.getvalue(), filename="b.zip")
+
+
+# ----------------------------------------------------------------------- email
+
+
+@pytest.mark.asyncio
+async def test_eml_attachment_is_recursed_through_normalizer() -> None:
+ msg = EmailMessage()
+ msg["From"] = "a@b.com"
+ msg["Subject"] = "x"
+ msg.set_content("ignore me")
+ msg.add_attachment(
+ _build_pdf(),
+ maintype="application",
+ subtype="pdf",
+ filename="visa.pdf",
+ )
+ rows = await _normalizer().normalise(msg.as_bytes(), filename="msg.eml")
+ by_name = {r.filename: r for r in rows}
+ assert "visa.pdf" in by_name
+ assert by_name["visa.pdf"].media_type == "application/pdf"
+
+
+# --------------------------------------------------------------------- unsupported
+
+
+@pytest.mark.asyncio
+async def test_truly_unknown_binary_raises_unsupported() -> None:
+ with pytest.raises(UnsupportedBinaryError):
+ await _normalizer().normalise(b"\x00\x01\x02unknown random", filename="x.bin")
+
+
+# --------------------------------------------------------------------- kill switch
+
+
+@pytest.mark.asyncio
+async def test_disabled_normalizer_passes_bytes_through() -> None:
+ nz = _normalizer(binary_normalize_enabled=False)
+ pdf = _build_pdf()
+ rows = await nz.normalise(pdf, filename="x.pdf")
+ assert len(rows) == 1
+ # Unchanged passthrough -- no PdfGuard, no LibreOffice, no nothing.
+ assert rows[0].bytes is pdf
diff --git a/tests/unit/test_binary_pdf_guard.py b/tests/unit/test_binary_pdf_guard.py
new file mode 100644
index 0000000..1cfae65
--- /dev/null
+++ b/tests/unit/test_binary_pdf_guard.py
@@ -0,0 +1,47 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""Encrypted / corrupt PDF rejection + valid passthrough."""
+
+from __future__ import annotations
+
+import io
+
+import pytest
+from reportlab.pdfgen import canvas
+
+from flydesk_idp.core.services.binary.errors import (
+ BinaryNormalizationError,
+ EncryptedPdfError,
+)
+from flydesk_idp.core.services.binary.pdf_guard import PdfGuard
+
+
+def _build_pdf() -> bytes:
+ buf = io.BytesIO()
+ c = canvas.Canvas(buf)
+ c.drawString(100, 750, "hello")
+ c.showPage()
+ c.save()
+ return buf.getvalue()
+
+
+def _build_encrypted_pdf() -> bytes:
+ buf = io.BytesIO()
+ c = canvas.Canvas(buf, encrypt="secret")
+ c.drawString(100, 750, "hello")
+ c.showPage()
+ c.save()
+ return buf.getvalue()
+
+
+def test_passes_clean_pdf() -> None:
+ PdfGuard().check(_build_pdf(), filename="x.pdf")
+
+
+def test_rejects_encrypted_pdf() -> None:
+ with pytest.raises(EncryptedPdfError):
+ PdfGuard().check(_build_encrypted_pdf(), filename="x.pdf")
+
+
+def test_rejects_corrupt_bytes() -> None:
+ with pytest.raises(BinaryNormalizationError):
+ PdfGuard().check(b"%PDF-1.7\nnot a real pdf", filename="x.pdf")
diff --git a/tests/unit/test_binary_sniffer.py b/tests/unit/test_binary_sniffer.py
new file mode 100644
index 0000000..a0f3a07
--- /dev/null
+++ b/tests/unit/test_binary_sniffer.py
@@ -0,0 +1,104 @@
+# Copyright 2026 Firefly Software Solutions Inc
+"""Magic-byte + filename sniffer for the binary normalizer."""
+
+from __future__ import annotations
+
+import io
+import zipfile
+
+import pytest
+
+from flydesk_idp.core.services.binary.sniffer import sniff_media_type
+
+
+def test_sniffs_pdf() -> None:
+ assert sniff_media_type(b"%PDF-1.7\nrest") == "application/pdf"
+
+
+def test_sniffs_png() -> None:
+ assert sniff_media_type(b"\x89PNG\r\n\x1a\nrest") == "image/png"
+
+
+def test_sniffs_jpeg() -> None:
+ assert sniff_media_type(b"\xff\xd8\xff\xe0rest") == "image/jpeg"
+
+
+def test_sniffs_gif() -> None:
+ assert sniff_media_type(b"GIF89arest") == "image/gif"
+
+
+def test_sniffs_webp() -> None:
+ assert sniff_media_type(b"RIFF1234WEBPrest") == "image/webp"
+
+
+def test_sniffs_tiff_le() -> None:
+ assert sniff_media_type(b"II*\x00rest") == "image/tiff"
+
+
+def test_sniffs_tiff_be() -> None:
+ assert sniff_media_type(b"MM\x00*rest") == "image/tiff"
+
+
+def test_sniffs_heic_via_ftyp_brand() -> None:
+ # HEIC magic: bytes 4..8 = 'ftyp', bytes 8..12 = 4-char brand.
+ blob = b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00mif1heic"
+ assert sniff_media_type(blob) == "image/heic"
+
+
+def test_sniffs_avif_via_ftyp_brand() -> None:
+ blob = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00mif1heic"
+ assert sniff_media_type(blob) == "image/avif"
+
+
+def test_sniffs_zip_with_no_filename_falls_back_to_zip() -> None:
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr("a.txt", b"hi")
+ assert sniff_media_type(buf.getvalue()) == "application/zip"
+
+
+@pytest.mark.parametrize(
+ ("ext", "expected"),
+ [
+ ("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
+ ("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
+ ("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"),
+ ("odt", "application/vnd.oasis.opendocument.text"),
+ ("epub", "application/epub+zip"),
+ ],
+)
+def test_disambiguates_zip_via_filename_extension(ext: str, expected: str) -> None:
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr("a.txt", b"hi")
+ assert sniff_media_type(buf.getvalue(), filename=f"doc.{ext}") == expected
+
+
+def test_sniffs_svg_by_content() -> None:
+ assert (
+ sniff_media_type(b'\n ')
+ == "image/svg+xml"
+ )
+
+
+def test_sniffs_html_by_content() -> None:
+ assert sniff_media_type(b"\n") == "text/html"
+
+
+def test_sniffs_eml_by_headers() -> None:
+ eml = b"Return-Path: \nFrom: a@b.com\nTo: c@d.com\nSubject: hi\n\nbody"
+ assert sniff_media_type(eml) == "message/rfc822"
+
+
+def test_falls_back_to_extension_for_unknown_magic() -> None:
+ # 7z-like garbage with .pdf extension: extension wins as last resort.
+ assert sniff_media_type(b"random bytes", filename="x.pdf") == "application/pdf"
+
+
+def test_returns_octet_stream_for_truly_unknown() -> None:
+ assert sniff_media_type(b"\x00\x01\x02\x03random") == "application/octet-stream"
+
+
+def test_empty_data_returns_default_or_octet_stream() -> None:
+ assert sniff_media_type(b"") == "application/octet-stream"
+ assert sniff_media_type(b"", default="image/png") == "image/png"
diff --git a/uv.lock b/uv.lock
index 1d98f4d..d2f5ab6 100644
--- a/uv.lock
+++ b/uv.lock
@@ -319,6 +319,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" },
]
+[[package]]
+name = "backports-zstd"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d4/05/480d439b482edf59b786bc19b474d990c61942e372f5de3dc14acac8154d/backports_zstd-1.5.0.tar.gz", hash = "sha256:a5e622a82eb183b4fbe18032755ce0a15fa9a82f2adb9b621620b91247aaedb7", size = 998556, upload-time = "2026-05-11T19:54:24.923Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c6/e6/252521e3a847eb200bc0a1d528542d651b9c8dc7953e231c39ed2890d5ff/backports_zstd-1.5.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:02a57ee8598dd863c0b11c7af00042ce6bc045bf6f4249fa4c322c62614ca1fd", size = 400134, upload-time = "2026-05-11T19:53:24.28Z" },
+ { url = "https://files.pythonhosted.org/packages/36/43/27ef105ffa2da3d52218d4a7b2e14037974283953b3ee790358af6e9b4df/backports_zstd-1.5.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:c56c11eb3173d540e1fb0216f7ab477cbd3a204eca41f5f329059ee8a5d2ad47", size = 454225, upload-time = "2026-05-11T19:53:25.874Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/c9/cdcba1244347500d00567ce2cd6bf04c92d1b0fb6405fb8e13c07715eb46/backports_zstd-1.5.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ef98f632026aa8e6ce05d786977092798efbe78677aa71219f22d31787809c90", size = 357229, upload-time = "2026-05-11T19:53:27.661Z" },
+ { url = "https://files.pythonhosted.org/packages/df/da/cea04dab3ffb940bde9a59866bde6f2594a7b3ef2948a63fb3898f73d311/backports_zstd-1.5.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:c3712300b18f9d07f788b03594b2f34dfad89d77df96938a640c5007522a6b69", size = 365907, upload-time = "2026-05-11T19:53:29.241Z" },
+ { url = "https://files.pythonhosted.org/packages/da/c4/6a71df2e65033f9b7d8017d77ea2bb572fc2ebc814ea383fdcda4187597a/backports_zstd-1.5.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:bdbc75d1f54df70b65bcfbc8aa0cac21475f79665bb045960af606dc07b56090", size = 446453, upload-time = "2026-05-11T19:53:30.888Z" },
+ { url = "https://files.pythonhosted.org/packages/66/e7/f98ad1a6a249c27884df9d28cf6ebc3c368e0e3288a741c1d51a572bb3d7/backports_zstd-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93d306300d25e59f1cbe98cda494bf295be03a20e8b2c5602ee5ddc03ded29f2", size = 436634, upload-time = "2026-05-11T19:53:32.484Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/42/d0393ecc64e2ab6ae1b5ca7edbe26e3fe5196885f15d6cc4bce7254e29cd/backports_zstd-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:305d2e4ae9a595d0fd9d5bea5a7a2163306c6c4dcc5eec35ecd5008219d4580e", size = 362867, upload-time = "2026-05-11T19:53:34.385Z" },
+ { url = "https://files.pythonhosted.org/packages/41/fe/87aa9404763bada695d06e5cb9d0575bae033cbf3a2e4e3bd648760178f7/backports_zstd-1.5.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:c8f0967bf8d806b250fb1e905a6b8190e7ae83656d5308989243f84e01fa3774", size = 506844, upload-time = "2026-05-11T19:53:36.023Z" },
+ { url = "https://files.pythonhosted.org/packages/56/94/3af7ce637d148e0b0acb1298b61afe9a934ed425bad9ff05e87afbf6766d/backports_zstd-1.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76b7314ca9a253171e3e9524960e9e6411997323cf10aecbbc330faa7a90278d", size = 476975, upload-time = "2026-05-11T19:53:37.885Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/6c/dc2aa1b48296ac6effc3bacb5a3061d40ed74bf73082dfe38eed2ba8362b/backports_zstd-1.5.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b1d0bf16bba86b1071731ced389f184e8de61c1afcafa584244f7f726632f92f", size = 582496, upload-time = "2026-05-11T19:53:39.812Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/38/dd49d3dd27eda9b165ccd63d70538fea016a3e9e42923bbbc1d89fae8a43/backports_zstd-1.5.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:96709d27d406008575ef759405169d538040156704b457d8c0ac035127a46b67", size = 643257, upload-time = "2026-05-11T19:53:41.819Z" },
+ { url = "https://files.pythonhosted.org/packages/59/75/78e819272450aec2462f97a1bceb90bde481f9dba435bf9e76d580b4dec4/backports_zstd-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5737402c29b2bd5bc661d4cde08aed531ed326f2b59a7ad98dc07650dc99a2c9", size = 491958, upload-time = "2026-05-11T19:53:43.501Z" },
+ { url = "https://files.pythonhosted.org/packages/62/ae/d860f9cf21cb59d583a12166353bf71a439538e2b669f4a7736e400ca596/backports_zstd-1.5.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b65f37ddd375114dbf84658e7dd168e10f5a93394940bfefa7fafc2d3234450", size = 567198, upload-time = "2026-05-11T19:53:45.226Z" },
+ { url = "https://files.pythonhosted.org/packages/38/7c/b175d4c9ff60f964c8f6dd43211de905227cfde5a41eb5f654df58483025/backports_zstd-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fae7825dde4f81c28b4c66b1e997f893e296c3f1668351952b3ed085eb9f8cd", size = 482792, upload-time = "2026-05-11T19:53:47.323Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/e3/f7b50cf891a10da5f9c412ed4a9c4a772df4d4186d98a41e75c9b462f148/backports_zstd-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3aa10e77c0e712d2dfb950910b50591c2fb11f0f1328814e23acc0b4950766df", size = 510363, upload-time = "2026-05-11T19:53:49.523Z" },
+ { url = "https://files.pythonhosted.org/packages/be/50/e7841fd4a65661d527697a0e2dab97295868965ccd4e3e12474472719a60/backports_zstd-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:518b2ef54ce0fee6d29379cfd64ef66e639456f1b18943466e929b19677f135f", size = 586917, upload-time = "2026-05-11T19:53:51.741Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/7c/57e985dbd621f0307b8c57cabb258eb976793f2aeaf8a5bc020e15b4a793/backports_zstd-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:673a1e5fdaa6cb0c7a967eb33066b6dd564871b3498a93e11e2972998047d11f", size = 565004, upload-time = "2026-05-11T19:53:53.774Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/8f/855ffcd1ee0fcf44c3fe62e36db8e7362292d450cc7c4b3f43edccbcd37a/backports_zstd-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1277c07ff2d731586aa05aebd946a1b30184620d886a735dd5d5bf94a4a1061e", size = 633737, upload-time = "2026-05-11T19:53:56.036Z" },
+ { url = "https://files.pythonhosted.org/packages/20/39/c4129a03d268699200dfebe1ccab97c7c332d2794571afb372a62e4ed098/backports_zstd-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aff334c7c38b4aea2a899f3138a99c1d58f0686ad7815c74bff506ecf4333296", size = 496309, upload-time = "2026-05-11T19:53:57.591Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/33/34152316dd244dcd43d5300ded3cf6e1b46d343e4e92620c23e533fa91df/backports_zstd-1.5.0-cp313-cp313-win32.whl", hash = "sha256:b932834c4d85360f46d1e7fbf3eee1e26ba594e0eb5c3ee1281e89bc1d48d06f", size = 289560, upload-time = "2026-05-11T19:53:59.274Z" },
+ { url = "https://files.pythonhosted.org/packages/71/c5/f759bc87fd77c88f4fdad2d878535fb7e9537c6a05876d206e6690bf33c6/backports_zstd-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:c71dfbeced720326a8917a6edf921c568dc2396228c6432205c6d7e7fe7f3707", size = 314812, upload-time = "2026-05-11T19:54:00.909Z" },
+ { url = "https://files.pythonhosted.org/packages/47/96/d7970dbb2fef34b549b34146090f48f41903cc7268b1ed1c7542eaa1852e/backports_zstd-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:7b5798b20ffff71ee4620a01f56fe0b50271724b4251db08c90a069446cc4752", size = 291411, upload-time = "2026-05-11T19:54:02.541Z" },
+]
+
[[package]]
name = "bcrypt"
version = "5.0.0"
@@ -394,6 +424,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
]
+[[package]]
+name = "beautifulsoup4"
+version = "4.13.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/85/2e/3e5079847e653b1f6dc647aa24549d68c6addb4c595cc0d902d1b19308ad/beautifulsoup4-4.13.5.tar.gz", hash = "sha256:5e70131382930e7c3de33450a2f54a63d5e4b19386eab43a5b34d594268f3695", size = 622954, upload-time = "2025-08-24T14:06:13.168Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/eb/f4151e0c7377a6e08a38108609ba5cede57986802757848688aeedd1b9e8/beautifulsoup4-4.13.5-py3-none-any.whl", hash = "sha256:642085eaa22233aceadff9c69651bc51e8bf3f874fb6d7104ece2beb24b47c4a", size = 105113, upload-time = "2025-08-24T14:06:14.884Z" },
+]
+
[[package]]
name = "boto3"
version = "1.43.7"
@@ -422,6 +465,55 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/f3/a9beaae1cda959fa438b3937fc995d82b2a08cb117c67c31547580f19849/botocore-1.43.7-py3-none-any.whl", hash = "sha256:e93f25dc186a9de033c87128c0f2016aedd74aea9057d918bfc0703a946b1ad1", size = 15031637, upload-time = "2026-05-13T19:35:27.288Z" },
]
+[[package]]
+name = "brotli"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" },
+ { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" },
+ { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" },
+ { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" },
+ { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" },
+ { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
+ { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
+ { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
+]
+
+[[package]]
+name = "brotlicffi"
+version = "1.2.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" },
+ { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" },
+ { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" },
+]
+
[[package]]
name = "cachetools"
version = "7.1.1"
@@ -448,6 +540,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
]
+[[package]]
+name = "cairocffi"
+version = "1.7.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/70/c5/1a4dc131459e68a173cbdab5fad6b524f53f9c1ef7861b7698e998b837cc/cairocffi-1.7.1.tar.gz", hash = "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b", size = 88096, upload-time = "2024-06-18T10:56:06.741Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl", hash = "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f", size = 75611, upload-time = "2024-06-18T10:55:59.489Z" },
+]
+
+[[package]]
+name = "cairosvg"
+version = "2.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cairocffi" },
+ { name = "cssselect2" },
+ { name = "defusedxml" },
+ { name = "pillow" },
+ { name = "tinycss2" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/38/07/e8412a13019b3f737972dea23a2c61ca42becafc16c9338f4ca7a0caa993/cairosvg-2.9.0.tar.gz", hash = "sha256:1debb00cd2da11350d8b6f5ceb739f1b539196d71d5cf5eb7363dbd1bfbc8dc5", size = 40877, upload-time = "2026-03-13T15:42:00.564Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bf/e0/5011747466414c12cac8a8df77aa235068669a6a5a5df301a96209db6054/cairosvg-2.9.0-py3-none-any.whl", hash = "sha256:4b82d07d145377dffdfc19d9791bd5fb65539bb4da0adecf0bdbd9cd4ffd7c68", size = 45962, upload-time = "2026-03-14T13:56:33.512Z" },
+]
+
[[package]]
name = "certifi"
version = "2026.4.22"
@@ -599,6 +719,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
+[[package]]
+name = "colorclass"
+version = "2.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/1a/31ff00a33569a3b59d65bbdc445c73e12f92ad28195b7ace299f68b9af70/colorclass-2.2.2.tar.gz", hash = "sha256:6d4fe287766166a98ca7bc6f6312daf04a0481b1eda43e7173484051c0ab4366", size = 16709, upload-time = "2021-12-09T00:41:35.661Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/30/b6/daf3e2976932da4ed3579cff7a30a53d22ea9323ee4f0d8e43be60454897/colorclass-2.2.2-py2.py3-none-any.whl", hash = "sha256:6f10c273a0ef7a1150b1120b6095cbdd68e5cf36dfd5d0fc957a2500bbf99a55", size = 18995, upload-time = "2021-12-09T00:41:34.653Z" },
+]
+
+[[package]]
+name = "compressed-rtf"
+version = "1.0.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b7/0c/929a4e8ef9d7143f54d77dadb5f370cc7b98534b1bd6e1124d0abe8efb24/compressed_rtf-1.0.7.tar.gz", hash = "sha256:7c30859334839f3cdc7d10796af5b434bb326b9df7cb5a65e95a8eacb2951b0e", size = 8152, upload-time = "2025-03-24T22:39:32.062Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/1d/62f5bf92e12335eb63517f42671ed78512d48bbc69e02a942dd7b90f03f0/compressed_rtf-1.0.7-py3-none-any.whl", hash = "sha256:b7904921d78c67a0a4b7fff9fb361a00ae2b447b6edca010ce321cd98fa0fcc0", size = 7968, upload-time = "2025-03-24T23:03:57.433Z" },
+]
+
[[package]]
name = "coverage"
version = "7.14.0"
@@ -733,6 +871,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
]
+[[package]]
+name = "cssselect2"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "tinycss2" },
+ { name = "webencodings" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595, upload-time = "2026-02-12T17:16:39.614Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" },
+]
+
[[package]]
name = "cyclopts"
version = "4.12.0"
@@ -748,6 +899,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/d0/17b6b7d5f64dea337a7a409a1e4e0eeceda724046b9acd158fd1aa2f2328/cyclopts-4.12.0-py3-none-any.whl", hash = "sha256:ee03d2b9ef790d866cb3823a7e54b2be5252c82d34536579846fce068b30c38f", size = 213706, upload-time = "2026-05-13T13:26:29.744Z" },
]
+[[package]]
+name = "defusedxml"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
+]
+
[[package]]
name = "distro"
version = "1.9.0"
@@ -798,6 +958,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" },
]
+[[package]]
+name = "easygui"
+version = "0.98.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/ad/e35f7a30272d322be09dc98592d2f55d27cc933a7fde8baccbbeb2bd9409/easygui-0.98.3.tar.gz", hash = "sha256:d653ff79ee1f42f63b5a090f2f98ce02335d86ad8963b3ce2661805cafe99a04", size = 85583, upload-time = "2022-04-01T13:15:50.752Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/a7/b276ff776533b423710a285c8168b52551cb2ab0855443131fdc7fd8c16f/easygui-0.98.3-py2.py3-none-any.whl", hash = "sha256:33498710c68b5376b459cd3fc48d1d1f33822139eb3ed01defbc0528326da3ba", size = 92655, upload-time = "2022-04-01T13:15:49.568Z" },
+]
+
+[[package]]
+name = "ebcdic"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/2f/633031205333bee5f9f93761af8268746aa75f38754823aabb8570eb245b/ebcdic-1.1.1-py2.py3-none-any.whl", hash = "sha256:33b4cb729bc2d0bf46cc1847b0e5946897cb8d3f53520c5b9aa5fa98d7e735f1", size = 128537, upload-time = "2019-08-09T00:54:35.544Z" },
+]
+
[[package]]
name = "email-validator"
version = "2.3.0"
@@ -829,6 +1006,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
]
+[[package]]
+name = "extract-msg"
+version = "0.55.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beautifulsoup4" },
+ { name = "compressed-rtf" },
+ { name = "ebcdic" },
+ { name = "olefile" },
+ { name = "red-black-tree-mod" },
+ { name = "rtfde" },
+ { name = "tzlocal" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5e/65/c70afb3b119a44b3ee36b029485dc15326cf3a7c50da19a1ecbbf949c5d1/extract_msg-0.55.0.tar.gz", hash = "sha256:cf08283498c3dfcc7f894dad1579f52e3ced9fb76b865c2355cbe757af8a54e1", size = 331170, upload-time = "2025-08-12T16:07:56.537Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/53/81/87d5241036046ea17c5c8db228f4c9e04e07e53b627015d4496a99449aaf/extract_msg-0.55.0-py3-none-any.whl", hash = "sha256:baf0cdee9a8d267b70c366bc57ceb03dbfa1e7ab2dca6824169a7fe623f0917c", size = 336033, upload-time = "2025-08-12T16:07:54.886Z" },
+]
+
[[package]]
name = "fastapi"
version = "0.136.1"
@@ -1021,9 +1216,14 @@ dependencies = [
{ name = "aiosqlite" },
{ name = "alembic" },
{ name = "asyncpg" },
+ { name = "cairosvg" },
+ { name = "extract-msg" },
{ name = "fireflyframework-agentic", extra = ["redis", "rest", "security"] },
{ name = "httpx" },
+ { name = "pillow" },
+ { name = "pillow-heif" },
{ name = "psycopg", extra = ["binary"] },
+ { name = "py7zr" },
{ name = "pydantic" },
{ name = "pydantic-ai-slim", extra = ["anthropic", "bedrock", "openai"] },
{ name = "pydantic-settings" },
@@ -1043,6 +1243,7 @@ dev = [
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-timeout" },
+ { name = "python-docx" },
{ name = "reportlab" },
{ name = "respx" },
{ name = "ruff" },
@@ -1054,10 +1255,15 @@ requires-dist = [
{ name = "aiosqlite", specifier = ">=0.22" },
{ name = "alembic", specifier = ">=1.14" },
{ name = "asyncpg", specifier = ">=0.30" },
+ { name = "cairosvg", specifier = ">=2.7" },
+ { name = "extract-msg", specifier = ">=0.51" },
{ name = "fireflyframework-agentic", extras = ["rest", "security", "redis"], editable = "../../fireflyframework/fireflyframework-agentic" },
{ name = "httpx", specifier = ">=0.28.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28" },
+ { name = "pillow", specifier = ">=11.0" },
+ { name = "pillow-heif", specifier = ">=0.18" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
+ { name = "py7zr", specifier = ">=0.22" },
{ name = "pydantic", specifier = ">=2.10.0" },
{ name = "pydantic-ai-slim", extras = ["anthropic", "openai", "bedrock"], specifier = ">=1.56.0" },
{ name = "pydantic-settings", specifier = ">=2.7.0" },
@@ -1068,6 +1274,7 @@ requires-dist = [
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
{ name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.3" },
+ { name = "python-docx", marker = "extra == 'dev'", specifier = ">=1.1" },
{ name = "redis", specifier = ">=5.2" },
{ name = "reportlab", marker = "extra == 'dev'", specifier = ">=4.2" },
{ name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" },
@@ -1507,6 +1714,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
]
+[[package]]
+name = "inflate64"
+version = "1.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3e/f3/41bb2901543abe7aad0b0b0284ae5854bb75f848cf406bf8a046bf525f67/inflate64-1.0.4.tar.gz", hash = "sha256:b398c686960c029777afc0ed281a86f66adb956cfc3fbf6667cc6453f7b407ce", size = 902542, upload-time = "2025-11-28T10:55:52.641Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/55/b7de7ae318a4f233f892c4f7c8b7e0e8643abe3fdcc53ed35020a9fe3f47/inflate64-1.0.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3f37540d0e64884a935fd62a7d17e40ab69f05ec63e815483b6513675d01bef", size = 58633, upload-time = "2025-11-28T10:55:01.931Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/5f/6f89c8524503fd7a9ca2bd91fe60d7291b3f684e9d41edb38ef49e10fc3d/inflate64-1.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4d24112180c95d12f279cade9a1e21f8be7f4790c4109c293292edf87d061992", size = 35864, upload-time = "2025-11-28T10:55:03.075Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/14/9eadd59244b38cf85ccd0ca43d1296c50b3a33aa37be4fb68a1928efa58c/inflate64-1.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5c098dab17821f466fc6e6a3d78fc6e0295bb51458015f03416b1d58d6a8df4f", size = 36019, upload-time = "2025-11-28T10:55:04.126Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/04/399e82d8f5003dd92c8a0c5c1a9a8ce0919114710a496cbe88848bff3a72/inflate64-1.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a984b9287ff0fb596eb058d66a9e94530556afd2b7c054b44f2e0aeeff894e8f", size = 98973, upload-time = "2025-11-28T10:55:05.229Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/c5/038dc2593bbc4272d87eac8c9f75692267d47f834ced888f6d81995df606/inflate64-1.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f62a13d0327631778fa2a47c308ae2b07b2659b7bb8564783259ac65949f8c0c", size = 100777, upload-time = "2025-11-28T10:55:06.41Z" },
+ { url = "https://files.pythonhosted.org/packages/74/4a/f6d3031dd3578510894a41bfe1ac149228970ce1629a43f20e0c5abbe8d1/inflate64-1.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:513201336fb3b0b7e2aee5dbbbe30a9f1b23291738b5ceb80076fc285f2ec2f1", size = 96967, upload-time = "2025-11-28T10:55:07.594Z" },
+ { url = "https://files.pythonhosted.org/packages/26/ef/7ab3bde4e176609ae0e607a8a9bb38d201885275664a6d574299f5bf7850/inflate64-1.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84ce3a97272ba745fce52b38363855c7201968f6402a794bbade774e64c657b9", size = 99997, upload-time = "2025-11-28T10:55:08.85Z" },
+ { url = "https://files.pythonhosted.org/packages/39/59/8256b3e802e203c8645e0b32b25e6bd94508ad572593f0cdf8234db3879a/inflate64-1.0.4-cp313-cp313-win32.whl", hash = "sha256:332051a9d7e50579b90a3f555d68f53414b06f636c9ffe82e97c0baae3c8fbcc", size = 32958, upload-time = "2025-11-28T10:55:10.343Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/4f/5784ee1eb8260f2310e24ef2883f1f494f9332bcfde4ed14ee780372149e/inflate64-1.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:3983f53b590ff7d0ba243f664ce852aca882482f30f7a8eab33e10d769336d0c", size = 35578, upload-time = "2025-11-28T10:55:11.389Z" },
+ { url = "https://files.pythonhosted.org/packages/39/e8/8d927770ce25dc9764c8104207a80653d65471d0a6a8f9ead350016e4586/inflate64-1.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:118d8286f085e99a14341c76ef9fbffd56619ccc80318a9a204aea3dbfa71470", size = 33465, upload-time = "2025-11-28T10:55:12.824Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/fb/ec9d10f44f2fc7666ad5d70acae2b8a1941e8e08ccae1fad0820f7796be3/inflate64-1.0.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4f61925b2d4248eac2ebb15350a80aaa0d1f7f1dc770bd5ebbbb3b0db4a6a416", size = 58727, upload-time = "2025-11-28T10:55:13.834Z" },
+ { url = "https://files.pythonhosted.org/packages/81/80/24ba0d2ee14e07e275e9c5b058e59a8a58f8ef42dd51a78ebbfd7c857ac4/inflate64-1.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c1acf18b08b32981a4a11ec5a112b8ad5d7c7a5b15cb5bdbdb5b19201e9aa180", size = 35945, upload-time = "2025-11-28T10:55:15.225Z" },
+ { url = "https://files.pythonhosted.org/packages/70/b8/073a79716e093db973b8823bdfb02e10fbdf65642dbe1fa3cda24832aeb2/inflate64-1.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:abddae8920b2eaef824254e14b8d4ff54afbe6194a1bbe9816584859f0c1244d", size = 36060, upload-time = "2025-11-28T10:55:16.261Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/f0/87d3c317ed0acd94f5e9b3b1b9e9000228ea2af0cb4618c62cbfc816da34/inflate64-1.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b303132cc562a906543a56f35c4e164e3880da6ff041cb4a7b1df9f9d2b4bb69", size = 98995, upload-time = "2025-11-28T10:55:17.405Z" },
+ { url = "https://files.pythonhosted.org/packages/90/72/0b6035302e9c33f004240a50cb6e2e1fc7bb1f2b415b02d939c551bdd06b/inflate64-1.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f0993214dea0738c557fa56c13cd9083aef0097a201d726c21984ad7f577514", size = 100781, upload-time = "2025-11-28T10:55:18.597Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/05/5f383c615ec0f01bcbbc699a71da167623e494083ab7ed0df86b4bddf125/inflate64-1.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a6baedc3288d7a4ff588951d3a9a97a5391dceed6255ff5b16e42cae7274bfa9", size = 97038, upload-time = "2025-11-28T10:55:20.156Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/9a/c1482718c717c49c67490c42b4fdced9476e894eaebd52193e488c12e188/inflate64-1.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a846ce1f38845b20bef2625af1b512be83416d97824539524c5a34e7a729aec7", size = 100016, upload-time = "2025-11-28T10:55:21.337Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/7f/700ede7474e72a1c0e2e8fe36624cd0225ca8b2875eca33d64aa2de75f4d/inflate64-1.0.4-cp314-cp314-win32.whl", hash = "sha256:eef87908c780439393d577a155868317f0a275b47b417db9f47d8633ec791745", size = 33691, upload-time = "2025-11-28T10:55:22.515Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/51/f2972df8cceecc9bf3afa3353d517ffc7125285198c844588e9aaf98f5d0/inflate64-1.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:fb2fdd63ef3933b67af98b3f2ee2f57e7787278041d7ba4821382fedd729b68a", size = 36304, upload-time = "2025-11-28T10:55:24.005Z" },
+ { url = "https://files.pythonhosted.org/packages/29/77/16200aced67215119fb30ec9a5889b48289ee2fa5ce5137623a9ad41b2c4/inflate64-1.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:2e129669a0243ac7816fd526946ee01c25688fe81623a6d6bc95b3156d80f4fb", size = 34491, upload-time = "2025-11-28T10:55:25.068Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/79/b466ec7666c40912ea81a305a8a2b75f5998e6cec1d3d75e067d76203731/inflate64-1.0.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:b17bf665d948dc4edeea0cd17752415d0cd7240c882b9c7e136ad4cc4321e9d4", size = 59300, upload-time = "2025-11-28T10:55:26.185Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ad/16e1168ac80f39894e6cb18b439eec63fec42cbced239aebfe12081b6ec7/inflate64-1.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6751758301936fbb38fa38eb5312e14e27b6a1abf568f83c17557fab2694373d", size = 36258, upload-time = "2025-11-28T10:55:27.295Z" },
+ { url = "https://files.pythonhosted.org/packages/25/42/c463b42fd8a7947b4445fbaf57c265bb7f3114362fb7aee6884ffd8b5341/inflate64-1.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d6a4136752aa2a544301059d8f13780aeb88c34d60770258436a87dacd3fc304", size = 36296, upload-time = "2025-11-28T10:55:28.632Z" },
+ { url = "https://files.pythonhosted.org/packages/39/f1/cf6121926e405020e9e7bccb78ec7781fbc87500ec67368e7d9e866758be/inflate64-1.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:938ebc6b28578bfd365d1a9fdb18b7faab08321babeb2198e8025d07d8dc7fb5", size = 106788, upload-time = "2025-11-28T10:55:29.797Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/dd/b653f9962497cf4d3520d69272894c37fd76f86a0e04bb3bd9f32827dc2f/inflate64-1.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61f51f80fa6f367288343c1a2cd20a42af454883087064e9274fd2a8c3a5a200", size = 107959, upload-time = "2025-11-28T10:55:31.306Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ae/65d88109b63611c9b6c29008201107127cf2603d186ab99beec39d85f38e/inflate64-1.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:172b51da7bbfa66b33f0a5405e944807b9949e92cf4cd9f983c07af8152766df", size = 104213, upload-time = "2025-11-28T10:55:32.506Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/94/c17de2f55b9fb1269bca4657f9089efe4ba0f3d4b652f07b34dfc69f69a2/inflate64-1.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8ca9a2985afd5a14fb48cd126a67e5944ccb7a0a6bdec58c4f796c8c88a84539", size = 106629, upload-time = "2025-11-28T10:55:33.735Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/30/8bbc587bb2ad09ab7edf1f9215b2c5faf4fa7ee7c071daefe9ed55e28814/inflate64-1.0.4-cp314-cp314t-win32.whl", hash = "sha256:f8964ceaabea294bc20abc9ef408c6aae978a75c25c83168a76cd87a37c38938", size = 33865, upload-time = "2025-11-28T10:55:35.335Z" },
+ { url = "https://files.pythonhosted.org/packages/91/87/8bf6f412f93c8b6dc14866a021b83321331fbdd17f6ab902a24dbf88773d/inflate64-1.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:7d13b04cba65c12d21e65eaa77da9484e265e8e821b26e0761d1455ad3a878d9", size = 36943, upload-time = "2025-11-28T10:55:36.983Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/85/33447bb3c4e3c0ae7b1fde3aadc52a18b2b0193cfcf4f585977e924c6463/inflate64-1.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:9ae3ee727235a06dc3cd353ee5761fdd8e3b56ad119c711f61680528972a6ced", size = 34846, upload-time = "2025-11-28T10:55:38.433Z" },
+]
+
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -1712,6 +1957,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
]
+[[package]]
+name = "lark"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" },
+]
+
[[package]]
name = "logfire"
version = "4.33.0"
@@ -1744,6 +1998,68 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/58/dc21672e0d5294668f4d35aaba4d839d031f096b4cf6802399c4b411b5fc/logfire_api-4.33.0-py3-none-any.whl", hash = "sha256:b992727bab71412b5a00f54453eec18e559232c83d7120cf5c6a9edd33fb0c4e", size = 128896, upload-time = "2026-05-13T15:14:14.322Z" },
]
+[[package]]
+name = "lxml"
+version = "6.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" },
+ { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" },
+ { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" },
+ { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" },
+ { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" },
+ { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713, upload-time = "2026-04-18T04:32:06.831Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874, upload-time = "2026-04-18T04:32:10.755Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535, upload-time = "2026-04-18T04:34:06.657Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881, upload-time = "2026-04-18T04:34:09.556Z" },
+ { url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305, upload-time = "2026-04-18T04:34:12.336Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522, upload-time = "2026-04-18T04:34:14.89Z" },
+ { url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310, upload-time = "2026-04-18T04:34:17.652Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799, upload-time = "2026-04-18T04:34:20.529Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693, upload-time = "2026-04-18T04:34:23.541Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708, upload-time = "2026-04-18T04:34:26.001Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737, upload-time = "2026-04-18T04:34:28.32Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817, upload-time = "2026-04-18T04:34:30.784Z" },
+ { url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753, upload-time = "2026-04-18T04:34:33.675Z" },
+ { url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071, upload-time = "2026-04-18T04:34:36.12Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319, upload-time = "2026-04-18T04:34:39.035Z" },
+ { url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139, upload-time = "2026-04-18T04:32:20.006Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195, upload-time = "2026-04-18T04:32:23.876Z" },
+ { url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870, upload-time = "2026-04-18T04:32:27.922Z" },
+ { url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548, upload-time = "2026-04-18T04:32:15.094Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866, upload-time = "2026-04-18T04:32:18.924Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476, upload-time = "2026-04-18T04:34:41.889Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719, upload-time = "2026-04-18T04:34:44.797Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890, upload-time = "2026-04-18T04:34:47.634Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008, upload-time = "2026-04-18T04:34:51.503Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451, upload-time = "2026-04-18T04:34:54.263Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135, upload-time = "2026-04-18T04:34:56.818Z" },
+ { url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126, upload-time = "2026-04-18T04:34:59.704Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579, upload-time = "2026-04-18T04:35:02.658Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206, upload-time = "2026-04-18T04:35:05.175Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906, upload-time = "2026-04-18T04:35:08.098Z" },
+ { url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553, upload-time = "2026-04-18T04:35:11.23Z" },
+ { url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458, upload-time = "2026-04-18T04:35:14.254Z" },
+ { url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861, upload-time = "2026-04-18T04:35:17.006Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377, upload-time = "2026-04-18T04:32:07.656Z" },
+ { url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701, upload-time = "2026-04-18T04:32:12.113Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" },
+]
+
[[package]]
name = "mako"
version = "1.3.12"
@@ -1863,6 +2179,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" },
]
+[[package]]
+name = "msoffcrypto-tool"
+version = "6.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "olefile" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a6/34/6250bdddaeaae24098e45449ea362fb3555a65fba30cad0ad5630ea48d1a/msoffcrypto_tool-6.0.0.tar.gz", hash = "sha256:9a5ebc4c0096b42e5d7ebc2350afdc92dc511061e935ca188468094fdd032bbe", size = 40593, upload-time = "2026-01-12T08:59:56.73Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3c/85/9e359fa9279e1d6861faaf9b6f037a3226374deb20a054c3937be6992013/msoffcrypto_tool-6.0.0-py3-none-any.whl", hash = "sha256:46c394ed5d9641e802fc79bf3fb0666a53748b23fa8c4aa634ae9d30d46fe397", size = 48791, upload-time = "2026-01-12T08:59:55.394Z" },
+]
+
[[package]]
name = "multidict"
version = "6.7.1"
@@ -1944,6 +2273,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
+[[package]]
+name = "multivolumefile"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/50/f0/a7786212b5a4cb9ba05ae84a2bbd11d1d0279523aea0424b6d981d652a14/multivolumefile-0.2.3.tar.gz", hash = "sha256:a0648d0aafbc96e59198d5c17e9acad7eb531abea51035d08ce8060dcad709d6", size = 77984, upload-time = "2021-04-29T12:18:39.882Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/31/ec5f46fd4c83185b806aa9c736e228cb780f13990a9cf4da0beb70025fcc/multivolumefile-0.2.3-py3-none-any.whl", hash = "sha256:237f4353b60af1703087cf7725755a1f6fcaeeea48421e1896940cd1c920d678", size = 17037, upload-time = "2021-04-29T12:18:38.886Z" },
+]
+
[[package]]
name = "nexus-rpc"
version = "1.4.0"
@@ -1965,6 +2303,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
+[[package]]
+name = "olefile"
+version = "0.47"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c", size = 112240, upload-time = "2023-12-01T16:22:53.025Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" },
+]
+
+[[package]]
+name = "oletools"
+version = "0.60.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorclass" },
+ { name = "easygui" },
+ { name = "msoffcrypto-tool", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'win32')" },
+ { name = "olefile" },
+ { name = "pcodedmp" },
+ { name = "pyparsing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5c/2f/037f40e44706d542b94a2312ccc33ee2701ebfc9a83b46b55263d49ce55a/oletools-0.60.2.zip", hash = "sha256:ad452099f4695ffd8855113f453348200d195ee9fa341a09e197d66ee7e0b2c3", size = 3433750, upload-time = "2024-07-02T14:50:38.242Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ac/ff/05257b7183279b80ecec6333744de23f48f0faeeba46c93e6d13ce835515/oletools-0.60.2-py2.py3-none-any.whl", hash = "sha256:72ad8bd748fd0c4e7b5b4733af770d11543ebb2bf2697455f99f975fcd50cc96", size = 989449, upload-time = "2024-07-02T14:50:29.122Z" },
+]
+
[[package]]
name = "openai"
version = "2.36.0"
@@ -2177,6 +2541,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" },
]
+[[package]]
+name = "pcodedmp"
+version = "1.2.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "oletools" },
+ { name = "win-unicode-console", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/20/6d461e29135f474408d0d7f95b2456a9ba245560768ee51b788af10f7429/pcodedmp-1.2.6.tar.gz", hash = "sha256:025f8c809a126f45a082ffa820893e6a8d990d9d7ddb68694b5a9f0a6dbcd955", size = 35549, upload-time = "2019-07-30T18:05:42.516Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/72/b380fb5c89d89c3afafac8cf02a71a45f4f4a4f35531ca949a34683962d1/pcodedmp-1.2.6-py2.py3-none-any.whl", hash = "sha256:4441f7c0ab4cbda27bd4668db3b14f36261d86e5059ce06c0828602cbe1c4278", size = 30939, upload-time = "2019-07-30T18:05:40.483Z" },
+]
+
[[package]]
name = "pillow"
version = "12.2.0"
@@ -2235,6 +2612,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
]
+[[package]]
+name = "pillow-heif"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pillow" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/58/2df4fc42840633e01c97b75965cb1bc6e14425973b92382391650e97e4b7/pillow_heif-1.3.0.tar.gz", hash = "sha256:af8d2bda85e395677d5bb50d7bda3b5655c946cc95b913b5e7222fabacbb467f", size = 17133211, upload-time = "2026-02-27T12:21:36.465Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/c3/9effa6ab5c2c2ffb80228143c578a9a2a8e2f059dd9d067ec6ff6f6c89db/pillow_heif-1.3.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:641c50a064aa9ad6626a6b2b914b65855202f937d573d53838e344feb2e8c6d1", size = 4667379, upload-time = "2026-02-27T12:20:57.561Z" },
+ { url = "https://files.pythonhosted.org/packages/23/eb/b6b52e3655f366b95301f18aecd2d35487cace18d17134b80ad0f70cc1eb/pillow_heif-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9390dd7987887aa09779fbd88bbab715c732c9ad3a71d6707284035e3ca93379", size = 3392725, upload-time = "2026-02-27T12:20:59.52Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/b3/b69610e9565fc8bcaf2303f412e857c0439d23cc18cf866c72a96ec6b2e6/pillow_heif-1.3.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e8444ccb330015e1db930207d269886e4b6c666121cd9e5fdad88735950b09f", size = 5844285, upload-time = "2026-02-27T12:21:00.771Z" },
+ { url = "https://files.pythonhosted.org/packages/47/8c/be44f6dea425a9756ff418cb03f5ee75ed1c7dd1ff9bee1f3893b2b82da4/pillow_heif-1.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d30054ccc97ecbe5ee3fa486a505ccc33bfbb27f005ad624ddb4c17b80ddd57", size = 5578691, upload-time = "2026-02-27T12:21:02.193Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/74/e12d49346a39e2204b408a835b31b2fd9a5d51f97ce3a6015cf22ca09a54/pillow_heif-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc1b9c9efdf8345d703118449ff69696d0827bdf28e3b52f82015f5714f7c23e", size = 6885923, upload-time = "2026-02-27T12:21:03.782Z" },
+ { url = "https://files.pythonhosted.org/packages/80/a6/51c937a9433f5ae9c625b686ee338bdf0080a1661f7eb34daaf75424ee77/pillow_heif-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee26b2155721e7f5f7b10fa93ca2ad3be59547c5c5e5d9d50e6ea17531b81d60", size = 6511216, upload-time = "2026-02-27T12:21:05.134Z" },
+ { url = "https://files.pythonhosted.org/packages/63/0a/bb8435e127f75b434166022471bbabf11c8c1fc3d48c8595fd6ab36c2785/pillow_heif-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:17ecbbadfe10ea12a65c1c12354dc1ed8ae1e5d1b7092ea753641b029f7d6f9e", size = 5483570, upload-time = "2026-02-27T12:21:06.566Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/17/aa056f8edb71396dd1131abcd0c6feab00097ceec89a12fc62d2dbc3ccf5/pillow_heif-1.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8267a73d3b2d07a47a96428bd8cd4c406e1637a94f29d4c16ce08b31b8e50a07", size = 4667395, upload-time = "2026-02-27T12:21:08.16Z" },
+ { url = "https://files.pythonhosted.org/packages/19/1f/da50ccd271a2878d17df359301dc2f7a79ec1cbb6e92c19ccc8c6219d497/pillow_heif-1.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:36bbea7679467caa3a154db11c04f1ca2fa8591e886f06f40f7831c14b58d771", size = 3392800, upload-time = "2026-02-27T12:21:09.668Z" },
+ { url = "https://files.pythonhosted.org/packages/11/bc/1f89d927c1293cf283bc5d0ae6735d268d2de9749aa6fb94342ec838a457/pillow_heif-1.3.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea3a4b2de4b6c63407af72afdac901616807c6e6a030fe77851d227bca3727a", size = 5844547, upload-time = "2026-02-27T12:21:10.826Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/04/d781b23f8bff125c8dd8da63d928a35e38f2b727e89582a1fd323664e968/pillow_heif-1.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05149bd26b08dae5af7a389af6db13cef4f12c7871db73d84e40a1f3c83b0142", size = 5578827, upload-time = "2026-02-27T12:21:12.06Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/98/8dcdaafcf9bd8b26ed0569dc93653dc20a06faef7bfbdd4ba05c091c5b60/pillow_heif-1.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f8b7a50058fc3152f42b68aa2b30601249f61aa5c6c27876af076785c7051fd9", size = 6886088, upload-time = "2026-02-27T12:21:13.635Z" },
+ { url = "https://files.pythonhosted.org/packages/99/26/93f3c8bfffb7e8fe0244bf86117235c49c23980e61320e7484c03ac836e2/pillow_heif-1.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:edb3ef437e8841475db14721f0529e600bb55c41b549ad1794a0831e28f33bac", size = 6511291, upload-time = "2026-02-27T12:21:15.354Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/f9/a8c72619ec212eb2612730fa2b3068e2d4b59e0a0957c2e8418aa4cff59e/pillow_heif-1.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:bdd6695d5be0d98ae0e9a5f88fe26f1a6eca0a5b6d43d0a92a97f89fea5842f7", size = 5640949, upload-time = "2026-02-27T12:21:16.647Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/31/92ce30e1ada892e18a03042bd5a8414f655304a78a36790e657f14265fed/pillow_heif-1.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:65c5d05cb7f5e1eadbe9c605ae3a4dd3ef953adb33e7d809d5fb56f8a6753588", size = 4668365, upload-time = "2026-02-27T12:21:18.004Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/2b/789fa3c82063a780e84de667771b8ec30bc328511855f15a83a3c77011ec/pillow_heif-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dc177fbdf598770cad4afa99c082a30b9d090e60c39656904338717803ae59b2", size = 3393554, upload-time = "2026-02-27T12:21:19.642Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/a4/4f8075f03c1d06d7afd674e263a3f57b7b24130c39b1544555b3b03ed369/pillow_heif-1.3.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71f88d180547bb5112b56310c8c5e338d8358320a402c80afabc6b2f39eadddb", size = 5849609, upload-time = "2026-02-27T12:21:20.953Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/08/e33a10bc84ade1b4ec56bdc765735bbfd452513e33537df68107edc0eb86/pillow_heif-1.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9acee893186bdde6140d30a7dc6d7c928e4ad3007989764f6e54a7a517faa332", size = 5582931, upload-time = "2026-02-27T12:21:22.571Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/45/6afc0f29701e0c9b911b33a35760ae6e2c581fc49b431dcce22ed18abfba/pillow_heif-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7cf893689132bec18f0c55a505da9ebf3a8feb33dd354fe2ac050f20f4f862e0", size = 6891268, upload-time = "2026-02-27T12:21:24.021Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/0a/0d6a69f76f277692555d0e687dbf3e31d03cf76fffa3ced1fea51a18c481/pillow_heif-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:54404c9b6f0323114527579f54cc966b47206f99d943e47d73e1091ab0b9d2ba", size = 6515405, upload-time = "2026-02-27T12:21:25.336Z" },
+ { url = "https://files.pythonhosted.org/packages/39/21/716856a36c1cc30a8f1354bf6423f251b1f50851af3e13b9cf084a13d2e3/pillow_heif-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:18c7c35a9d98ed9eaaf2db601ee43425ebccc698801df9c008aa04e00756a22e", size = 5641581, upload-time = "2026-02-27T12:21:26.642Z" },
+]
+
[[package]]
name = "platformdirs"
version = "4.9.6"
@@ -2366,6 +2775,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" },
]
+[[package]]
+name = "psutil"
+version = "7.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
+ { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
+ { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
+ { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
+ { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
+]
+
[[package]]
name = "psycopg"
version = "3.3.4"
@@ -2437,6 +2874,27 @@ memory = [
{ name = "cachetools" },
]
+[[package]]
+name = "py7zr"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "backports-zstd", marker = "python_full_version < '3.14'" },
+ { name = "brotli", marker = "platform_python_implementation == 'CPython'" },
+ { name = "brotlicffi", marker = "platform_python_implementation == 'PyPy'" },
+ { name = "inflate64" },
+ { name = "multivolumefile" },
+ { name = "psutil", marker = "sys_platform != 'cygwin'" },
+ { name = "pybcj" },
+ { name = "pycryptodomex" },
+ { name = "pyppmd" },
+ { name = "texttable" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0c/e6/01fb15361ca75ee5d01df6361825a49816a836c99980c5481da0e40c6877/py7zr-1.1.0.tar.gz", hash = "sha256:087b1a94861ad9eb4d21604f6aaa0a8986a7e00580abd79fedd6f82fecf0592c", size = 70855, upload-time = "2025-12-21T03:27:44.653Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f0/9c/762284710ead9076eeecd55fb60509c19cd1f4bea811df5f3603725b44cb/py7zr-1.1.0-py3-none-any.whl", hash = "sha256:5921bc30fb72b5453aafe3b2183664c08ef508cde2655988d5e9bd6078353ef7", size = 71257, upload-time = "2025-12-21T03:27:42.881Z" },
+]
+
[[package]]
name = "pyasn1"
version = "0.6.3"
@@ -2458,6 +2916,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
+[[package]]
+name = "pybcj"
+version = "1.0.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/12/0c/2670b672655b18454841b8e88f024b9159d637a4c07f6ce6db85accf8467/pybcj-1.0.7.tar.gz", hash = "sha256:72d64574069ffb0a800020668376b7ebd7adea159adbf4d35f8effc62f0daa67", size = 31282, upload-time = "2025-11-29T00:53:29.738Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/6f/b08d5be15209b584858981b44a447ae7a6d8c591487e502e212b5420f94e/pybcj-1.0.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d5e0feeeee3a659b30d7afbd89bf41da84e8c8fe13e5b997457e799a70fa550", size = 31869, upload-time = "2025-11-29T00:52:56.973Z" },
+ { url = "https://files.pythonhosted.org/packages/47/e5/68bffbc87581ea96bb4aea623d8cd085786f36d5b912ed8d9bade3265110/pybcj-1.0.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:60baaf9f0da31438515a401145f920f75f2ec7d511165bbf57475467af72a3e6", size = 23750, upload-time = "2025-11-29T00:52:57.986Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/ad/718d6daa0d5e15e2131301220eaefbf6bc8cd0c90791c5ac18c893be111a/pybcj-1.0.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b9c6e726618c3d43c730df5a4067fc19653b360f89c2f72f4323dae10d324552", size = 24089, upload-time = "2025-11-29T00:52:59.457Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/a6/7df6b55b64b370e2d42aa51b88402336d7f17c97eda284755f404d8d6047/pybcj-1.0.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a5fcd40a4ce8f0c5428032ec5db9f03abb42214b993886cdf558e5644de636e", size = 52454, upload-time = "2025-11-29T00:53:00.468Z" },
+ { url = "https://files.pythonhosted.org/packages/86/43/2ce282501a39f32f27cef2a3ee11c621f9d5348da4441d2326f2fcc9b17e/pybcj-1.0.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:029112255c22de66e0117bec932c8be341ed20c56dcf6a961c14689f7f0ce772", size = 51522, upload-time = "2025-11-29T00:53:01.714Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/2a/f75ea5f09c91e01456a13759a325e007663855fa16af28830ce7d44d5427/pybcj-1.0.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6492bcef5cb6883506b9dce5e48cb81217407305957b0e602c6c689c60097c5e", size = 50398, upload-time = "2025-11-29T00:53:02.839Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/14/6d49e0c62a0ab68aa3325e6f141c33f37e5bc9d61cc9a1186c0a2d324fbe/pybcj-1.0.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22a7f4a51d36a1abb67a61e93248f997eb2be278f788d681096f5044ae18b4f9", size = 50087, upload-time = "2025-11-29T00:53:03.966Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/71/a0d58633236783028c08e585b3c4f4715a4286970b4ec4a25acfe23794f1/pybcj-1.0.7-cp313-cp313-win_amd64.whl", hash = "sha256:ebcce9b419fe5d3109150a1fab0fc93a64d5cd812ca44c5ddb7d4f7128ea369f", size = 24939, upload-time = "2025-11-29T00:53:05.363Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/98/8856cd8bc07e66322a55750ab87a264829cdfbc6cd85c5844340cf06bc53/pybcj-1.0.7-cp313-cp313-win_arm64.whl", hash = "sha256:bc6acf0320976b4e31bdc0e59b16689083d5c346a6c62ac4f799685d1cc5cf27", size = 23409, upload-time = "2025-11-29T00:53:06.749Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9e/eb50f11ea7fb6342167bb8353d48966b490afa8ae47c98917e9acd045b71/pybcj-1.0.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:293f951eb3877840acab79f0c4dcfc06eab03e087cb9e4c004ec058e093acb1d", size = 32409, upload-time = "2025-11-29T00:53:08.152Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/f6/b55f4a5faf9bd162426f49de84873fbf813698d1b018234c3c99816a9662/pybcj-1.0.7-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3ae64960904f362d33ffca10715803afd9f9a6a2a592f871dcb335acf82edf29", size = 23818, upload-time = "2025-11-29T00:53:09.236Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/24/78cf0973b3deded1e072479ac35d387083a626b3dc0b29e2e099cf73e082/pybcj-1.0.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:70aa4476910f982025f878e598c136559a6d78b59fc20ba8b4b592306cde6051", size = 24115, upload-time = "2025-11-29T00:53:10.241Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/69/e044d7127018158c67b119e6ed26d1a4b34b1e1defd9b0d3de029a782b9f/pybcj-1.0.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79ce3ce9b380b1b75c5e490abc3888ee3b5b2d28c22b59618674bf410b9cee16", size = 52484, upload-time = "2025-11-29T00:53:11.71Z" },
+ { url = "https://files.pythonhosted.org/packages/98/05/55337b0a9807887967b2cf49dd6f3bf47c7745786b0bda51a5cbfda66c78/pybcj-1.0.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc73ee1bc064d6f97dfd66051d3859b32e1b6a4cf89b077f5c8ef6c2dccb71af", size = 51471, upload-time = "2025-11-29T00:53:12.86Z" },
+ { url = "https://files.pythonhosted.org/packages/59/09/6c57cbbf4931ac304f822ef78f478dd6b34b00b08e9530d308737959f064/pybcj-1.0.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5b0d13f41a9f85b3f95dd5dc7bfaa9539e80f8ae60a96db7f34c07ed732e4a82", size = 50435, upload-time = "2025-11-29T00:53:14.264Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/93/26415ca1b96456e27550dae67092023af3e8454621b1efa701079d8acb64/pybcj-1.0.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:597d7e9a8cbb30a6ed54d552fd3436edb32bbb821a7ac2fa8e5c7ebd1f7e0e93", size = 50010, upload-time = "2025-11-29T00:53:15.396Z" },
+ { url = "https://files.pythonhosted.org/packages/91/f7/d4e55aede85143adff3ded89a1ae87bbf29060ddc2249fb861d78b103d4a/pybcj-1.0.7-cp314-cp314-win_amd64.whl", hash = "sha256:4603cc41ceb1236abe9169e2ead344140be5d2c3ac01bbc5e44cb1b13078a009", size = 25283, upload-time = "2025-11-29T00:53:16.472Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/19/9939ba437140d7375aecf8063d8695dfeea56ca40f57ddaa6ca3b828c131/pybcj-1.0.7-cp314-cp314-win_arm64.whl", hash = "sha256:adf985e816ddd59f3bf6d1066b7fa89de7424a4f19f3725f9976284cabe54e28", size = 23622, upload-time = "2025-11-29T00:53:17.994Z" },
+ { url = "https://files.pythonhosted.org/packages/62/6e/5e14c70f3ddc268f28e7ac912510c8bc2b5430f18bb7f326bbb9d1878955/pybcj-1.0.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bbd835873de147481d62c11ba91a75d26a72df1142de3516b384b04e5a1db6d", size = 33016, upload-time = "2025-11-29T00:53:19.434Z" },
+ { url = "https://files.pythonhosted.org/packages/49/c1/25a1a8d5811c5d2a2ca3439c548abbba882fa72eba9a6eb41040206fdc26/pybcj-1.0.7-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b7576b25d7b01a953e2f987e77cef93c001db7b95924a5541d5a55f9195a7e89", size = 24114, upload-time = "2025-11-29T00:53:20.476Z" },
+ { url = "https://files.pythonhosted.org/packages/35/66/09567564cdc6cc2713d729bd29794367ebb67fa9ea5c10313c49608c08fa/pybcj-1.0.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57b36920498f82ca6a325a98b13e0fbff8fc29bade7aaaddc7d284640bffd87d", size = 24428, upload-time = "2025-11-29T00:53:21.522Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/49/4f6793624eb418cc2536ef39e67c8783d7ca542f6139a051e9d2d76fdc80/pybcj-1.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aac2a46faf41e373939f6d3e6a5aa2121bf09e2446972c14a8e5d1ca3b0f8130", size = 58806, upload-time = "2025-11-29T00:53:22.568Z" },
+ { url = "https://files.pythonhosted.org/packages/be/1c/8604f7fe360a9340bbf798b826a88f8e9d186fc031eb531bcd11ac9e684b/pybcj-1.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c7d6156ef2b4e8ecd450b62dc4cc3a89e8dda307cb26288b670952ef0df3a37", size = 56975, upload-time = "2025-11-29T00:53:23.691Z" },
+ { url = "https://files.pythonhosted.org/packages/83/71/d706b4220f60fa4e6a5756dc0051fb76c32a5615958fc12f0dcacef3f86b/pybcj-1.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0fe306213de1e764abae63c06ae5a4e9a83632f62612805f1f883b8d74431901", size = 56023, upload-time = "2025-11-29T00:53:25.135Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/28/ad306f5acb1226241960bb86e9021b4e32bb7da426ccdc824d9d36d61261/pybcj-1.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:00448182d535cca37e8f24d892d480fa86f80ff20c79385f6eca75f118efcbb4", size = 55194, upload-time = "2025-11-29T00:53:26.209Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/d8/70c5e5701926fc67dae02101f0298d21c0b89ff83fa705712c3b4da252e5/pybcj-1.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:7e94aa712d0fa5fda9875828441755ece7121fc3f8c5cc3bc8ee92d05b853590", size = 25826, upload-time = "2025-11-29T00:53:27.655Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/5d/7a87ba32c0c0756f36000fafe642fa4609be2c26a50a7913a057a47eabdf/pybcj-1.0.7-cp314-cp314t-win_arm64.whl", hash = "sha256:16fd4e51a5556d1f38d7ba5d1fab588bfb60ae23d2299b5179779bf9900adf71", size = 24049, upload-time = "2025-11-29T00:53:28.679Z" },
+]
+
[[package]]
name = "pycparser"
version = "3.0"
@@ -2467,6 +2960,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
+[[package]]
+name = "pycryptodomex"
+version = "3.23.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" },
+ { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" },
+ { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" },
+ { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" },
+ { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" },
+ { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" },
+ { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" },
+ { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" },
+ { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" },
+ { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" },
+]
+
[[package]]
name = "pydantic"
version = "2.13.4"
@@ -2831,6 +3354,15 @@ crypto = [
{ name = "cryptography" },
]
+[[package]]
+name = "pyparsing"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
+]
+
[[package]]
name = "pypdf"
version = "6.11.0"
@@ -2849,6 +3381,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
]
+[[package]]
+name = "pyppmd"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/81/d7/803232913cab9163a1a97ecf2236cd7135903c46ac8d49613448d88e8759/pyppmd-1.3.1.tar.gz", hash = "sha256:ced527f08ade4408c1bfc5264e9f97ffac8d221c9d13eca4f35ec1ec0c7b6b2e", size = 1351815, upload-time = "2025-11-27T22:08:44.175Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f0/01/7cc3854b0e1304b90d2435e946db5e1c42e103adf840a68400002fc838b4/pyppmd-1.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4b4edb3e9619fd0bc39c1a07eb03e8731db833a93b23134f36c7ef581a94b37a", size = 77800, upload-time = "2025-11-27T22:08:03.856Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/02/caf6305224b9432ea7373670d101392f620c1ce741d6f95458b1fb9a16a4/pyppmd-1.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8b5c813e462c91048b88e2adfbcc0c69f2c905f70097001d32066f86f675bd4", size = 48195, upload-time = "2025-11-27T22:08:05.323Z" },
+ { url = "https://files.pythonhosted.org/packages/45/82/8c5d3f0f738a3cf5c209d1471efda105a16417c0aaa91a2ad39efb3d4efb/pyppmd-1.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e8d372d9fac382183e0371cf0c2d736b494b1857a1befe98d563342b1205265b", size = 48482, upload-time = "2025-11-27T22:08:06.441Z" },
+ { url = "https://files.pythonhosted.org/packages/76/9c/09ab8b4f00473f210284dacf837cae8355ad900f3fd8fd98b2bba12de4fa/pyppmd-1.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b765ae21f7ed2f4ea8f32bfd9e3a4a8d738e73fc8f8dcddec9cbe2c898d60be", size = 142828, upload-time = "2025-11-27T22:08:07.611Z" },
+ { url = "https://files.pythonhosted.org/packages/64/fd/673d429df719affa1445611288aab6111c0bc87e51eb4677fe2421a1dd64/pyppmd-1.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd00522ddfcc292304577386b6c217758c0c10e1fb9ce7877ad7d3b7b821a808", size = 144502, upload-time = "2025-11-27T22:08:08.869Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/0f/64dad213f56eaf23210ac7d3a5dedb19ad8f186f8b8682f65e7d51d9fa4a/pyppmd-1.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3de62099ff2ca876c2d39bc547bcba6f7b878988663abd782a5bad4edac3bb44", size = 139718, upload-time = "2025-11-27T22:08:10.114Z" },
+ { url = "https://files.pythonhosted.org/packages/74/61/bd5dbe8d7374748687d48f0bc44e6b930470e09552b1bbc65ef899ab673e/pyppmd-1.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:011f845de195d60fe973a635a1f4be981b7d80f357a8acb1b2d83bdf5087c808", size = 142578, upload-time = "2025-11-27T22:08:11.388Z" },
+ { url = "https://files.pythonhosted.org/packages/42/ae/0af00a9b1cd59b6821eb182413033d5ddbe05dfcde7511e3a6b1a5b83f6d/pyppmd-1.3.1-cp313-cp313-win32.whl", hash = "sha256:7d61bd01f25289b6ae54832db4254602fb0c6d105f6e6bf0aee39b803b698b98", size = 42102, upload-time = "2025-11-27T22:08:13.297Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/6d/311d8faca142f39b4d158c94ed7eb237a197d41dd7eb67f53442a7904e04/pyppmd-1.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:df8d84ab72381058a964ba66e5e81ed52dbd0b5ad734a5ef8353452983506098", size = 46962, upload-time = "2025-11-27T22:08:14.759Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/42/24b59b6bb73a3fec96c7f81521d146c2ac89ffcb89cf9e848f62e0660381/pyppmd-1.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:124a04aab6936ba011f9ad57067798c7f052fdb1848b0cc4318606eea55475e6", size = 45209, upload-time = "2025-11-27T22:08:15.933Z" },
+ { url = "https://files.pythonhosted.org/packages/65/10/ac2a011af1c7c40b6482e60d85d267ac5923fb8794b51bfddbb56b04d1ec/pyppmd-1.3.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ea53f71ac16e113599b8441a9d8b6dcd71cfdf15cdb33ba5151810b8e656c5ec", size = 77897, upload-time = "2025-11-27T22:08:17.104Z" },
+ { url = "https://files.pythonhosted.org/packages/20/13/737f4dac06685865d23f51b7061dbe9373c7bcc60b5b6456cd08301bc807/pyppmd-1.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:985c8703b53e5f68fe17f653e96748d60b1f855676c852a6e67cd472eb853671", size = 48268, upload-time = "2025-11-27T22:08:18.263Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/d3/c50ebaee8b8a064fe9a534e98df5051e309efb705728aadff4e6893cd87f/pyppmd-1.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:33daa996ad5203c665c0b55aff6329817b2cb7fa95f2c33a2e83ed0121b400cb", size = 48521, upload-time = "2025-11-27T22:08:19.742Z" },
+ { url = "https://files.pythonhosted.org/packages/80/6c/4a97c0a0ab7640aeddde4843cb6381b80ecb71fe2c6c48b9032d60586b11/pyppmd-1.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b49870c6d7194f6eb80f30335ca03596d153e02fcde2c222e4f1202ac25f7fcf", size = 142892, upload-time = "2025-11-27T22:08:20.972Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/22/744c32c7da3de171d9e62a1b2cb4104544ac778358565a3dbc06a2253324/pyppmd-1.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:385e92c97c42e8a6f0bfc0e4acfc6c074cb1ba3a2f650f292696dd9f19e2e603", size = 144550, upload-time = "2025-11-27T22:08:22.594Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/18/84af6808e0754923062122d3ee9f0532050f1612069d558bb5d53978e67a/pyppmd-1.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:017a1e2903f1c3147a1046db486990d401e8a25eb52c320b1fc2fb3e7b83cbeb", size = 139850, upload-time = "2025-11-27T22:08:23.863Z" },
+ { url = "https://files.pythonhosted.org/packages/20/5e/dc6166ea7929d442625301289d99313a5b358e3cb2e927a6bd913047e8ee/pyppmd-1.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f2770a4b777c0c5236b3d9294b7bf4bc15538c95d45b2079eb8ebc1298e62e37", size = 142589, upload-time = "2025-11-27T22:08:25.228Z" },
+ { url = "https://files.pythonhosted.org/packages/77/92/adc6b12ddf11173a02fd631010f88ad4fe4c532363959638d5d53583a3d9/pyppmd-1.3.1-cp314-cp314-win32.whl", hash = "sha256:b9d54cd59ce97f2ba57be1da91b3d874d129faca21c9565d7afec111f942e6a1", size = 42761, upload-time = "2025-11-27T22:08:26.917Z" },
+ { url = "https://files.pythonhosted.org/packages/12/4c/5003a413d9f2743298a070df6713a75dedccc470e7adeeced5b43cad7418/pyppmd-1.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0e64247618cb150d2909beb0137da3084fef1d3479b4cc73b5b47fda7611abf9", size = 47806, upload-time = "2025-11-27T22:08:28.059Z" },
+ { url = "https://files.pythonhosted.org/packages/07/b7/db927e1df7fc3132cb57960f4ea23bf174c7e86ccec22857e6a35cccdae7/pyppmd-1.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:d354d6e551d2630b0ac98f27e3ad63e86cdcac9ce2115b5dfe46e2c9d3f4e82c", size = 46122, upload-time = "2025-11-27T22:08:29.191Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/5f/3150227624f374b06e331a7f67ae3383e796dd90973ee17ec3e35d30524c/pyppmd-1.3.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:2d77ed79662c32e2551748d59763cfe3dcd10855bf3495937e3d5e5917507818", size = 78876, upload-time = "2025-11-27T22:08:30.635Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/81/11a3bba62d1d92050f4dd86db810062b506ec76d20b6e00b4ab567ff15e4/pyppmd-1.3.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6a1f92b94635c23d85270bb26db25cc0db544e436af86efc1cf58302d71d5af1", size = 48830, upload-time = "2025-11-27T22:08:32.087Z" },
+ { url = "https://files.pythonhosted.org/packages/24/47/e8f50cf89c689543d5a16c7be3df3c4afd43cbeb1a019b84ff9e82f13415/pyppmd-1.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:610f214f2405e27eb5a3dad7fa15f385cfc42141a01cda71995d9c1e0b09fab9", size = 48956, upload-time = "2025-11-27T22:08:33.567Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/0f/944b30679a8dea3ea95a66531ee30cb6feb5d011ec7fd6832069d9130bf3/pyppmd-1.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae81a14895498d9a23429d92114c98da478b74b8e33251527d7cff3e01c09de0", size = 152872, upload-time = "2025-11-27T22:08:35.098Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/87/1e48ea92994f4c72dc9b5520a6a386b21c524d3d2969c2c2a5c325929ad7/pyppmd-1.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1683e6d1ba09e377e0ae02de3a518191a3d63ccdb0b6037c74e6ddf577b5644", size = 154210, upload-time = "2025-11-27T22:08:36.368Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/21/650911f98c4cb360442724bbdade27cb3679b0587ea77e73512693d2918d/pyppmd-1.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e4d74fa0f3531e9dadc56e0ace41bce82d3c0babed47b3f224101dc0dbde7287", size = 147636, upload-time = "2025-11-27T22:08:37.657Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/29/4f8cfc1dd67b04ced8c10669fa14c4e35a42cf4a84e1101793735a82d5f0/pyppmd-1.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f8d6375b18b9c79127fee0885cfd52e2e983edb67041464309426571d38dcd4", size = 150998, upload-time = "2025-11-27T22:08:38.951Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/da/dc9e5e10bc56056bd8c33e533517cb327752cbaf172688bba3c23f6b2318/pyppmd-1.3.1-cp314-cp314t-win32.whl", hash = "sha256:de87f7acd575fb07a4ff42d41bcc071570fe759a36f345f1f54f574ecfccfc5b", size = 43016, upload-time = "2025-11-27T22:08:40.234Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/a0/46dc15549ad2c0427a9825730e6bdf342045ad410543368afd4389a16f36/pyppmd-1.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:76e4800aa67292b4cc80058fd29b39e02a5dded721af9fe5654f356ef24307f4", size = 48636, upload-time = "2025-11-27T22:08:41.45Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/7b/7e9a83848de928c26f0ab3ee105c0da63a932a0804a94807205e6313e73a/pyppmd-1.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:e066cbf1d335fe20480cd8ecc10848ba78d99fe6d1e44ea00def48feaf46afdf", size = 46402, upload-time = "2025-11-27T22:08:42.804Z" },
+]
+
[[package]]
name = "pyright"
version = "1.1.409"
@@ -2928,6 +3498,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
+[[package]]
+name = "python-docx"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "lxml" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
+]
+
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -3016,6 +3599,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
]
+[[package]]
+name = "red-black-tree-mod"
+version = "1.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/48/75/bfa342a2ebfc9623b701f1c6995b9906fd6dd2cedf6bce777d09e23303ac/red-black-tree-mod-1.22.tar.gz", hash = "sha256:38e3652903a2bf96379c27c2082ca0b7b905158662dd7ef0c97f4fd93a9aa908", size = 34173, upload-time = "2023-12-26T14:00:22.056Z" }
+
[[package]]
name = "redis"
version = "7.4.0"
@@ -3247,6 +3836,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
]
+[[package]]
+name = "rtfde"
+version = "0.1.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "lark" },
+ { name = "oletools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/5c/116a016b38af589e8141160bc9b034b73dde2e50c22a921751f4d982a7ca/rtfde-0.1.2.2.tar.gz", hash = "sha256:2f0cd6ecd644071e39452e6fc4f4a1435453af0ec7c90ea86fb4fc96010c7f1b", size = 33408, upload-time = "2025-12-09T17:10:31.805Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/24/5a653278259be44c1845ddd56dd30cfa7265281ba149b9342b79f9d4f788/rtfde-0.1.2.2-py3-none-any.whl", hash = "sha256:d43868c74f21ae9ea5acbfd4176d5de1f2cfae0ff7f267698471c606287c04ec", size = 36713, upload-time = "2025-12-09T17:10:30.893Z" },
+]
+
[[package]]
name = "ruff"
version = "0.15.12"
@@ -3324,6 +3926,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
+[[package]]
+name = "soupsieve"
+version = "2.8.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
+]
+
[[package]]
name = "sqlalchemy"
version = "2.0.49"
@@ -3451,6 +4062,15 @@ redis = [
{ name = "redis" },
]
+[[package]]
+name = "texttable"
+version = "1.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/dc/0aff23d6036a4d3bf4f1d8c8204c5c79c4437e25e0ae94ffe4bbb55ee3c2/texttable-1.7.0.tar.gz", hash = "sha256:2d2068fb55115807d3ac77a4ca68fa48803e84ebb0ee2340f858107a36522638", size = 12831, upload-time = "2023-10-03T09:48:12.272Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/99/4772b8e00a136f3e01236de33b0efda31ee7077203ba5967fcc76da94d65/texttable-1.7.0-py2.py3-none-any.whl", hash = "sha256:72227d592c82b3d7f672731ae73e4d1f88cd8e2ef5b075a7a7f01a23a3743917", size = 10768, upload-time = "2023-10-03T09:48:10.434Z" },
+]
+
[[package]]
name = "tiktoken"
version = "0.12.0"
@@ -3491,6 +4111,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" },
]
+[[package]]
+name = "tinycss2"
+version = "1.5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "webencodings" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" },
+]
+
[[package]]
name = "tokenizers"
version = "0.23.1"
@@ -3596,6 +4228,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
]
+[[package]]
+name = "tzlocal"
+version = "5.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
+]
+
[[package]]
name = "uncalled-for"
version = "0.3.2"
@@ -3730,6 +4374,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" },
]
+[[package]]
+name = "webencodings"
+version = "0.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" },
+]
+
[[package]]
name = "websockets"
version = "16.0"
@@ -3766,6 +4419,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
+[[package]]
+name = "win-unicode-console"
+version = "0.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/89/8d/7aad74930380c8972ab282304a2ff45f3d4927108bb6693cabcc9fc6a099/win_unicode_console-0.5.zip", hash = "sha256:d4142d4d56d46f449d6f00536a73625a871cba040f0bc1a2e305a04578f07d1e", size = 31420, upload-time = "2016-06-25T19:48:54.05Z" }
+
[[package]]
name = "wrapt"
version = "2.1.2"