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"