From addb8d0be8ee81ef4decfb91dc77cb212de83266 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Mon, 15 Jun 2026 15:28:18 -0700 Subject: [PATCH] feat(attachments): capability-gated client-side fallback (pdf->text, audio->transcript) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the active model can't ingest a kind natively, the wire resolver converts it client-side instead of sending a part the model can't read. Per-kind ownership, no shared machinery: PDF text-extraction is a pure-local PDF concern; audio transcription is an STT concern memoized in the audio domain. - core/pdf.py: extract_pdf_text via pypdfium2 (pure-local, no network, no cache — re-run per build; page-capped) - core/audio.py: transcribe_cached — non-raising, memoized by (alias, content-hash); backend failures not cached - session._wire_content_part: per-kind dispatch — native where the model supports the kind (supports_pdf / supports_audio_input), else fallback; display/export resolve natively so no conversion fires on a render - image left ungated (pre-existing behavior unchanged) - pyproject: pypdfium2 dependency + mypy untyped-import override - tests: pdf extraction, transcript memoization, per-kind gate dispatch --- pyproject.toml | 5 ++ tests/test_audio.py | 37 +++++++++++++ tests/test_pdf.py | 40 ++++++++++++++ tests/test_session_attachments.py | 76 ++++++++++++++++++++++++++ turnstone/core/audio.py | 45 +++++++++++++++ turnstone/core/pdf.py | 65 ++++++++++++++++++++++ turnstone/core/session.py | 91 +++++++++++++++++++++++++++++-- uv.lock | 45 +++++++++++++++ 8 files changed, 398 insertions(+), 6 deletions(-) create mode 100644 tests/test_pdf.py create mode 100644 turnstone/core/pdf.py diff --git a/pyproject.toml b/pyproject.toml index 0dd875d7..c85ea50a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "cryptography>=42", "lacme>=1.0.5", "python-frontmatter>=1.0", + "pypdfium2>=4", # PDF text-extract fallback for models without native PDF input (core/attachment_fallback.py) ] [project.urls] @@ -180,6 +181,10 @@ ignore_missing_imports = true module = ["lacme", "lacme.*"] ignore_missing_imports = true +[[tool.mypy.overrides]] +module = ["pypdfium2", "pypdfium2.*"] +ignore_missing_imports = true + [[tool.mypy.overrides]] module = ["turnstone.channels.discord.*"] disallow_subclassing_any = false diff --git a/tests/test_audio.py b/tests/test_audio.py index d2756a59..af01c609 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -229,3 +229,40 @@ class TestOpenAIAudioModelsKnown: caps = lookup_model_capabilities("openai", "gpt-5") or {} assert not caps.get("supports_transcription") assert not caps.get("supports_speech_synthesis") + + +class TestTranscribeCached: + """The memoized, non-raising transcribe used by the no-native-audio wire + fallback. Caching an STT result is an audio-domain concern, so it lives here + next to ``transcribe`` rather than bundled with PDF text extraction.""" + + def _result(self, text: str): + return audio.TranscriptionResult(transcript=text, model_alias="w", model="m") + + def test_memoizes_by_alias_and_hash(self, monkeypatch): + audio._clear_transcript_cache_for_test() + calls = [] + + def fake(*, registry, alias, data, filename): + calls.append(1) + return self._result("hello world") + + monkeypatch.setattr(audio, "transcribe", fake) + kw = dict(registry=object(), alias="w", content_hash="h1", data=b"x", filename="a.wav") + assert audio.transcribe_cached(**kw) == "hello world" + assert audio.transcribe_cached(**kw) == "hello world" + assert len(calls) == 1 # second served from cache + + def test_backend_failure_returns_empty_and_is_not_cached(self, monkeypatch): + audio._clear_transcript_cache_for_test() + calls = [] + + def boom(*, registry, alias, data, filename): + calls.append(1) + raise audio.AudioBackendError("down") + + monkeypatch.setattr(audio, "transcribe", boom) + kw = dict(registry=object(), alias="w", content_hash="h2", data=b"x", filename="a.wav") + assert audio.transcribe_cached(**kw) == "" + audio.transcribe_cached(**kw) + assert len(calls) == 2 # failure not cached -> retried diff --git a/tests/test_pdf.py b/tests/test_pdf.py new file mode 100644 index 00000000..93d46f5d --- /dev/null +++ b/tests/test_pdf.py @@ -0,0 +1,40 @@ +"""Tests for core.pdf text extraction (the no-native-PDF wire fallback).""" + +from __future__ import annotations + +from turnstone.core.pdf import extract_pdf_text + + +def _minimal_pdf(text: str = "Hello PDF") -> bytes: + """A valid one-page PDF with a single text line (xref offsets computed).""" + stream = b"BT /F1 24 Tf 20 60 Td (" + text.encode("latin-1") + b") Tj ET" + objs = [ + b"<>", + b"<>", + b"<>>>>>", + b"<>\nstream\n%s\nendstream" % (len(stream), stream), + b"<>", + ] + pdf = b"%PDF-1.4\n" + offsets = [] + for i, obj in enumerate(objs, 1): + offsets.append(len(pdf)) + pdf += b"%d 0 obj\n%s\nendobj\n" % (i, obj) + xref = len(pdf) + pdf += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1) + for off in offsets: + pdf += b"%010d 00000 n \n" % off + pdf += b"trailer\n<>\nstartxref\n%d\n%%%%EOF" % (len(objs) + 1, xref) + return pdf + + +class TestExtractPdfText: + def test_extracts_text(self) -> None: + assert "Hello PDF" in extract_pdf_text(_minimal_pdf("Hello PDF")) + + def test_garbage_returns_empty_no_raise(self) -> None: + assert extract_pdf_text(b"not a pdf at all") == "" + + def test_empty_returns_empty(self) -> None: + assert extract_pdf_text(b"") == "" diff --git a/tests/test_session_attachments.py b/tests/test_session_attachments.py index 046a9c70..5016fb94 100644 --- a/tests/test_session_attachments.py +++ b/tests/test_session_attachments.py @@ -11,6 +11,7 @@ from turnstone.core.memory import ( get_attachment, register_workstream, ) +from turnstone.core.providers._protocol import ModelCapabilities from turnstone.core.session import ChatSession from turnstone.core.trajectory import ( dicts_from_turns, @@ -399,3 +400,78 @@ class TestTokenAccounting: } _t2, _i2, doc2 = ChatSession._msg_text_chars(inline_plus_meta) assert doc2 == 4000 + + +class TestCapabilityGatedFallback: + """The wire resolver routes each blob to a native part or a client-side + fallback based on the active model's capabilities — per-kind dispatch, no + shared 'fallback' machinery.""" + + def _att(self, kind, content=b"x", fn="f", mime="application/octet-stream"): + return { + "attachment_id": "aX", + "filename": fn, + "mime_type": mime, + "kind": kind, + "content": content, + } + + def test_pdf_native_when_supported(self, tmp_db, mock_openai_client): + s = _make_session(mock_openai_client) + part = s._wire_content_part( + self._att("pdf", b"%PDF-1.4 x", "r.pdf", "application/pdf"), + ModelCapabilities(supports_pdf=True), + ) + assert part["type"] == "document" + assert part["document"]["media_type"] == "application/pdf" + + def test_pdf_text_fallback_when_unsupported(self, tmp_db, mock_openai_client, monkeypatch): + s = _make_session(mock_openai_client) + monkeypatch.setattr("turnstone.core.pdf.extract_pdf_text", lambda data: "EXTRACTED") + part = s._wire_content_part( + self._att("pdf", b"%PDF", "r.pdf", "application/pdf"), + ModelCapabilities(supports_pdf=False), + ) + assert part["type"] == "document" + assert part["document"]["media_type"] == "text/plain" + assert part["document"]["data"] == "EXTRACTED" + assert "extracted text" in part["document"]["name"] + + def test_pdf_empty_extract_is_placeholder(self, tmp_db, mock_openai_client, monkeypatch): + s = _make_session(mock_openai_client) + monkeypatch.setattr("turnstone.core.pdf.extract_pdf_text", lambda data: "") + part = s._wire_content_part( + self._att("pdf", b"%PDF", "scan.pdf", "application/pdf"), + ModelCapabilities(supports_pdf=False), + ) + assert part["type"] == "text" + assert "no extractable text" in part["text"] + + def test_audio_native_when_supported(self, tmp_db, mock_openai_client): + s = _make_session(mock_openai_client) + part = s._wire_content_part( + self._att("audio", b"RIFFxxxxWAVE", "a.wav", "audio/wav"), + ModelCapabilities(supports_audio_input=True), + ) + assert part["type"] == "input_audio" + assert part["input_audio"]["format"] == "wav" + + def test_audio_fallback_no_stt_is_placeholder(self, tmp_db, mock_openai_client): + # _make_session leaves registry / config_store None -> no STT role. + s = _make_session(mock_openai_client) + part = s._wire_content_part( + self._att("audio", b"RIFF", "a.wav", "audio/wav"), + ModelCapabilities(supports_audio_input=False), + ) + assert part["type"] == "text" + assert "no transcription backend" in part["text"] + + def test_image_not_gated(self, tmp_db, mock_openai_client): + # Images are unchanged by this work — still emitted as image_url even to + # a no-vision model (pre-existing behavior, left as-is). + s = _make_session(mock_openai_client) + part = s._wire_content_part( + self._att("image", PNG_1x1, "i.png", "image/png"), + ModelCapabilities(), + ) + assert part["type"] == "image_url" diff --git a/turnstone/core/audio.py b/turnstone/core/audio.py index a4436846..bde0ae7a 100644 --- a/turnstone/core/audio.py +++ b/turnstone/core/audio.py @@ -15,9 +15,14 @@ backend is surfaced as a typed error the endpoint maps to 503 / 502. from __future__ import annotations +import threading from dataclasses import dataclass from typing import Any +from turnstone.core.log import get_logger + +log = get_logger(__name__) + # Setting key + capability flag per media role. Kept deliberately small; the # perception/eval roles (vision_eval/av_eval/intent_eval) are a later slice. _ROLE_SETTING: dict[str, str] = { @@ -160,6 +165,46 @@ def transcribe( return TranscriptionResult(transcript=transcript, model_alias=alias, model=model) +# -- transcript memoization (no-native-audio wire fallback) ------------------- +# Caching an STT result is an audio-domain concern, so it lives here next to +# ``transcribe``. The wire resolver re-materializes every attachment on every +# send, so without this an audio clip attached early in a conversation would be +# re-sent to the (external, fallible) STT backend on every subsequent turn. +_TRANSCRIPT_CACHE_MAX = 256 +_transcript_lock = threading.Lock() +_transcript_cache: dict[str, str] = {} + + +def _clear_transcript_cache_for_test() -> None: + with _transcript_lock: + _transcript_cache.clear() + + +def transcribe_cached( + *, registry: Any, alias: str, content_hash: str, data: bytes, filename: str +) -> str: + """Memoized, non-raising :func:`transcribe` for the wire fallback. + + Keyed by ``(alias, content_hash)``. Returns ``""`` on a backend failure (a + placeholder is rendered upstream) and does *not* cache failures, so a + transient outage doesn't poison the memo. + """ + key = f"{alias}:{content_hash}" + with _transcript_lock: + if key in _transcript_cache: + return _transcript_cache[key] + try: + text = transcribe(registry=registry, alias=alias, data=data, filename=filename).transcript + except (AudioUnavailableError, AudioBackendError) as exc: + log.warning("audio transcription fallback failed: %s", exc) + return "" + with _transcript_lock: + if key not in _transcript_cache and len(_transcript_cache) >= _TRANSCRIPT_CACHE_MAX: + _transcript_cache.pop(next(iter(_transcript_cache)), None) + _transcript_cache[key] = text + return text + + def synthesize( *, registry: Any, alias: str, text: str, voice: str, response_format: str = "mp3" ) -> SpeechResult: diff --git a/turnstone/core/pdf.py b/turnstone/core/pdf.py new file mode 100644 index 00000000..8b47f8cf --- /dev/null +++ b/turnstone/core/pdf.py @@ -0,0 +1,65 @@ +"""PDF helpers. + +Text extraction for the no-native-PDF fallback: when a model lacks +``supports_pdf``, the wire resolver extracts the PDF's text here and sends it as +a text document rather than PDF bytes the model can't read. Pure-local +(pypdfium2), no network, deterministic. + +Re-run per wire build by design — there is intentionally no module-global cache +here. A PDF re-parsed on every turn of a long conversation is wasteful, but the +principled place to memoize a *derived representation of a content-addressed +blob* is a durable derived-artifact store keyed by (source-hash, derivation) +that would serve every kind uniformly — not a per-module dict that happens to +hold PDFs. See the attachments design brief; that store is deferred. +""" + +from __future__ import annotations + +import contextlib + +from turnstone.core.log import get_logger + +log = get_logger(__name__) + +# Bound the page walk so a pathological (small-bytes, many-pages) PDF can't block +# the sync send thread unbounded. +_MAX_PAGES = 100 + + +def extract_pdf_text(data: bytes) -> str: + """Best-effort text from a PDF; never raises. + + Returns ``""`` on a parse failure or a scanned PDF with no text layer. Walks + at most :data:`_MAX_PAGES` pages. + """ + try: + import pypdfium2 as pdfium + except ImportError: # pragma: no cover - declared dependency; defensive + log.warning("pypdfium2 not installed; PDF text extraction unavailable") + return "" + + doc = None + try: + doc = pdfium.PdfDocument(data) + parts: list[str] = [] + truncated = False + for i, page in enumerate(doc): + if i >= _MAX_PAGES: + truncated = True + page.close() + break + textpage = page.get_textpage() + parts.append(textpage.get_text_range() or "") + textpage.close() + page.close() + text = "\n\n".join(p.strip() for p in parts if p.strip()) + if truncated: + text += f"\n\n[PDF truncated at {_MAX_PAGES} pages]" + return text + except Exception as exc: + log.warning("PDF text extraction failed: %s", exc) + return "" + finally: + if doc is not None: + with contextlib.suppress(Exception): + doc.close() diff --git a/turnstone/core/session.py b/turnstone/core/session.py index f7000b1a..85295589 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -2930,15 +2930,94 @@ class ChatSession: The send-time materialization of the by-reference content lane: handed to the provider translator, which calls it with the placeholder ids it finds - and expands each to the inline ``data:…`` / document part the wire needs. - Blobs are batch-fetched from the content-addressed store; a pruned id - resolves to nothing and the translator drops its placeholder.""" + and expands each to the inline part the wire needs. Blobs are + batch-fetched from the content-addressed store; a pruned id resolves to + nothing and the translator drops its placeholder. + + Kinds the active model can't ingest natively (pdf without ``supports_pdf``, + audio without ``supports_audio_input``) are converted client-side here — + see :meth:`_wire_content_part`. This is the wire path only; the display / + export resolvers stay native-only, so no conversion (or external STT call) + fires on a history render.""" if not ids: return {} + caps = self._get_capabilities() + out: dict[str, dict[str, Any]] = {} + for att in get_attachments(ids): + part = self._wire_content_part(att, caps) + if part is not None: + out[str(att["attachment_id"])] = part + return out + + def _wire_content_part( + self, att: dict[str, Any], caps: ModelCapabilities + ) -> dict[str, Any] | None: + """The active model's inline part for one blob: native where supported, + else a client-side fallback for a kind it can't read (pdf -> extracted + text, audio -> STT transcript).""" + kind = att.get("kind") + if kind == "pdf" and not caps.supports_pdf: + return self._pdf_text_fallback_part(att) + if kind == "audio" and not caps.supports_audio_input: + return self._audio_transcript_fallback_part(att) + return attachment_to_content_part(att) + + def _pdf_text_fallback_part(self, att: dict[str, Any]) -> dict[str, Any]: + """Non-PDF model: extract the PDF's text and carry it as a text document.""" + from turnstone.core.pdf import extract_pdf_text + + name = str(att.get("filename") or "document.pdf") + raw = att.get("content") + text = extract_pdf_text(raw) if isinstance(raw, bytes) else "" + if not text: + return { + "type": "text", + "text": ( + f"[PDF attachment '{name}' — no extractable text; " + "this model cannot read PDFs natively]" + ), + } return { - str(att["attachment_id"]): part - for att in get_attachments(ids) - if (part := attachment_to_content_part(att)) is not None + "type": "document", + "document": { + "name": f"{name} (extracted text)", + "media_type": "text/plain", + "data": text, + }, + } + + def _audio_transcript_fallback_part(self, att: dict[str, Any]) -> dict[str, Any]: + """Non-omni model: transcribe via the STT role and carry the transcript. + + Only engages when an operator has configured an STT role (which may be a + local backend) — otherwise a placeholder, never a surprise external call.""" + from turnstone.core.audio import resolve_role_alias, transcribe_cached + + name = str(att.get("filename") or "audio") + raw = att.get("content") + alias = resolve_role_alias( + config_store=self._config_store, registry=self._registry, role="stt" + ) + if not alias or not isinstance(raw, bytes): + return { + "type": "text", + "text": f"[audio attachment '{name}' — no transcription backend configured]", + } + transcript = transcribe_cached( + registry=self._registry, + alias=alias, + content_hash=str(att.get("attachment_id")), + data=raw, + filename=name, + ) + if not transcript: + return { + "type": "text", + "text": f"[audio attachment '{name}' — transcription unavailable]", + } + return { + "type": "text", + "text": f"[Transcript of audio attachment '{name}']\n\n{transcript}", } def _prepare_wire_messages( diff --git a/uv.lock b/uv.lock index dae8f621..4cba31da 100644 --- a/uv.lock +++ b/uv.lock @@ -794,7 +794,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/8fd452fd81adb9ec79c8275c1375702ab0fd6bee4952da12eaa09b9508d8/greenlet-3.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360", size = 623515, upload-time = "2026-05-20T14:09:07.853Z" }, { url = "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", size = 614754, upload-time = "2026-05-20T13:14:24.947Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bc/c318aa9f3ffc77320fddcee3d892be957b42e2ff947198d9450b004f3a38/greenlet-3.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747", size = 418439, upload-time = "2026-05-20T14:01:38.446Z" }, { url = "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", size = 1574097, upload-time = "2026-05-20T14:02:24.003Z" }, { url = "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", size = 1641058, upload-time = "2026-05-20T13:14:31.83Z" }, { url = "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", size = 238089, upload-time = "2026-05-20T13:14:03.229Z" }, @@ -802,7 +804,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, @@ -810,7 +814,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, @@ -818,7 +824,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, @@ -826,14 +834,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, @@ -841,7 +853,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, @@ -1792,6 +1806,35 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pypdfium2" +version = "5.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/78/d9b45abb97a3686643f7c6472a5f7688f2013a373226121dc76b9debbacf/pypdfium2-5.10.1.tar.gz", hash = "sha256:f257d2011eb43c846b7e9f5a802e28646b29732763e4a35dd6ca76f9be580538", size = 272963, upload-time = "2026-06-15T10:09:16.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/49/0b85fcc0d236582143a25cf275b0b4f5d786f51eb07a89ec9c79d43efe18/pypdfium2-5.10.1-py3-none-android_23_arm64_v8a.whl", hash = "sha256:13abf7a9f5e0ddebc8bbcccea5f13ae5abe8a298ea219e125b0fc24c1d2171b4", size = 3409176, upload-time = "2026-06-15T10:08:36.017Z" }, + { url = "https://files.pythonhosted.org/packages/20/d2/2f522c5b2ad5166edf256bb4dbab97de5d07b2573f8a5630ddfcd1f8d4ee/pypdfium2-5.10.1-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:a0dc52b56631e2f7edcdb22bae3b155aa840bec32c5bd05781e90baaecee88e7", size = 2866175, upload-time = "2026-06-15T10:08:37.955Z" }, + { url = "https://files.pythonhosted.org/packages/a7/be/477548c026c2badfdbf4afc3358b7135121fb5bec2e2effcb67e3a674d0b/pypdfium2-5.10.1-py3-none-macosx_12_0_arm64.whl", hash = "sha256:ebb9e63f92d15fc41b359fe7a187233dfae37548800e1fa09cb2fc466ac89951", size = 3621427, upload-time = "2026-06-15T10:08:39.969Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9b/0131c7f711b62c6edd6b200e9eb6340be6de4f6dc5baae625e3394d8d5fb/pypdfium2-5.10.1-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:d04f2050b6b32bb18624688b600543342a4ab3aacf64bf66521a5af72bbc7de1", size = 3682825, upload-time = "2026-06-15T10:08:42.181Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3d/bddfceb6e67e54d6dd1ab6c0f1feff796a89596e40f6345a3b4cc6a3d408/pypdfium2-5.10.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0de53d2710ca9509fc2812340acc57c3f043697609068592d87de654f8cabf44", size = 3682206, upload-time = "2026-06-15T10:08:44.087Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/dedeb25c6645fc8a5eda24f54e0b1d083b7334ababf5f518bb939d729cc0/pypdfium2-5.10.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10a26ce04795f8ec079e81c707fcb5737061e8a78025babc3d6e36642e9c903a", size = 3413720, upload-time = "2026-06-15T10:08:45.739Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/69e1fc8b1216005243c6415183bbf6de1cda3f5a06758b3fa4a26a7385c6/pypdfium2-5.10.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be6d2a8d1bcfd777188e7aa55a25c83f34d54e8350ad8810fb32017787d9b0d9", size = 3812913, upload-time = "2026-06-15T10:08:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/72/7f/132455a58ad736d76815c6cd1307532c3f433299d945b9d2f8cc2387c309/pypdfium2-5.10.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf4d2527f79f31c550490cc74c9f32e19385a944630a3ef4cd4d9b6f961fbf77", size = 4223220, upload-time = "2026-06-15T10:08:49.099Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8c/4d5804eca598bbe894e0a9a510807e221c1623e7276ce6b68fa2660dc933/pypdfium2-5.10.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ba127750bc3f4161461538d532d74491cd976f584f1753a6cee9cb821338ec1", size = 3738950, upload-time = "2026-06-15T10:08:50.821Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7f/baac59bf14ff914d97789ee0368c22ae233aa857aa9c0726bcb515dbc4d7/pypdfium2-5.10.1-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:80f30517ee089dfbbc6e9de6da365b6e8c0ce8f80c40b7d4025a3b1d3bbd8a70", size = 4029869, upload-time = "2026-06-15T10:08:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d7/a5d58a0bcba31a0e37ed636a76ef3d2d215733f28af61637287d061b0c54/pypdfium2-5.10.1-py3-none-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ad5f5de15febb788c6eb3853e58aceda9cd8c5187c92472abaeecf9558deb0cf", size = 3990927, upload-time = "2026-06-15T10:08:54.555Z" }, + { url = "https://files.pythonhosted.org/packages/da/1a/98eebd14b36812176297cf765d504ebeebc982f895c8a3a9fbd2717797de/pypdfium2-5.10.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8947fa3cd808da33960bdf8ff9e5247aa94ccd94f0b09bb3402e99498d83bcc9", size = 4989624, upload-time = "2026-06-15T10:08:56.284Z" }, + { url = "https://files.pythonhosted.org/packages/1c/20/f2e124d607b8bb90a9f1ce976afff38c70e215cd7ea86af784cb2e8a19dd/pypdfium2-5.10.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:139a6387a3a2652f288e53164268bb03af4fa221d78484ee18407053a60082a3", size = 4535124, upload-time = "2026-06-15T10:08:58.214Z" }, + { url = "https://files.pythonhosted.org/packages/59/ef/469ea87f668a32ff3280ea15e522e3a7858d2c80f1ecc320ece1244624c9/pypdfium2-5.10.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:172ff3e10358d66456e27fb0b8b5098e28ec24e51072eb4b5b86077d550e21bb", size = 5229373, upload-time = "2026-06-15T10:09:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/c3/48/c7ed3001f0c5e28114c98bf918c8121e422a65ba7323e1e12f3f28e2d278/pypdfium2-5.10.1-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:09fda0609dc4749c9865a0315c447d6f2583a580693bcabd69980d3fbb22ad51", size = 5140010, upload-time = "2026-06-15T10:09:02.734Z" }, + { url = "https://files.pythonhosted.org/packages/b2/bc/00b731bfc1fdc0f3c7d91108fceb54978ff4f5a92336820e5ed6c12535ff/pypdfium2-5.10.1-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:6b01adcfe9aaf7a635a59bed5687fd4fd7b0da292664f050d4ebd2bfa5c70584", size = 4643310, upload-time = "2026-06-15T10:09:04.794Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e8/3ad242233f657c19092a8c83684c8154b8d02a826535a6a2591d91aa5dde/pypdfium2-5.10.1-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:610e14c37d2090b826dccc0604fc7e7612c0ce591190780ae228abdb9abb971e", size = 5087879, upload-time = "2026-06-15T10:09:06.861Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/9d0747a02ac3021ed3db7ac27c5187d97e78b0253a4bdfbf386666968474/pypdfium2-5.10.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f2803020952afa57e1e148adc19369b835154e1fa251a1ca85008ab6466a710f", size = 5047369, upload-time = "2026-06-15T10:09:08.883Z" }, + { url = "https://files.pythonhosted.org/packages/9e/41/7d5187e9527eae81890a3b13193442112ef788d61ddaa68a6d59ac447bec/pypdfium2-5.10.1-py3-none-win32.whl", hash = "sha256:8702bb4f01ddfc8e7757b41b4c2c8392ac17c9f0234476e1e69672ea7c6d6aa0", size = 3680056, upload-time = "2026-06-15T10:09:10.95Z" }, + { url = "https://files.pythonhosted.org/packages/16/1d/c62bd59dd8345cc4b640f942f465633f6b07b859d01ddb648610a7bf5c7c/pypdfium2-5.10.1-py3-none-win_amd64.whl", hash = "sha256:58da5b51fb7884c7d21a05062ab13edb011d1a08dfd9694f3d5d685df62796b9", size = 3812105, upload-time = "2026-06-15T10:09:12.684Z" }, + { url = "https://files.pythonhosted.org/packages/10/d5/21bac39125df8a93e99c04583486b58a62b5997d6b3541e3ad0f69053392/pypdfium2-5.10.1-py3-none-win_arm64.whl", hash = "sha256:e3301c2f7a66fb8cb57dba857d0c9e90215e178f6602a87c5a306cd98513dab8", size = 3600043, upload-time = "2026-06-15T10:09:14.606Z" }, +] + [[package]] name = "pytest" version = "9.1.0" @@ -2323,6 +2366,7 @@ dependencies = [ { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pyjwt" }, + { name = "pypdfium2" }, { name = "python-frontmatter" }, { name = "sqlalchemy" }, { name = "sse-starlette" }, @@ -2374,6 +2418,7 @@ requires-dist = [ { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyjwt", specifier = ">=2.8" }, + { name = "pypdfium2", specifier = ">=4" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=9.0" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.0" }, { name = "python-frontmatter", specifier = ">=1.0" },