From 6b86f6f723847c9040d1e9f6df2a718c7b1be2ae Mon Sep 17 00:00:00 2001 From: stumpylog <797416+stumpylog@users.noreply.github.com> Date: Tue, 5 May 2026 09:00:02 -0700 Subject: [PATCH 01/53] Corrects the Docker image build check name --- .github/workflows/ci-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 865609c40..65dd56773 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -23,7 +23,7 @@ jobs: uses: lewagon/wait-on-check-action@9312864dfbc9fd208e9c0417843430751c042800 # v1.7.0 with: ref: ${{ github.sha }} - check-name: 'Build Docker Image' + check-name: 'Merge and Push Manifest' repo-token: ${{ secrets.GITHUB_TOKEN }} wait-interval: 60 build-release: From 749079963eadce11c7d02dfde44c29702167ff99 Mon Sep 17 00:00:00 2001 From: stumpylog <797416+stumpylog@users.noreply.github.com> Date: Tue, 5 May 2026 09:02:55 -0700 Subject: [PATCH 02/53] Dynamically update commitish so it should pick things for the changelog from beta --- .github/workflows/ci-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 65dd56773..98dbaa024 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -177,7 +177,7 @@ jobs: version: ${{ steps.get-version.outputs.version }} prerelease: ${{ steps.get-version.outputs.prerelease }} publish: true - commitish: main + commitish: ${{ steps.get-version.outputs.prerelease == 'true' && 'dev' || 'main' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload release archive From 76b2b6ad369b5ea58334178e8f756d2bb37669d2 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 5 May 2026 12:40:24 -0700 Subject: [PATCH 03/53] Bumps all our versions to 3.0.0 (#12715) --- docs/changelog.md | 2 +- pyproject.toml | 2 +- src-ui/package.json | 2 +- src-ui/src/environments/environment.prod.ts | 2 +- uv.lock | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 8ec847ae5..16f66e4be 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,6 @@ # Changelog -## paperless-ngx 2.20.15 +## paperless-ngx 3.0.0 ### Security diff --git a/pyproject.toml b/pyproject.toml index 9343e7529..211755733 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "paperless-ngx" -version = "2.20.15" +version = "3.0.0" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" readme = "README.md" requires-python = ">=3.11" diff --git a/src-ui/package.json b/src-ui/package.json index 246de3543..a1888c367 100644 --- a/src-ui/package.json +++ b/src-ui/package.json @@ -1,6 +1,6 @@ { "name": "paperless-ngx-ui", - "version": "2.20.15", + "version": "3.0.0", "scripts": { "preinstall": "npx only-allow pnpm", "ng": "ng", diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts index d92d7d7d9..593808020 100644 --- a/src-ui/src/environments/environment.prod.ts +++ b/src-ui/src/environments/environment.prod.ts @@ -6,7 +6,7 @@ export const environment = { apiVersion: '10', // match src/paperless/settings.py appTitle: 'Paperless-ngx', tag: 'prod', - version: '2.20.15', + version: '3.0.0', webSocketHost: window.location.host, webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketBaseUrl: base_url.pathname + 'ws/', diff --git a/uv.lock b/uv.lock index 96ac0e6f7..f2ddc4897 100644 --- a/uv.lock +++ b/uv.lock @@ -2868,7 +2868,7 @@ wheels = [ [[package]] name = "paperless-ngx" -version = "2.20.15" +version = "3.0.0" source = { virtual = "." } dependencies = [ { name = "azure-ai-documentintelligence", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, From e2ad14f9caf153b34c511255b2de2f1d7c057894 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 5 May 2026 12:52:34 -0700 Subject: [PATCH 04/53] Fix: workflow password removal didn't handle lists from the DB (#12716) --- src/documents/tests/test_workflows.py | 6 +++--- src/documents/workflows/actions.py | 7 +------ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index ad9768a17..2e31608bb 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -4164,7 +4164,7 @@ class TestWorkflows( ) action = WorkflowAction.objects.create( type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL, - passwords="wrong, right\n extra ", + passwords=["wrong", "right", "extra"], ) workflow = Workflow.objects.create(name="Password workflow") workflow.triggers.add(trigger) @@ -4218,7 +4218,7 @@ class TestWorkflows( ) action = WorkflowAction.objects.create( type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL, - passwords=" \n , ", + passwords=[" ", " "], ) workflow = Workflow.objects.create(name="Password workflow missing passwords") workflow.triggers.add(trigger) @@ -4276,7 +4276,7 @@ class TestWorkflows( """ action = WorkflowAction.objects.create( type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL, - passwords="first, second", + passwords=["first", "second"], ) temp_dir = Path(tempfile.mkdtemp()) diff --git a/src/documents/workflows/actions.py b/src/documents/workflows/actions.py index 9089c6dd8..b6adc35b7 100644 --- a/src/documents/workflows/actions.py +++ b/src/documents/workflows/actions.py @@ -1,5 +1,4 @@ import logging -import re import uuid from pathlib import Path @@ -290,11 +289,7 @@ def execute_password_removal_action( ) return - passwords = [ - password.strip() - for password in re.split(r"[,\n]", passwords) - if password.strip() - ] + passwords = [p.strip() for p in passwords if p.strip()] if isinstance(document, ConsumableDocument): # hook the consumption-finished signal to attempt password removal later From cc918bae5fc547437ddb886ecb842cdfea5a4df6 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 5 May 2026 16:57:47 -0700 Subject: [PATCH 05/53] Fix: pass allow parallel tool calls in LLM client (#12718) --- src/paperless_ai/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/paperless_ai/client.py b/src/paperless_ai/client.py index 8a653baef..ce1874461 100644 --- a/src/paperless_ai/client.py +++ b/src/paperless_ai/client.py @@ -73,6 +73,7 @@ class AIClient: tools=[tool], user_msg=user_msg, chat_history=[], + allow_parallel_tool_calls=True, ) tool_calls = self.llm.get_tool_calls_from_response( result, From 2296d7fa0ec55cf35e28d177929ec2830f29320c Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Wed, 6 May 2026 09:26:46 -0700 Subject: [PATCH 06/53] Fix: Rewrite Whoosh year only queries to be to Tantivy date syntax (#12725) --- src/documents/search/_query.py | 25 ++++++++ src/documents/tests/search/test_query.py | 79 +++++++++++++++++++++++- 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/documents/search/_query.py b/src/documents/search/_query.py index 0fbe4603b..65df260c3 100644 --- a/src/documents/search/_query.py +++ b/src/documents/search/_query.py @@ -71,6 +71,10 @@ _WHOOSH_REL_RANGE_RE = regex.compile( ) # Whoosh-style 8-digit date: field:YYYYMMDD — field-aware so timezone can be applied correctly _DATE8_RE = regex.compile(r"(?P\w+):(?P\d{8})\b") +_YEAR_RANGE_RE = regex.compile( + r"(?P\w+):\[(?P\d{4})\s+TO\s+(?P\d{4})\]", + regex.IGNORECASE, +) _SIMPLE_QUERY_TOKEN_RE = regex.compile(r"\S+") @@ -336,6 +340,26 @@ def _rewrite_8digit_date(query: str, tz: tzinfo) -> str: ) +def _rewrite_year_range(query: str) -> str: + """Rewrite Whoosh-style year-only date ranges to ISO 8601 UTC boundaries. + + Converts ``field:[YYYY TO YYYY]`` to a full ISO 8601 datetime range. + The upper bound is the start of the year after the end year (exclusive), + matching the Whoosh convention of treating year-only ranges as full-year spans. + """ + + def _sub(m: regex.Match[str]) -> str: + field = m.group("field") + lo = datetime(int(m.group("y1")), 1, 1, tzinfo=UTC) + hi = datetime(int(m.group("y2")) + 1, 1, 1, tzinfo=UTC) + return f"{field}:[{_fmt(lo)} TO {_fmt(hi)}]" + + try: + return _YEAR_RANGE_RE.sub(_sub, query, timeout=_REGEX_TIMEOUT) + except TimeoutError: # pragma: no cover + raise ValueError("Query too complex to process (year range rewrite timed out)") + + def rewrite_natural_date_keywords(query: str, tz: tzinfo) -> str: """ Rewrite natural date syntax to ISO 8601 format for Tantivy compatibility. @@ -359,6 +383,7 @@ def rewrite_natural_date_keywords(query: str, tz: tzinfo) -> str: """ query = _rewrite_compact_date(query) query = _rewrite_whoosh_relative_range(query) + query = _rewrite_year_range(query) query = _rewrite_8digit_date(query, tz) query = _rewrite_relative_range(query) diff --git a/src/documents/tests/search/test_query.py b/src/documents/tests/search/test_query.py index e47d6b7df..11297eb92 100644 --- a/src/documents/tests/search/test_query.py +++ b/src/documents/tests/search/test_query.py @@ -444,6 +444,83 @@ class TestParseUserQuery: assert isinstance(q, tantivy.Query) +class TestYearRangeRewriting: + """Whoosh-style year-only date ranges must be rewritten to ISO 8601.""" + + @pytest.mark.parametrize( + ("query", "field", "expected_lo", "expected_hi"), + [ + pytest.param( + "created:[2020 TO 2020]", + "created", + "2020-01-01T00:00:00Z", + "2021-01-01T00:00:00Z", + id="single_year_created", + ), + pytest.param( + "created:[2018 TO 2021]", + "created", + "2018-01-01T00:00:00Z", + "2022-01-01T00:00:00Z", + id="multi_year_range_created", + ), + pytest.param( + "added:[2022 TO 2023]", + "added", + "2022-01-01T00:00:00Z", + "2024-01-01T00:00:00Z", + id="added_field", + ), + pytest.param( + "modified:[2021 TO 2021]", + "modified", + "2021-01-01T00:00:00Z", + "2022-01-01T00:00:00Z", + id="modified_field", + ), + pytest.param( + "created:[2020 to 2020]", + "created", + "2020-01-01T00:00:00Z", + "2021-01-01T00:00:00Z", + id="lowercase_to_keyword", + ), + ], + ) + def test_year_range_rewritten( + self, + query: str, + field: str, + expected_lo: str, + expected_hi: str, + ) -> None: + result = rewrite_natural_date_keywords(query, UTC) + lo, hi = _range(result, field) + assert lo == expected_lo + assert hi == expected_hi + + def test_year_range_in_complex_boolean_query(self) -> None: + query = "tag:steuer AND (title:2020 OR (NOT title:2019 AND NOT title:2018 AND created:[2020 TO 2020]))" + result = rewrite_natural_date_keywords(query, UTC) + lo, hi = _range(result, "created") + assert lo == "2020-01-01T00:00:00Z" + assert hi == "2021-01-01T00:00:00Z" + assert "title:2020" in result + assert "title:2019" in result + assert "title:2018" in result + + def test_already_iso_date_range_passes_through_unchanged(self) -> None: + original = "created:[2020-01-01T00:00:00Z TO 2021-01-01T00:00:00Z]" + assert rewrite_natural_date_keywords(original, UTC) == original + + def test_8digit_in_brackets_not_matched_as_year_range(self) -> None: + # [YYYYMMDD TO YYYYMMDD] has 8-digit values - must not be caught by year rewriter + original = "created:[20200101 TO 20201231]" + result = rewrite_natural_date_keywords(original, UTC) + assert "20200101" in result or "2020-01-01" in result + assert "20201231" in result or "2020-12-31" in result + + class TestPassthrough: """Queries without field prefixes or unrelated content pass through unchanged.""" @@ -474,7 +551,7 @@ class TestNormalizeQuery: class TestPermissionFilter: """ - build_permission_filter tests use an in-memory index — no DB access needed. + build_permission_filter tests use an in-memory index - no DB access needed. Users are constructed as unsaved model instances (django_user_model(pk=N)) so no database round-trip occurs; only .pk is read by build_permission_filter. From 4f8eae17e19237b880c65c09475095310b723087 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Wed, 6 May 2026 12:24:30 -0700 Subject: [PATCH 07/53] Fix: Makes the font cache folder writeable to all users, like ourselves (#12726) --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index ed2eee3fe..0ab31e611 100644 --- a/Dockerfile +++ b/Dockerfile @@ -236,6 +236,8 @@ RUN set -eux \ && mkdir -m700 --verbose /usr/src/paperless/.gnupg \ && echo "Adjusting all permissions" \ && chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \ + && echo "Making fontconfig cache writable for arbitrary container UIDs" \ + && chmod 1777 /var/cache/fontconfig \ && echo "Collecting static files" \ && PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \ && PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \ From 8b6e8142f184b0d8ab8e3cc72f77c9376ae109b0 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Wed, 6 May 2026 15:07:13 -0700 Subject: [PATCH 08/53] Upgrades Django to the latest, cryptography, django-allauth for the release (#12731) --- pyproject.toml | 4 +- uv.lock | 246 ++++++++++++++++++++++++------------------------- 2 files changed, 125 insertions(+), 125 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 211755733..101d82127 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ # WARNING: django does not use semver. # Only patch versions are guaranteed to not introduce breaking changes. "django~=5.2.13", - "django-allauth[mfa,socialaccount]~=65.15.0", + "django-allauth[mfa,socialaccount]~=65.16.0", "django-auditlog~=3.4.1", "django-cachalot~=2.9.0", "django-compression-middleware~=0.5.0", @@ -40,7 +40,7 @@ dependencies = [ "djangorestframework~=3.16", "djangorestframework-guardian~=0.4.0", "drf-spectacular~=0.28", - "drf-spectacular-sidecar~=2026.4.14", + "drf-spectacular-sidecar~=2026.5.1", "drf-writable-nested~=0.7.1", "faiss-cpu>=1.10", "filelock~=3.29.0", diff --git a/uv.lock b/uv.lock index f2ddc4897..30f7c8ebc 100644 --- a/uv.lock +++ b/uv.lock @@ -383,7 +383,7 @@ wheels = [ [[package]] name = "celery" -version = "5.6.2" +version = "5.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "billiard", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -396,9 +396,9 @@ dependencies = [ { name = "tzlocal", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "vine", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/9d/3d13596519cfa7207a6f9834f4b082554845eb3cd2684b5f8535d50c7c44/celery-5.6.2.tar.gz", hash = "sha256:4a8921c3fcf2ad76317d3b29020772103581ed2454c4c042cc55dcc43585009b", size = 1718802, upload-time = "2026-01-04T12:35:58.012Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/b4/a1233943ab5c8ea05fb877a88a0a0622bf47444b99e4991a8045ac37ea1d/celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912", size = 1742243, upload-time = "2026-03-26T12:14:51.76Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/bd/9ecd619e456ae4ba73b6583cc313f26152afae13e9a82ac4fe7f8856bfd1/celery-5.6.2-py3-none-any.whl", hash = "sha256:3ffafacbe056951b629c7abcf9064c4a2366de0bdfc9fdba421b97ebb68619a5", size = 445502, upload-time = "2026-01-04T12:35:55.894Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/6eccdda96e098f7ae843162db2d3c149c6931a24fda69fe4ab84d0027eb5/celery-5.6.3-py3-none-any.whl", hash = "sha256:0808f42f80909c4d5833202360ffafb2a4f83f4d8e23e1285d926610e9a7afa6", size = 451235, upload-time = "2026-03-26T12:14:49.491Z" }, ] [package.optional-dependencies] @@ -723,54 +723,54 @@ toml = [ [[package]] name = "cryptography" -version = "46.0.7" +version = "48.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, - { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, - { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, - { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, - { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, - { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, - { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, - { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, - { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, - { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, - { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, - { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, - { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, - { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, ] [[package]] @@ -877,28 +877,28 @@ wheels = [ [[package]] name = "django" -version = "5.2.13" +version = "5.2.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sqlparse", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" }, + { url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" }, ] [[package]] name = "django-allauth" -version = "65.15.0" +version = "65.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/c1/d3385f4c3169c1d6eea3c63aed0f36af51478c1d72e46db12bb1a08f8034/django_allauth-65.15.0.tar.gz", hash = "sha256:b404d48cf0c3ee14dacc834c541f30adedba2ff1c433980ecc494d6cb0b395a8", size = 2215709, upload-time = "2026-03-09T13:51:28.675Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/df/357187dfff18c7783e4911827a6c69437e290d7259a32a99c23fcd85997f/django_allauth-65.16.1.tar.gz", hash = "sha256:4425ac3088541c4c54983e16e08f6e3eb9f438dc1b1009534fa51c8bb739ed31", size = 2232835, upload-time = "2026-04-17T18:53:59.475Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/b8/c8411339171bd8bc075c09ef190fb42195e9a2149e5c5026e094fe62fce0/django_allauth-65.15.0-py3-none-any.whl", hash = "sha256:ad9fc49c49a9368eaa5bb95456b76e2a4f377b3c6862ee8443507816578c098d", size = 2022994, upload-time = "2026-03-09T13:51:19.711Z" }, + { url = "https://files.pythonhosted.org/packages/ad/58/d95b6c3088d83697bfd93782ee57bc6a6462e41eb19121a947b8a015396a/django_allauth-65.16.1-py3-none-any.whl", hash = "sha256:e49df24056bf37c44e56aaad1e51f78994b7d175bc3476d65e8f8f58390a8ce8", size = 2051868, upload-time = "2026-04-17T18:54:12.032Z" }, ] [package.optional-dependencies] @@ -1160,14 +1160,14 @@ wheels = [ [[package]] name = "drf-spectacular-sidecar" -version = "2026.4.14" +version = "2026.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/0a/6dcc9cf1a60fa4247b886e9d4249ea7d04e67fede20af6fd631ef74c84a0/drf_spectacular_sidecar-2026.4.14.tar.gz", hash = "sha256:d4c299a85f3be44e93eaff3d83e986f27b744bb9823ba034aff9fd267ebc9fea", size = 2466204, upload-time = "2026-04-14T16:06:46.818Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/e9/600a7806111c6d1ba49d7e31bfc978a745682724310ad29b0d2c068f1f73/drf_spectacular_sidecar-2026.5.1.tar.gz", hash = "sha256:cdeca03e32859318a563b5733d5fc196c8b563a178a85fd380e227ed642c19ca", size = 2466161, upload-time = "2026-05-01T12:04:01.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/e7/23cb71e9348af587c2d34d6e548e0f02af21432cdf50afabf379d5649ee8/drf_spectacular_sidecar-2026.4.14-py3-none-any.whl", hash = "sha256:a040d360ada2592722e90f40c2cf744376d9a30cccb3caaa5423bad791dec0aa", size = 2488555, upload-time = "2026-04-14T16:06:45.097Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2e/29e8676c87201a174491d0e1104df99d27258b3b7e0dc15a0e9b11652d86/drf_spectacular_sidecar-2026.5.1-py3-none-any.whl", hash = "sha256:2af264e5b125fedc5d382be1349f7f736f128bc8fa05c3be3fc7f3e5b282d3c4", size = 2488545, upload-time = "2026-05-01T12:03:59.269Z" }, ] [[package]] @@ -1434,74 +1434,74 @@ wheels = [ [[package]] name = "granian" -version = "2.7.2" +version = "2.7.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/19/d4ea523715ba8dd2ed295932cc3dda6bb197060f78aada6e886ff08587b2/granian-2.7.2.tar.gz", hash = "sha256:cdae2f3a26fa998d41fefad58f1d1c84a0b035a6cc9377addd81b51ba82f927f", size = 128969, upload-time = "2026-02-24T23:04:23.314Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/0c/27aa25280b6c1f323312e83088304da8a7f3e5c1e568d3a560365ec6fa67/granian-2.7.4.tar.gz", hash = "sha256:1dc0530d7ae6b0ae43aafafe771ac0b8c38af68bbd71ab355828817faf13aac1", size = 128212, upload-time = "2026-04-23T11:55:55.275Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/58/dcf0e8a54b9a7f8b7482ed617bca08503a47eb6b702aea73cda9efd2c81c/granian-2.7.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3a0d33ada95a1421e5a22d447d918e5615ff0aa37f12de5b84455afe89970875", size = 6522860, upload-time = "2026-02-24T23:02:15.901Z" }, - { url = "https://files.pythonhosted.org/packages/2b/dd/398de0f273fdcf0e96bd70d8cd97364625176990e67457f11e23f95772bd/granian-2.7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ee26f0258cc1b6ccf87c7bdcee6d1f90710505522fc9880ec02b299fb15679ad", size = 6135934, upload-time = "2026-02-24T23:02:18.52Z" }, - { url = "https://files.pythonhosted.org/packages/67/b7/7bf635bbdfb88dfc6591fa2ce5c3837ab9535e57e197a780c4a338363de7/granian-2.7.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f52338cfab08b8cdaadaa5b93665e0be5b4c4f718fbd132d76ceacacb9ff864e", size = 7138393, upload-time = "2026-02-24T23:02:19.911Z" }, - { url = "https://files.pythonhosted.org/packages/0a/90/e424fd8a703add1e8922390503be8d057882b35b42ba51796157aabd659b/granian-2.7.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e377d03a638fecb6949ab05c8fd4a76f892993aed17c602d179bfd56aebc2de", size = 6467189, upload-time = "2026-02-24T23:02:21.896Z" }, - { url = "https://files.pythonhosted.org/packages/65/9a/5de24d7e2dba1aa9fbac6f0a80dace975cfac1b7c7624ece21da75a38987/granian-2.7.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f742f3ca1797a746fae4a9337fe5d966460c957fa8efeaccf464b872e158d3d", size = 6870813, upload-time = "2026-02-24T23:02:23.972Z" }, - { url = "https://files.pythonhosted.org/packages/ac/cd/a604e38237857f4ad4262eadc409f94fe08fed3e86fa0b8734479cc5bfb1/granian-2.7.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:ca4402e8f28a958f0c0f6ebff94cd0b04ca79690aded785648a438bc3c875ba3", size = 7046583, upload-time = "2026-02-24T23:02:25.94Z" }, - { url = "https://files.pythonhosted.org/packages/cc/ad/79eaae0cddd90c4e191b37674cedd8f4863b44465cb435b10396d0f12c82/granian-2.7.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1f9a899123b0d084783626e5225608094f1d2f6fc81b3a7c77ab8daac33ab74a", size = 7121958, upload-time = "2026-02-24T23:02:27.641Z" }, - { url = "https://files.pythonhosted.org/packages/ca/51/e5c923b1baa003f5b4b7fc148be6f8d2e3cabe55d41040fe8139da52e31b/granian-2.7.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:56ba4bef79d0ae3736328038deed2b5d281b11672bc0b08ffc8ce6210e406ef8", size = 7303047, upload-time = "2026-02-24T23:02:30.863Z" }, - { url = "https://files.pythonhosted.org/packages/06/c0/ebd68144a3ce9ead1a3192ac02e1c26e4874df1257435ce6137adf92fedb/granian-2.7.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ea46e3f43d94715aa89d1f2f5754753d46e6b653d561b82b0291e62a31bdfb35", size = 7011349, upload-time = "2026-02-24T23:02:32.887Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ed/37f5d7d887ec9159dd8f5b1c9c38cee711d51016d203959f2d51c536a33b/granian-2.7.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a836f3f8ebfe61cb25d9afb655f2e5d3851154fd2ad97d47bb4fb202817212fc", size = 6451593, upload-time = "2026-02-24T23:02:36.203Z" }, - { url = "https://files.pythonhosted.org/packages/1e/06/84ee67a68504836a52c48ec3b4b2b406cbd927c9b43aae89d82db8d097a0/granian-2.7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09b1c543ba30886dea515a156baf6d857bbb8b57dbfd8b012c578b93c80ef0c3", size = 6101239, upload-time = "2026-02-24T23:02:37.636Z" }, - { url = "https://files.pythonhosted.org/packages/ed/50/ece7dc8efe144542cd626b88b1475b649e2eaa3eb5f7541ca57390151b05/granian-2.7.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d334d4fbefb97001e78aa8067deafb107b867c102ba2120b4b2ec989fa58a89", size = 7079443, upload-time = "2026-02-24T23:02:39.651Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e8/0f37b531d3cc96b8538cca2dc86eda92102e0ee345b30aa689354194a4cb/granian-2.7.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c86081d8c87989db69650e9d0e50ed925b8cd5dad21e0a86aa72d7a45f45925", size = 6428683, upload-time = "2026-02-24T23:02:41.827Z" }, - { url = "https://files.pythonhosted.org/packages/47/09/228626706554b389407270e2a6b19b7dee06d6890e8c01a39c6a785827fd/granian-2.7.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9eda33dca2c8bc6471bb6e9e25863077bca3877a1bba4069cd5e0ee2de41765", size = 6959520, upload-time = "2026-02-24T23:02:43.488Z" }, - { url = "https://files.pythonhosted.org/packages/61/c0/a639ceabd59b8acae2d71b5c918fcb2d42f8ef98994eedcf9a8b6813731d/granian-2.7.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9cf69aaff6f632074ffbe7c1ee214e50f64be36101b7cb8253eeec1d460f2dba", size = 6991548, upload-time = "2026-02-24T23:02:44.954Z" }, - { url = "https://files.pythonhosted.org/packages/b1/99/a35ed838a3095dcad02ae3944d19ebafe1d5a98cdc72bb61835fb5faf933/granian-2.7.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f761a748cc7f3843b430422d2539da679daf5d3ef0259a101b90d5e55a0aafa7", size = 7121475, upload-time = "2026-02-24T23:02:46.991Z" }, - { url = "https://files.pythonhosted.org/packages/ce/24/3952c464432b904ec1cf537d2bd80d2dfde85524fa428ab9db2b5afe653c/granian-2.7.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:41c7b8390b78647fe34662ed7296e1465dad4a5112af9b0ecf8e367083d6c76a", size = 7243647, upload-time = "2026-02-24T23:02:49.165Z" }, - { url = "https://files.pythonhosted.org/packages/c9/fa/ab39e39c6b78eab6b42cf5bb36f56badde2aaafc3807f03f781d00e7861a/granian-2.7.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a052ed466da5922cb443435a95a0c751566943278a6f22cef3d2e19d4e7ecdea", size = 7048915, upload-time = "2026-02-24T23:02:50.773Z" }, - { url = "https://files.pythonhosted.org/packages/ab/bc/cf0bc29f583096a842cf0f26ae2fe40c72ed5286d4548be99ecfcdbb17e2/granian-2.7.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:76b840ff13dde8838fd33cd096f2e7cadf2c21a499a67f695f53de57deab6ff8", size = 6440868, upload-time = "2026-02-24T23:02:53.619Z" }, - { url = "https://files.pythonhosted.org/packages/2f/0d/bae1dcd2182ba5d9a5df33eb50b56dc5bbe67e31033d822e079aa8c1ff30/granian-2.7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:00ccc8d7284bc7360f310179d0b4d17e5ca3077bbe24427e9e9310df397e3831", size = 6097336, upload-time = "2026-02-24T23:02:55.185Z" }, - { url = "https://files.pythonhosted.org/packages/65/7d/3e0a7f32b0ad5faa1d847c51191391552fa239821c95fc7c022688985df2/granian-2.7.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:675987c1b321dc8af593db8639e00c25277449b32e8c1b2ddd46b35f28d9fac4", size = 7098742, upload-time = "2026-02-24T23:02:57.898Z" }, - { url = "https://files.pythonhosted.org/packages/89/41/3b44386d636ac6467f0f13f45474c71fc3b90a4f0ba8b536de91b2845a09/granian-2.7.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:681c6fbe3354aaa6251e6191ec89f5174ac3b9fbc4b4db606fea456d01969fcb", size = 6430667, upload-time = "2026-02-24T23:02:59.789Z" }, - { url = "https://files.pythonhosted.org/packages/52/70/7b24e187aed3fb7ac2b29d2480a045559a509ef9fec54cffb8694a2d94af/granian-2.7.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e5c9ae65af5e572dca27d8ca0da4c5180b08473ac47e6f5329699e9455a5cc3", size = 6948424, upload-time = "2026-02-24T23:03:01.406Z" }, - { url = "https://files.pythonhosted.org/packages/fa/4c/cb74c367f9efb874f2c8433fe9bf3e824f05cf719f2251d40e29e07f08c0/granian-2.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e37fab2be919ceb195db00d7f49ec220444b1ecaa07c03f7c1c874cacff9de83", size = 7000407, upload-time = "2026-02-24T23:03:03.214Z" }, - { url = "https://files.pythonhosted.org/packages/58/98/dfed3966ed7fbd3aae56e123598f90dc206484092b8373d0a71e2d8b82a8/granian-2.7.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:8ec167ab30f5396b5caaff16820a39f4e91986d2fe5bdc02992a03c2b2b2b313", size = 7121626, upload-time = "2026-02-24T23:03:05.349Z" }, - { url = "https://files.pythonhosted.org/packages/39/82/acec732a345cd03b2f6e48ac04b66b7b8b61f5c50eb08d7421fc8c56591a/granian-2.7.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:63f426d793f2116d23be265dd826bec1e623680baf94cc270fe08923113a86ba", size = 7253447, upload-time = "2026-02-24T23:03:06.986Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2b/64779e69b08c1ff1bfc09a4ede904ab761ff63f936c275710886057c52f7/granian-2.7.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1617cbb4efe3112f07fb6762cf81d2d9fe4bdb78971d1fd0a310f8b132f6a51e", size = 7053005, upload-time = "2026-02-24T23:03:09.021Z" }, - { url = "https://files.pythonhosted.org/packages/4c/49/9eb88875d709db7e7844e1c681546448dab5ff5651cd1c1d80ac4b1de4e3/granian-2.7.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:016c5857c8baedeab7eb065f98417f5ea26bb72b0f7e0544fe76071efc5ab255", size = 6401748, upload-time = "2026-02-24T23:03:12.802Z" }, - { url = "https://files.pythonhosted.org/packages/e3/80/85726ad9999ed89cb6a32f7f57eb50ce7261459d9c30c3b194ae4c5aa2c5/granian-2.7.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dcbe01fa141adf3f90964e86a959e250754aa7c6dad8fa7a855e6fd382de4c13", size = 6101265, upload-time = "2026-02-24T23:03:14.435Z" }, - { url = "https://files.pythonhosted.org/packages/07/82/0df56a42b9f4c327d0e0b052f43369127e1b565b9e66bf2c9488f1c8d759/granian-2.7.2-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:283ba23817a685784b66f45423d2f25715fdc076c8ffb43c49a807ee56a0ffc0", size = 6249488, upload-time = "2026-02-24T23:03:16.387Z" }, - { url = "https://files.pythonhosted.org/packages/ef/cc/d83a351560a3d6377672636129c52f06f8393f5831c5ee0f06f274883ea6/granian-2.7.2-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3258419c741897273ce155568b5a9cbacb7700a00516e87119a90f7d520d6783", size = 7104734, upload-time = "2026-02-24T23:03:17.993Z" }, - { url = "https://files.pythonhosted.org/packages/84/d1/539907ee96d0ee2bcceabb4a6a9643b75378d6dfea09b7a9e4fd22cdf977/granian-2.7.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a196125c4837491c139c9cc83541b48c408c92b9cfbbf004fd28717f9c02ad21", size = 6785504, upload-time = "2026-02-24T23:03:19.763Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/4b6f45882f8341e7c6cb824d693deb94c306be6525b483c76fb373d1e749/granian-2.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:746555ac8a2dcd9257bfe7ad58f1d7a60892bc4613df6a7d8f736692b3bb3b88", size = 6902790, upload-time = "2026-02-24T23:03:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/44/b8/832970d2d4b144b87be39f5b9dfd31fdb17f298dc238a0b2100c95002cf8/granian-2.7.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:5ac1843c6084933a54a07d9dcae643365f1d83aaff3fd4f2676ea301185e4e8b", size = 7082682, upload-time = "2026-02-24T23:03:23.875Z" }, - { url = "https://files.pythonhosted.org/packages/38/bc/1521dbf026d1c9d2465cd54e016efd8ff6e1e72eff521071dab20dd61c44/granian-2.7.2-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:3612eb6a3f4351dd2c4df246ed0d21056c0556a6b1ed772dd865310aa55a9ba9", size = 7264742, upload-time = "2026-02-24T23:03:25.562Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/00884ab77045a2f54db90932f9d1ca522201e2a6b2cf2a9b38840db0fd54/granian-2.7.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:34708b145e31b4538e0556704a07454a76d6776c55c5bc3a1335e80ef6b3bae3", size = 7062571, upload-time = "2026-02-24T23:03:27.278Z" }, - { url = "https://files.pythonhosted.org/packages/69/4a/8ce622f4f7d58e035d121b9957dd5a8929028dc99cfc5d2bf7f2aa28912c/granian-2.7.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:592806c28c491f9c1d1501bac706ecf5e72b73969f20f912678d53308786d658", size = 6442041, upload-time = "2026-02-24T23:03:30.986Z" }, - { url = "https://files.pythonhosted.org/packages/27/62/7d36ed38a40a68c2856b6d2a6fedd40833e7f82eb90ba0d03f2d69ffadf5/granian-2.7.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9dcde3968b921654bde999468e97d03031f28668bc1fc145c81d8bedb0fb2a4", size = 6100793, upload-time = "2026-02-24T23:03:32.734Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c5/17fea68f4cb280c217cbd65534664722c9c9b0138c2754e20c235d70b5f4/granian-2.7.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d4d78408283ec51f0fb00557856b4593947ad5b48287c04e1c22764a0ac28a5", size = 7119810, upload-time = "2026-02-24T23:03:34.807Z" }, - { url = "https://files.pythonhosted.org/packages/0a/76/35e240d107e0f158662652fd61191de4fb0c2c080e3786ca8f16c71547b7/granian-2.7.2-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66d28b078e8087f794b83822055f95caf93d83b23f47f4efcd5e2f0f7a5d8a81", size = 6450789, upload-time = "2026-02-24T23:03:36.81Z" }, - { url = "https://files.pythonhosted.org/packages/4c/55/a6d08cfecc808149a910e51c57883ab26fad69d922dc2e76fb2d87469e2d/granian-2.7.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ff7a93123ab339ba6cad51cc7141f8880ec47b152ce2491595bb08edda20106", size = 6902672, upload-time = "2026-02-24T23:03:38.655Z" }, - { url = "https://files.pythonhosted.org/packages/98/2e/c86d95f324248fcc5dcaf034c9f688b32f7a488f0b2a4a25e6673776107f/granian-2.7.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a52effb9889f0944f0353afd6ce5a9d9aa83826d44bbf3c8013e978a3d6ef7b7", size = 6964399, upload-time = "2026-02-24T23:03:40.459Z" }, - { url = "https://files.pythonhosted.org/packages/37/4b/44fde33fe10245a3fba76bf843c387fad2d548244345115b9d87e1c40994/granian-2.7.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:76c987c3ca78bf7666ab053c3ed7e3af405af91b2e5ce2f1cf92634c1581e238", size = 7034929, upload-time = "2026-02-24T23:03:42.149Z" }, - { url = "https://files.pythonhosted.org/packages/90/76/38d205cb527046241a9ee4f51048bf44101c626ad4d2af16dd9d14dc1db6/granian-2.7.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:6590f8092c2bb6614e561ba771f084cbf72ecbc38dbf9849762ac38718085c29", size = 7259609, upload-time = "2026-02-24T23:03:43.852Z" }, - { url = "https://files.pythonhosted.org/packages/00/37/04245c7259e65f1083ce193875c6c44da4c98604d3b00a264a74dd4f042b/granian-2.7.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7c1ce9b0c9446b680e9545e7fc95a75f0c53a25dedcf924b1750c3e5ba5bf908", size = 7073161, upload-time = "2026-02-24T23:03:45.655Z" }, - { url = "https://files.pythonhosted.org/packages/cc/07/0e56fb4f178e14b4c1fa1f6f00586ca81761ccbe2d8803f2c12b6b17a7d6/granian-2.7.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a698d9b662d5648c8ae3dc01ad01688e1a8afc3525e431e7cddb841c53e5e291", size = 6415279, upload-time = "2026-02-24T23:03:48.932Z" }, - { url = "https://files.pythonhosted.org/packages/27/bc/3e69305bf34806cd852f4683deec844a2cb9a4d8888d7f172b507f6080a8/granian-2.7.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:17516095b520b3c039ddbe41a6beb2c59d554b668cc229d36d82c93154a799af", size = 6090528, upload-time = "2026-02-24T23:03:50.52Z" }, - { url = "https://files.pythonhosted.org/packages/ec/10/7d58a922b44417a6207c0a3230b0841cd7385a36fc518ac15fed16ebf6f7/granian-2.7.2-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:96b0fd9eac60f939b3cbe44c8f32a42fdb7c1a1a9e07ca89e7795cdc7a606beb", size = 6252291, upload-time = "2026-02-24T23:03:52.248Z" }, - { url = "https://files.pythonhosted.org/packages/54/56/65776c6d759dcef9cce15bc11bdea2c64fe668088faf35d87916bd88f595/granian-2.7.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e50fb13e053384b8bd3823d4967606c6fd89f2b0d20e64de3ae212b85ffdfed2", size = 7106748, upload-time = "2026-02-24T23:03:53.994Z" }, - { url = "https://files.pythonhosted.org/packages/81/ee/d9ed836316607401f158ac264a3f770469d1b1edbf119402777a9eff1833/granian-2.7.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9bb1ef13125bc05ab2e18869ed311beaeb085a4c4c195d55d0865f5753a4c0b4", size = 6778883, upload-time = "2026-02-24T23:03:55.574Z" }, - { url = "https://files.pythonhosted.org/packages/a1/46/eabab80e07a14527c336dec6d902329399f3ba2b82dc94b6435651021359/granian-2.7.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b1c77189335070c6ba6b8d158518fde4c50f892753620f0b22a7552ad4347143", size = 6903426, upload-time = "2026-02-24T23:03:57.296Z" }, - { url = "https://files.pythonhosted.org/packages/24/8a/8ce186826066f6d453316229383a5be3b0b8a4130146c21f321ee64fe2cb/granian-2.7.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:1777166c3c853eed4440adb3cbbf34bba2b77d595bfc143a5826904a80b22f34", size = 7083877, upload-time = "2026-02-24T23:03:59.425Z" }, - { url = "https://files.pythonhosted.org/packages/cf/eb/91ed4646ce1c920ad39db0bcddb6f4755e1823002b14fb026104e3eb8bce/granian-2.7.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:0ffac19208ae548f3647c849579b803beaed2b50dfb0f3790ad26daac0033484", size = 7267282, upload-time = "2026-02-24T23:04:01.218Z" }, - { url = "https://files.pythonhosted.org/packages/49/2f/58cba479254530ab09132e150e4ab55362f6e875d9e82b6790477843e0aa/granian-2.7.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:82f34e78c1297bf5a1b6a5097e30428db98b59fce60a7387977b794855c0c3bc", size = 7054941, upload-time = "2026-02-24T23:04:03.211Z" }, - { url = "https://files.pythonhosted.org/packages/59/71/f21b26c7dc7a8bc9d8288552c9c12128e73f1c3f04799b6e28a0a269b9b0/granian-2.7.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5613ee8c1233a79e56e1735e19c8c70af22a8c6b5808d7c1423dc5387bee4c05", size = 6504773, upload-time = "2026-02-24T23:04:06.498Z" }, - { url = "https://files.pythonhosted.org/packages/6e/68/282fbf5418f9348f657f505dc744cdca70ac850d39a805b21395211bf099/granian-2.7.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0cd6fee79f585de2e1a90b6a311f62b3768c7cda649bc0e02908157ffa2553cc", size = 6138096, upload-time = "2026-02-24T23:04:09.138Z" }, - { url = "https://files.pythonhosted.org/packages/e7/e0/b578709020f84c07ad2ca88f77ac67fd2c62e6b16f93ff8c8d65b7d99296/granian-2.7.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e94c825f8b327114f7062d158c502a540ef5819f809e10158f0edddddaf41bb9", size = 6900043, upload-time = "2026-02-24T23:04:11.015Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2f/a2671cc160f29ccf8e605eb8fa113c01051b0d7947048c5b29eb4e603384/granian-2.7.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a6adea5fb8a537d18f3f2b848023151063bc45896415fdebfeb0bf0663d5a03b", size = 7040211, upload-time = "2026-02-24T23:04:13.31Z" }, - { url = "https://files.pythonhosted.org/packages/36/ce/df9bba3b211cda2d47535bb21bc040007e021e8c8adc20ce36619f903bc4/granian-2.7.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2392ab03cb92b1b2d4363f450b2d875177e10f0e22d67a4423052e6885e430f2", size = 7118085, upload-time = "2026-02-24T23:04:15.05Z" }, - { url = "https://files.pythonhosted.org/packages/a9/87/37124b2ee0cddce6ba438b0ff879ddae094ae2c92b24b28ffbe35110931f/granian-2.7.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:406c0bb1f5bf55c72cfbfdfd2ccec21299eb3f7b311d85c4889dde357fd36f33", size = 7314667, upload-time = "2026-02-24T23:04:16.783Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ac/8b142ed352bc525e3c97440aab312928beebc735927b0cf979692bfcda3b/granian-2.7.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:362a6001daa2ce62532a49df407fe545076052ef29289a76d5760064d820f48b", size = 7004934, upload-time = "2026-02-24T23:04:19.059Z" }, + { url = "https://files.pythonhosted.org/packages/df/00/a7db7e3627992c59927f57d5447638be515e683e2c8037ab7845250270d2/granian-2.7.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:455c51baf51dd0c3d22004fc04f9afb0662cb84ab2b75b48e5d6bb8b3e4e3548", size = 6353285, upload-time = "2026-04-23T11:53:50.113Z" }, + { url = "https://files.pythonhosted.org/packages/b2/23/337ab1a0929cb0cfbdedc06879cff62d6c08cb725fa2d4e139c7e305fed3/granian-2.7.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2c54f3fe69790aa4b685372bcc8f382a8e9ba570b8ea4cb476e3b240a5a5a7c", size = 6050711, upload-time = "2026-04-23T11:53:51.887Z" }, + { url = "https://files.pythonhosted.org/packages/28/ba/3605834adaf5dc9ac3701b817bc9d42c73c89fb67815c7c87c7f64a9b6e1/granian-2.7.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9549c44b325fe51ee4fc57308761f5178add4d531f1cc333b4a1eedf4a5b7af", size = 6882199, upload-time = "2026-04-23T11:53:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1e/f11c9773dbf07ed326efc26a771b39ce97f7ee25608c16d69248db2da8da/granian-2.7.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0910390ea8f893cc4c3f38a28c923a321609358cf46d31aa7df5c3d3e58e8337", size = 6135800, upload-time = "2026-04-23T11:53:55.186Z" }, + { url = "https://files.pythonhosted.org/packages/f1/96/ca238b4f5d813643264abace48ca630efb1ab6d10409bd9e2c05c1d1ef12/granian-2.7.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0de44552990b3dacb87ea3f37ebbcce67881712c0b0db500013821b14df7e4e", size = 6784786, upload-time = "2026-04-23T11:53:56.706Z" }, + { url = "https://files.pythonhosted.org/packages/ae/85/2feeffc37fe3c8a0f3e932393bcc99c8972984fe95907b34b380284caf1c/granian-2.7.4-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5c9c6d51a675d9b7084244e63157899dd1afe6f1a5ab014015bc86afd4871df5", size = 6906836, upload-time = "2026-04-23T11:53:58.991Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b6/faed26e3abd741e1d261defc0e7e3b2ecb9a2189c557e829bb28c3281456/granian-2.7.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6be8c6ebbc53efea03284aef87de9b7367df3c9433f7df3b46c1edceaaa9d840", size = 6929985, upload-time = "2026-04-23T11:54:00.846Z" }, + { url = "https://files.pythonhosted.org/packages/25/5c/9f7a836177b5e94ad15da49046254e5b837e1d2c3d03981111d4af9a9d2b/granian-2.7.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:c10e056a6e76da640adb35f88d41ba40ae44065c5e04d4bc35f47c19a7f83a99", size = 7050668, upload-time = "2026-04-23T11:54:02.466Z" }, + { url = "https://files.pythonhosted.org/packages/08/88/19c01761d639b5e2c2eb2f12ff064c6765f32fa7f129c9f48162cdca0668/granian-2.7.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e60a3153456f8922ca73d3a427cc3bb594c021f70ec08ecded6581efe25f48c", size = 6899480, upload-time = "2026-04-23T11:54:03.948Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d9/148024fd3a8bd974bb5c68a0cb48d15df7763fd1364bf090ccc2d423028a/granian-2.7.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2c2f40aaecf2ba3d8232e55181c8f6db7bc68d9112a419ab8d5f9e2f33f631f5", size = 6374067, upload-time = "2026-04-23T11:54:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bb/c53b61a7cb67d33677d96913438eca3d79de1b1b7173a361fcdf2753ade7/granian-2.7.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a8111d5e74b27721e0fdda3edba7c154d44c41b469466857ca3c51b088e3846b", size = 6046338, upload-time = "2026-04-23T11:54:08.684Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/5c9dc91b9c9a05bf6ed0b795d30f4bb8f290d61502779a89ed2fd75f9fb6/granian-2.7.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:74adbb6c1920dbf4271b824135639318b2a20ff5e33bc35639a8e2928a777234", size = 7000585, upload-time = "2026-04-23T11:54:10.451Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7c/c770593b24a472ab5265a44546f56079757efbf89f8e8b2229a8443e453b/granian-2.7.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b778d356b61e0389c823016ad2be50a634b80d3d28a33922f7ac39553e828ad", size = 6255544, upload-time = "2026-04-23T11:54:12.484Z" }, + { url = "https://files.pythonhosted.org/packages/15/46/796147587edb494a330294cb001cf68520ad8296a7da91d80ec672ac8615/granian-2.7.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3607b091c4ef225ee99150f3b02cb827de8d677b52fc75f0b28893244f7bab27", size = 6875124, upload-time = "2026-04-23T11:54:13.967Z" }, + { url = "https://files.pythonhosted.org/packages/c5/25/b867f624886e11053e7a6235244de26fd864a136e65d12295e728b3e5005/granian-2.7.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3d3cf4fe3cafd9b874d8b749c66c790cbf2b4225f2a7d9fb284c51b77a8e938d", size = 6982394, upload-time = "2026-04-23T11:54:15.733Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e1/5746bfe202bd2f6a1506346463ce52dd015c2b5d03d07a53ecf0fddefa3f/granian-2.7.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:846c9cbfea8684ab13d21d66855ad06dc077fb95b5590e7f5040e79994d6429d", size = 6991457, upload-time = "2026-04-23T11:54:17.325Z" }, + { url = "https://files.pythonhosted.org/packages/e0/45/fc6992839d367b6ae8fa8d88b5e70ec293162c3a2e0e6b90fc426f228df2/granian-2.7.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:d34d97cfe4a7805ecb5b1b1684f3f197bb4baf019d2a9f18e34fd1d697a03a7f", size = 7148499, upload-time = "2026-04-23T11:54:19.234Z" }, + { url = "https://files.pythonhosted.org/packages/fe/12/16ffd64a1213858d4cf824767b398758be807dd1a6df5a303dc76994b6d6/granian-2.7.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f11336e4bcd8ef5c5143b075b5260e37e8431eb36d68564cc39416ca526c797f", size = 7006829, upload-time = "2026-04-23T11:54:20.804Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0f/fa7c63afedcb214edb96703cade360d946d5f1ca59ddb0b3d8e04587fb45/granian-2.7.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d11da4a4527ba8dc28b5533d5e3241d8d9212e593195d27c6e72c8a422010af5", size = 6373513, upload-time = "2026-04-23T11:54:24.246Z" }, + { url = "https://files.pythonhosted.org/packages/be/39/3088ce32d940f7982102ea3bdc230090e34ac56dc0bce04f2d03b56ea435/granian-2.7.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:057a3db87e93eca1a11255dd13b45b5dd83f798a750fd87f02e14d54db5741b6", size = 6045232, upload-time = "2026-04-23T11:54:25.708Z" }, + { url = "https://files.pythonhosted.org/packages/ac/61/588f6b5397ea4f5bd9fc8de4b8cc092c555b8d95371c03d149b3bc419277/granian-2.7.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb63d64c686799cea850c0c328d21adf75e323991a20be04923afc729432d2b5", size = 7001059, upload-time = "2026-04-23T11:54:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/58/63/2affbcecfe96f940744c2086ea3793935d5f6898207590a579c92fc8588f/granian-2.7.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f406648c47569e983f0c58bd0853bac30a2bcdc6227428255ee5cc65a8ee62b6", size = 6255487, upload-time = "2026-04-23T11:54:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/87/ac/31f7155a467020e7640e91af15ca3a70b0e7da210de42e3d3344e5eba8d0/granian-2.7.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bd56306eed06e293f4848c5ea997e1d019d1ad13b8252dde1f0bc773aca85ef", size = 6875068, upload-time = "2026-04-23T11:54:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/99/22/402cc903e5c4e82bd363177392d4e1dcab8b27c1f7006c5316c37c597056/granian-2.7.4-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:732639e612e6b6e8d481f399f367e8c9bbb6f0e1b7b0aa74db340c574ee3dd98", size = 6982487, upload-time = "2026-04-23T11:54:32.704Z" }, + { url = "https://files.pythonhosted.org/packages/d3/92/3878f977bda82fc3a66fc7e95a54366a7b82edd53e6c9fdb3ec053693280/granian-2.7.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:47b8fdbfb369d52bb3fb884514a6a3a7e4d8e81c65fd26e5232985f2b46ebe0f", size = 6990683, upload-time = "2026-04-23T11:54:34.301Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b3/a1239f3bc4e9034e07cb32403e6a6d26db01bba1c244dd654f6a76bf2612/granian-2.7.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:b679086082bfd7c1aa8c248ef673b715616a4ce58eec6fbeef8b83b30ac84283", size = 7148570, upload-time = "2026-04-23T11:54:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/fef781ea7356b21f671615dd0d53adc00fad81031a9ea506f80d1f46a43d/granian-2.7.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a29191e949a99ffae2807abb7a864f7493f7a744e4fe2ddd2b5cd8db9b71378d", size = 7006976, upload-time = "2026-04-23T11:54:38.135Z" }, + { url = "https://files.pythonhosted.org/packages/21/51/10344430e495bfa128dccc114957b33e712e971f91668788c08fe791df73/granian-2.7.4-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:4e093fe9511387313ad7ec9a76b0c78397cc584ef3dff47d46c336c5aee9cd8d", size = 6249290, upload-time = "2026-04-23T11:54:41.738Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/c7eda2e71a89a13e174598649f721c63ed3d908c0904b62621e8a433af0f/granian-2.7.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:227889f821526b8b60c5edf31b01fc987c4193bb0fc198c0998e0841e0cb719c", size = 5901799, upload-time = "2026-04-23T11:54:43.708Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/79e51f9f794389a9d6cab3d7c6b834b87d65fba72a43784eb5d2664a57a6/granian-2.7.4-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2b28d4aec5a9f2758a48da1897649a01b70ee1c00f2c4649db574527a3d00943", size = 6037594, upload-time = "2026-04-23T11:54:45.595Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d8/835873a407279435fa0c8e8ac52392d3ba5c9a652bb15c0036aa07d9c302/granian-2.7.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f708fea5024a40e0dfba1c17c1c4b09e02e00ac0ac9ac1e345b409f0c11b71e5", size = 6966672, upload-time = "2026-04-23T11:54:47.242Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/21eacdda27c38e4194de5f9bef36c4045058daf6d58533fadb7c54c70573/granian-2.7.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7006dfe9852cded794bc60008a168faf4dc2ecc18f1d74b5fde545685b699ec", size = 6563668, upload-time = "2026-04-23T11:54:49.751Z" }, + { url = "https://files.pythonhosted.org/packages/bd/06/9b19956d75277df44ee380e873a86b9890c431f2e2bcde32b3ba341f0efa/granian-2.7.4-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:77103af44034e30505fb5577b8214b0ad39cd6cbdc854ff980d4755faf93adaa", size = 6664285, upload-time = "2026-04-23T11:54:51.502Z" }, + { url = "https://files.pythonhosted.org/packages/85/33/740e0c9478be49c0778c4ea1773357680980e10e84b59bc19664033996dc/granian-2.7.4-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b23194e1e0652297086224212605edb4998442511637e732d6009506277f8ff9", size = 6820367, upload-time = "2026-04-23T11:54:53.506Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/3453fc1212268a01fee957122f2b1699af0efe50eca07ac570e11d1be12b/granian-2.7.4-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:f62941a4ffa1f1c2c5750cfc0b0ad96aa85d63b016125289779eef8888f5340d", size = 7132366, upload-time = "2026-04-23T11:54:55.123Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ca/8479e4d2a02f210ce68b5dc73c77953ec1dfd3769bf725d06e6ec420d502/granian-2.7.4-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:ea6f97d2ade676f1bf49b79088fa4b5640b8b9804b7470218486df3d4be50046", size = 6842094, upload-time = "2026-04-23T11:54:56.665Z" }, + { url = "https://files.pythonhosted.org/packages/98/5d/a0c3d8778cd8aa68131974d34c439a38a00a32953e71e3b549759a5e3cdb/granian-2.7.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c19ebe797d7383cbb3497c599b8201af71f9fff6b18deaf9965d106f61588ab8", size = 6322736, upload-time = "2026-04-23T11:55:00.292Z" }, + { url = "https://files.pythonhosted.org/packages/5e/99/211da053030574f2402c750f3e3e5dc587f5192eac4888affe6ca8894a9f/granian-2.7.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4cee0bdba9179537669c2fa0afab2ce89327a372f1b2a82f280798da321c996c", size = 6052103, upload-time = "2026-04-23T11:55:02.797Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9d/23ec1fd519a4c0db961b05d1821869ed6371cbaf8b3d3a0a85c04f89e6ca/granian-2.7.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a4bc5b54845bfb5f87537483f25c8f8e6003c3c1b4b0eadf6b93a432d0604265", size = 7000868, upload-time = "2026-04-23T11:55:04.826Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/b8798c98c90d3293d9c85580ea6021f148d5ab73ab99d1f82a0e66f73131/granian-2.7.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b550fb98b89465c8192b6e506993de6bfb956838e715ffb58e944aec1afdae99", size = 6257266, upload-time = "2026-04-23T11:55:06.962Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4f/5574db17193d90a5831120a0ce2a2dc64a711110ccb9af5a3630260c3597/granian-2.7.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7100a6a6d3835fec2a207fef536a259dd42d9efdb5c46933cf6f9d55d5bfaad", size = 6849667, upload-time = "2026-04-23T11:55:08.862Z" }, + { url = "https://files.pythonhosted.org/packages/66/a7/90b85cc6a31cbee772fc8ee731479429a64169e389444a5fdd685d44a342/granian-2.7.4-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:034ac1bfe8c19b5a7916d35a1ca426845db9ac11215f1b367566aec3b6530549", size = 6902612, upload-time = "2026-04-23T11:55:10.888Z" }, + { url = "https://files.pythonhosted.org/packages/06/6c/ba203ca40bd406db0412bca70281e44712f941bc6aafb59a628f4811d517/granian-2.7.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:baf1c390a25d3d9840204c39e7b801c909e99e896ae2713d898c46b563cbf962", size = 6927025, upload-time = "2026-04-23T11:55:12.663Z" }, + { url = "https://files.pythonhosted.org/packages/ee/52/77e2abfba54523943eea275ebbe733a6d186fe646304fe25f6d22b243d03/granian-2.7.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:3bb99778ae05c1118cd694717d025cc0b85f5ee81f60cbcb2a8783692798db96", size = 7146800, upload-time = "2026-04-23T11:55:14.459Z" }, + { url = "https://files.pythonhosted.org/packages/1d/66/7209201856b7de8d3c643ba87e11272c4d651c216d05ea3fcbdce0da4ab0/granian-2.7.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:13f0a39872afa81c6aaa8e29832371fd831373140f1f04de459ff862824f488b", size = 6999983, upload-time = "2026-04-23T11:55:16.236Z" }, + { url = "https://files.pythonhosted.org/packages/45/a2/609f8f0dca7f596b5fb6e57b988b4b8f4d6579724b2720933c379d43301a/granian-2.7.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7b1aca6c654f0e61c9e493dd6d3ddb1698f47dc33ed04566a6635948b081b64", size = 6251034, upload-time = "2026-04-23T11:55:19.29Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f5/2eefa8ff477cce7b119ed2fe97fc1f3b2d108397d4755e83a5198149f2c8/granian-2.7.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4e0c8cc6850dec7180a26b6805b2c4cdbac4c1c48077fd7857a3cd8ff342d9d", size = 5912772, upload-time = "2026-04-23T11:55:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/ae/40/9a5070badaed4ceecf4082855985840c320f7232b8c1ddc93e1732c63265/granian-2.7.4-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7e6b1f6e0fe873efa3393ef28803ff699a94254f2a7dc07422cc01d9849e2136", size = 6037318, upload-time = "2026-04-23T11:55:23.855Z" }, + { url = "https://files.pythonhosted.org/packages/95/52/1db412e63425cb12f5ca61877956583c6d12f21657b1a3e47eb3200e9c1b/granian-2.7.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dce110217825cff60f68da83280bc20471b10e004e720fa94b845e01925d8698", size = 6962778, upload-time = "2026-04-23T11:55:26.095Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/fcca39f617bf70e29ef903bb7a4d037970c637023484f2112d9ed6882516/granian-2.7.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:058f9a4ebfc7b9c2577569c6ecfd333628d0d045de272afaa65ee9933849778c", size = 6566618, upload-time = "2026-04-23T11:55:28.233Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/0da1bb552746d74275017e1ffc7fc419dd1a33345f132f6f5a90f9f41142/granian-2.7.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:7c05f74fa5b5dcedc9f035a7c10b8afd90a3d941975a370f1e07c3f3095dd883", size = 6670850, upload-time = "2026-04-23T11:55:29.945Z" }, + { url = "https://files.pythonhosted.org/packages/11/2a/d0d9cdb10d2760e2f47bd4600c8eef02e326f8f7e253a80ce4ba384265e6/granian-2.7.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:8b992bbc667e3c74de4ad48ac8d735c7cddf3f709fc2097f7dd230ecc46fd7b3", size = 6824752, upload-time = "2026-04-23T11:55:32.066Z" }, + { url = "https://files.pythonhosted.org/packages/3d/79/0432f92f9df6e54394e4dd1c159c0d4814d255a2d2541fa9a5c187d19152/granian-2.7.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:df05e0f85712b3e90ddf28cb8be358664b1afa8cb8f09978141ca70052dca3a7", size = 7130809, upload-time = "2026-04-23T11:55:33.807Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/11cc0e08f59f03a3cd6a1fe46d7632a0f8690ef945a495b1303140bb7541/granian-2.7.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:dbc620f35b67cf6b03d2b6a24b9b442d1bf52961eaebadb2c3ff214d3d0c8dc4", size = 6845920, upload-time = "2026-04-23T11:55:35.583Z" }, + { url = "https://files.pythonhosted.org/packages/51/18/577637bb861ab688db8eb5d698ad700133818debd7ae6f58c0574c43f70e/granian-2.7.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ce50300cf876f418ba0545f6e8c56d8c75038fc503add0fd1b58d9a3057d95ea", size = 6363314, upload-time = "2026-04-23T11:55:39.837Z" }, + { url = "https://files.pythonhosted.org/packages/48/41/11a6219baa10270f1a6a2a101cfa372e5d55a46a839a43b49a8d087fac09/granian-2.7.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eb7f727f14d7d485a5df4078e7cc3038864b4e7c380865968e75e1e51e62457a", size = 6027259, upload-time = "2026-04-23T11:55:42.122Z" }, + { url = "https://files.pythonhosted.org/packages/bf/58/12b19b17fb79ee064a8a77a865a031bb49f4ea813789ad63186458ea02c9/granian-2.7.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abbab303b502a770355c13c93569e6c0c71ccc864ab41b59636720d5a643f6b3", size = 6760497, upload-time = "2026-04-23T11:55:44.116Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/572711f882423f599707aae577ccdbc1700cf0cc3ceb4e9500e00c6b8d14/granian-2.7.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:f0b0423fa33a1afb9730fbfb5700fef4dac16bf7a1b7a2a79d0349739c1b1f44", size = 6843897, upload-time = "2026-04-23T11:55:46.322Z" }, + { url = "https://files.pythonhosted.org/packages/31/f9/75d51721069a184cd00310c4b0b0d614a6370905c13a096ccee193432ba3/granian-2.7.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:efa0d4fc35ab42562747e4103124e1c4f21afab081c1591de6472174a3416802", size = 6876194, upload-time = "2026-04-23T11:55:48.231Z" }, + { url = "https://files.pythonhosted.org/packages/44/5e/fd81492529bc2b02dafc63c95d03c2c7faa26ac883ccd94aa93b21fc68c3/granian-2.7.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:03b5ce06df095b5db49bd4e976ac8d8419bb0e73dc160613fc3db5e5d5dcd1af", size = 7094066, upload-time = "2026-04-23T11:55:50.328Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/f2fa35dc2956edb9a5abaabc0840aed92b4121ce27adf684a1c75e3c70ac/granian-2.7.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:9247db25dd66f74766a6a9488f1279c9b40cf422c6d7a04010492fa1aa7c9019", size = 6892580, upload-time = "2026-04-23T11:55:52.084Z" }, ] [package.optional-dependencies] @@ -3029,7 +3029,7 @@ requires-dist = [ { name = "concurrent-log-handler", specifier = "~=0.9.25" }, { name = "dateparser", specifier = "~=1.2" }, { name = "django", specifier = "~=5.2.13" }, - { name = "django-allauth", extras = ["mfa", "socialaccount"], specifier = "~=65.15.0" }, + { name = "django-allauth", extras = ["mfa", "socialaccount"], specifier = "~=65.16.0" }, { name = "django-auditlog", specifier = "~=3.4.1" }, { name = "django-cachalot", specifier = "~=2.9.0" }, { name = "django-compression-middleware", specifier = "~=0.5.0" }, @@ -3044,7 +3044,7 @@ requires-dist = [ { name = "djangorestframework", specifier = "~=3.16" }, { name = "djangorestframework-guardian", specifier = "~=0.4.0" }, { name = "drf-spectacular", specifier = "~=0.28" }, - { name = "drf-spectacular-sidecar", specifier = "~=2026.4.14" }, + { name = "drf-spectacular-sidecar", specifier = "~=2026.5.1" }, { name = "drf-writable-nested", specifier = "~=0.7.1" }, { name = "faiss-cpu", specifier = ">=1.10" }, { name = "filelock", specifier = "~=3.29.0" }, @@ -3735,15 +3735,15 @@ wheels = [ [[package]] name = "pyopenssl" -version = "26.0.0" +version = "26.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" }, + { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, ] [[package]] From af0df43bac6640e9db1fe048235b7b26da3744a9 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 7 May 2026 07:39:57 -0700 Subject: [PATCH 09/53] Fix: bump version.py to 3.0.0 also (#12736) --- docs/changelog.md | 2 +- src/paperless/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 16f66e4be..8ec847ae5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,6 @@ # Changelog -## paperless-ngx 3.0.0 +## paperless-ngx 2.20.15 ### Security diff --git a/src/paperless/version.py b/src/paperless/version.py index bb4d12607..825857097 100644 --- a/src/paperless/version.py +++ b/src/paperless/version.py @@ -1,6 +1,6 @@ from typing import Final -__version__: Final[tuple[int, int, int]] = (2, 20, 15) +__version__: Final[tuple[int, int, int]] = (3, 0, 0) # Version string like X.Y.Z __full_version_str__: Final[str] = ".".join(map(str, __version__)) # Version string like X.Y From f985f7db51cb3832b2fd609d22b0a7dbed1f4295 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Thu, 7 May 2026 09:20:04 -0700 Subject: [PATCH 10/53] Fix: Celery chords by using Redis as our result backend (#12741) --- src/paperless/settings/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index db57173cd..d82694dd8 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -650,6 +650,11 @@ logging.config.dictConfig(LOGGING) # https://docs.celeryq.dev/en/stable/userguide/configuration.html CELERY_BROKER_URL = _CELERY_REDIS_URL +CELERY_RESULT_BACKEND = _CELERY_REDIS_URL +CELERY_RESULT_SERIALIZER = "signed-pickle" +# Results are only needed for chord synchronization +# a short TTL avoids Redis memory accumulation. +CELERY_RESULT_EXPIRES = 3600 CELERY_TIMEZONE = TIME_ZONE CELERY_WORKER_HIJACK_ROOT_LOGGER = False From 3097f061897bc1531ab03e07969411ddc1464ed7 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 7 May 2026 09:34:26 -0700 Subject: [PATCH 11/53] Fix: exclude versions from stats count (#12738) --- src/documents/tests/test_api_documents.py | 29 +++++++++++++++++++++++ src/documents/views.py | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index e7892072a..34c501192 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -1305,6 +1305,35 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase): self.assertEqual(response.data["document_type_count"], 1) self.assertEqual(response.data["storage_path_count"], 2) + def test_statistics_excludes_document_versions(self) -> None: + root = Document.objects.create( + title="root", + checksum="A", + mime_type="application/pdf", + content="root", + ) + version = Document.objects.create( + title="version", + checksum="B", + mime_type="application/pdf", + content="version", + root_document=root, + version_index=1, + ) + tag_inbox = Tag.objects.create(name="t1", is_inbox_tag=True) + version.tags.add(tag_inbox) + + response = self.client.get("/api/statistics/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["documents_total"], 1) + self.assertEqual(response.data["documents_inbox"], 0) + self.assertEqual(response.data["character_count"], 4) + self.assertEqual( + response.data["document_file_type_counts"][0]["mime_type_count"], + 1, + ) + def test_statistics_no_inbox_tag(self) -> None: Document.objects.create(title="none1", checksum="A") diff --git a/src/documents/views.py b/src/documents/views.py index fde9a9106..a60bbc30e 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -3614,7 +3614,7 @@ class StatisticsView(GenericAPIView[Any]): "documents.view_document", Document, ) - ) + ).filter(root_document__isnull=True) tags = ( Tag.objects.all() if can_view_global_stats From 2354f87a4096c0adc6e2dad1d9ae4ae7823d3eec Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Thu, 7 May 2026 10:07:35 -0700 Subject: [PATCH 12/53] Fixes trash preview when a document has deleted versions (#12742) --- src/documents/tests/test_api_documents.py | 36 +++++++++++++++++++++++ src/documents/views.py | 4 +-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index 34c501192..98908b80e 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -485,6 +485,42 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase): response = self.client.get(f"/api/documents/{doc.pk}/thumb/") self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + def test_document_actions_trashed_document(self) -> None: + """ + GIVEN: + - Document with files exists + WHEN: + - Document is soft-deleted (moved to trash) + - Preview and thumb endpoints are requested + THEN: + - HTTP 200 OK for both (trashed documents remain previewable) + """ + _, filename = tempfile.mkstemp(dir=self.dirs.originals_dir) + content = b"This is a test" + content_thumbnail = b"thumbnail content" + + with Path(filename).open("wb") as f: + f.write(content) + + doc = Document.objects.create( + title="none", + filename=Path(filename).name, + mime_type="application/pdf", + ) + + with (self.dirs.thumbnail_dir / f"{doc.pk:07d}.webp").open("wb") as f: + f.write(content_thumbnail) + + doc.delete() + + response = self.client.get(f"/api/documents/{doc.pk}/preview/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(read_streaming_response(response), content) + + response = self.client.get(f"/api/documents/{doc.pk}/thumb/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(read_streaming_response(response), content_thumbnail) + def test_document_history_action(self) -> None: """ GIVEN: diff --git a/src/documents/views.py b/src/documents/views.py index a60bbc30e..59c3399fc 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -1542,7 +1542,7 @@ class DocumentViewSet( condition(etag_func=preview_etag, last_modified_func=preview_last_modified), ) def preview(self, request, pk=None): - resolved = self._resolve_request_and_root_doc(pk, request) + resolved = self._resolve_request_and_root_doc(pk, request, include_deleted=True) if isinstance(resolved, HttpResponseForbidden): return resolved @@ -1566,7 +1566,7 @@ class DocumentViewSet( @method_decorator(cache_control(no_cache=True)) @method_decorator(last_modified(thumbnail_last_modified)) def thumb(self, request, pk=None): - resolved = self._resolve_request_and_root_doc(pk, request) + resolved = self._resolve_request_and_root_doc(pk, request, include_deleted=True) if isinstance(resolved, HttpResponseForbidden): return resolved From 9a1e2aea5030363343a113c9f76f9b0b282d8284 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Thu, 7 May 2026 10:26:11 -0700 Subject: [PATCH 13/53] Fix: Handle dash or plus operators in search queries (#12734) --- .github/workflows/ci-backend.yml | 1 + src/documents/search/_query.py | 14 ++++- src/documents/tests/search/test_query.py | 74 ++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml index d7efc62a4..5ab863063 100644 --- a/.github/workflows/ci-backend.yml +++ b/.github/workflows/ci-backend.yml @@ -106,6 +106,7 @@ jobs: enable-cache: true python-version: ${{ steps.setup-python.outputs.python-version }} - name: Install system dependencies + timeout-minutes: 10 run: | sudo apt-get update -qq sudo apt-get install -qq --no-install-recommends \ diff --git a/src/documents/search/_query.py b/src/documents/search/_query.py index 65df260c3..04d79d1ef 100644 --- a/src/documents/search/_query.py +++ b/src/documents/search/_query.py @@ -76,6 +76,11 @@ _YEAR_RANGE_RE = regex.compile( regex.IGNORECASE, ) _SIMPLE_QUERY_TOKEN_RE = regex.compile(r"\S+") +# Tantivy syntax error: " - " and " + " with spaces on both sides are invalid because +# the NOT/MUST operators require no space between the operator and the term. +# In natural-language queries (e.g., "H52.1 - Kurzsichtigkeit"), the dash is a separator. +_SPACED_OPERATOR_RE = regex.compile(r"\s+[-+]\s+") +_TRAILING_OPERATOR_RE = regex.compile(r"\s+[-+]+\s*$") def _fmt(dt: datetime) -> str: @@ -430,7 +435,14 @@ def normalize_query(query: str) -> str: query, timeout=_REGEX_TIMEOUT, ) - return regex.sub(r" {2,}", " ", query, timeout=_REGEX_TIMEOUT).strip() + query = regex.sub(r" {2,}", " ", query, timeout=_REGEX_TIMEOUT).strip() + # Strip trailing dangling operators before Tantivy sees them. + query = _TRAILING_OPERATOR_RE.sub("", query, timeout=_REGEX_TIMEOUT).strip() + # Replace " - " / " + " with a space: Tantivy requires no space between + # the operator and its operand (-term / +term), so spaces on both sides + # means this is a natural-language separator, not a query operator. + query = _SPACED_OPERATOR_RE.sub(" ", query, timeout=_REGEX_TIMEOUT).strip() + return query except TimeoutError: # pragma: no cover raise ValueError("Query too complex to process (normalization timed out)") diff --git a/src/documents/tests/search/test_query.py b/src/documents/tests/search/test_query.py index 11297eb92..0db224c5d 100644 --- a/src/documents/tests/search/test_query.py +++ b/src/documents/tests/search/test_query.py @@ -443,6 +443,25 @@ class TestParseUserQuery: q = parse_user_query(query_index, "created:today", UTC) assert isinstance(q, tantivy.Query) + @pytest.mark.parametrize( + "raw_query", + [ + pytest.param("h52.1 - kurzsichtigkeit", id="icd_code_dash_description"), + pytest.param("H52.1 - asd", id="icd_code_uppercase"), + pytest.param("h52.1 -", id="trailing_minus"), + pytest.param(". -", id="dot_trailing_minus"), + pytest.param("h52. -", id="partial_code_trailing_minus"), + pytest.param(".12 -", id="dot_number_trailing_minus"), + pytest.param("h52.1 - ku", id="partial_word_after_dash"), + ], + ) + def test_spaced_dash_queries_do_not_raise( + self, + query_index: tantivy.Index, + raw_query: str, + ) -> None: + assert isinstance(parse_user_query(query_index, raw_query, UTC), tantivy.Query) + class TestYearRangeRewriting: """Whoosh-style year-only date ranges must be rewritten to ISO 8601.""" @@ -548,6 +567,61 @@ class TestNormalizeQuery: def test_normalize_no_commas_unchanged(self) -> None: assert normalize_query("bank statement") == "bank statement" + @pytest.mark.parametrize( + ("raw", "expected"), + [ + pytest.param( + "h52.1 - kurzsichtigkeit", + "h52.1 kurzsichtigkeit", + id="icd_code_dash_description", + ), + pytest.param( + "H52.1 - asd", + "H52.1 asd", + id="icd_code_uppercase_dash", + ), + pytest.param( + "h52.1 -", + "h52.1", + id="trailing_minus", + ), + pytest.param( + ". -", + ".", + id="dot_trailing_minus", + ), + pytest.param( + "h52. -", + "h52.", + id="partial_code_trailing_minus", + ), + pytest.param( + "foo - bar - baz", + "foo bar baz", + id="multiple_dashes", + ), + pytest.param( + "foo + bar", + "foo bar", + id="spaced_plus_operator", + ), + ], + ) + def test_normalize_strips_dangling_operators(self, raw: str, expected: str) -> None: + assert normalize_query(raw) == expected + + @pytest.mark.parametrize( + "query", + [ + pytest.param("term -other", id="adjacent_not_operator"), + pytest.param("-term", id="leading_not_operator"), + pytest.param("+term", id="leading_must_operator"), + pytest.param("foo -bar +baz", id="mixed_adjacent_operators"), + ], + ) + def test_normalize_preserves_valid_operators(self, query: str) -> None: + assert normalize_query(query) == query + class TestPermissionFilter: """ From 268ded92bc75bda84b70b2e4a6026a5cbe9dc0c1 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 8 May 2026 08:19:15 -0700 Subject: [PATCH 14/53] Documentation: Update v3 migration docs (#12752) --- docs/migration-v3.md | 8 ++++++++ src/paperless/checks.py | 10 +++++----- src/paperless/tests/test_checks.py | 6 +++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/migration-v3.md b/docs/migration-v3.md index 1cedfaa28..e17dd3b65 100644 --- a/docs/migration-v3.md +++ b/docs/migration-v3.md @@ -1,5 +1,9 @@ # v3 Migration Guide +## Pre-Requisites + +Upgrading to Paperless-ngx v3 can only be performed from version 2.20.15. If you are running an older version, please upgrade to v2.20.15 before proceeding with the v3 upgrade. + ## Secret Key is Now Required The `PAPERLESS_SECRET_KEY` environment variable is now required. This is a critical security setting used for cryptographic signing and should be set to a long, random value. @@ -37,6 +41,10 @@ separating the directory ignore from the file ignore. | `CONSUMER_IGNORE_PATTERNS` | [`CONSUMER_IGNORE_PATTERNS`](configuration.md#PAPERLESS_CONSUMER_IGNORE_PATTERNS) | **Now regex, not fnmatch**; user patterns are added to (not replacing) default ones | | _New_ | [`CONSUMER_IGNORE_DIRS`](configuration.md#PAPERLESS_CONSUMER_IGNORE_DIRS) | Additional directories to ignore; user entries are added to (not replacing) defaults | +## Duplicate Handling Changes + +Paperless-ngx v3 no longer rejects duplicate documents by default. Instead, it now allows duplicates but adds a way to identify them via the UI. To (re-)enable duplicate rejection, set `PAPERLESS_CONSUMER_DELETE_DUPLICATES=true` in your environment. + ## Encryption Support Document and thumbnail encryption is no longer supported. This was previously deprecated in [paperless-ng 0.9.3](https://github.com/paperless-ngx/paperless-ngx/blob/dev/docs/changelog.md#paperless-ng-093) diff --git a/src/paperless/checks.py b/src/paperless/checks.py index adf8b9f07..4d8646112 100644 --- a/src/paperless/checks.py +++ b/src/paperless/checks.py @@ -202,10 +202,10 @@ def check_v3_minimum_upgrade_version( **kwargs: object, ) -> list[Error]: """ - Enforce that upgrades to v3 must start from v2.20.10. + Enforce that upgrades to v3 must start from v2.20.15. v3 squashes all prior migrations into 0001_squashed and 0002_squashed. - If a user skips v2.20.10, the data migration in 1075_workflowaction_order + If a user skips v2.20.15, the data migration in 1075_workflowaction_order never runs and the squash may apply schema changes against an incomplete database state. """ @@ -232,7 +232,7 @@ def check_v3_minimum_upgrade_version( if {"0001_squashed", "0002_squashed"} & applied: return [] - # On v2.20.10 exactly — squash will pick up cleanly from here + # On v2.20.15 exactly — squash will pick up cleanly from here if "1075_workflowaction_order" in applied: return [] @@ -243,8 +243,8 @@ def check_v3_minimum_upgrade_version( Error( "Cannot upgrade to Paperless-ngx v3 from this version.", hint=( - "Upgrading to v3 can only be performed from v2.20.10." - "Please upgrade to v2.20.10, run migrations, then upgrade to v3." + "Upgrading to v3 can only be performed from v2.20.15." + "Please upgrade to v2.20.15, run migrations, then upgrade to v3." "See https://docs.paperless-ngx.com/setup/#upgrading for details." ), id="paperless.E002", diff --git a/src/paperless/tests/test_checks.py b/src/paperless/tests/test_checks.py index c4935c1f1..e5fef4629 100644 --- a/src/paperless/tests/test_checks.py +++ b/src/paperless/tests/test_checks.py @@ -584,11 +584,11 @@ class TestV3MinimumUpgradeVersionCheck: ) -> None: """ GIVEN: - - DB is on an old v2 version (pre-v2.20.10) + - DB is on an old v2 version (pre-v2.20.15) WHEN: - The v3 upgrade check runs THEN: - - The error hint explicitly references v2.20.10 so users know what to do + - The error hint explicitly references v2.20.15 so users know what to do """ mocker.patch.dict( "paperless.checks.connections", @@ -596,7 +596,7 @@ class TestV3MinimumUpgradeVersionCheck: ) result = check_v3_minimum_upgrade_version(None) assert len(result) == 1 - assert "v2.20.10" in result[0].hint + assert "v2.20.15" in result[0].hint def test_db_error_is_swallowed(self, mocker: MockerFixture) -> None: """ From 978e54ab52519b10131a57b7f7afe7e06cc70c2b Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 8 May 2026 08:26:37 -0700 Subject: [PATCH 15/53] Fixhancement: version-aware thumbnail etag (#12754) --- src/documents/conditionals.py | 11 ++++++ .../tests/test_api_document_versions.py | 34 +++++++++++++++++++ .../tests/test_version_conditionals.py | 2 ++ src/documents/views.py | 9 +++-- 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/documents/conditionals.py b/src/documents/conditionals.py index fa10ff58a..82114e8d6 100644 --- a/src/documents/conditionals.py +++ b/src/documents/conditionals.py @@ -117,6 +117,17 @@ def preview_last_modified(request, pk: int) -> datetime | None: return doc.modified +def thumbnail_etag(request: Any, pk: int) -> str | None: + """ + Thumbnails are version-dependent, so use the effective document checksum as + the ETag to invalidate cache when the latest version changes. + """ + doc = resolve_effective_document_by_pk(pk, request).document + if doc is None: + return None + return doc.checksum + + def thumbnail_last_modified(request: Any, pk: int) -> datetime | None: """ Returns the filesystem last modified either from cache or from filesystem. diff --git a/src/documents/tests/test_api_document_versions.py b/src/documents/tests/test_api_document_versions.py index 848c6ec21..a81b9d545 100644 --- a/src/documents/tests/test_api_document_versions.py +++ b/src/documents/tests/test_api_document_versions.py @@ -464,6 +464,40 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase): self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(read_streaming_response(resp), b"thumb") + def test_thumb_etag_changes_when_latest_version_is_deleted(self) -> None: + root = self._create_pdf(title="root", checksum="root") + v1 = self._create_pdf( + title="v1", + checksum="v1", + root_document=root, + ) + v2 = self._create_pdf( + title="v2", + checksum="v2", + root_document=root, + ) + self._write_file(v1.thumbnail_path, b"thumb-v1") + self._write_file(v2.thumbnail_path, b"thumb-v2") + + resp = self.client.get(f"/api/documents/{root.id}/thumb/") + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(read_streaming_response(resp), b"thumb-v2") + self.assertEqual(resp.headers["ETag"], '"v2"') + + with mock.patch("documents.search.get_backend"): + delete_resp = self.client.delete( + f"/api/documents/{root.id}/versions/{v2.id}/", + ) + self.assertEqual(delete_resp.status_code, status.HTTP_200_OK) + + resp = self.client.get( + f"/api/documents/{root.id}/thumb/", + HTTP_IF_NONE_MATCH='"v2"', + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.headers["ETag"], '"v1"') + self.assertEqual(read_streaming_response(resp), b"thumb-v1") + def test_metadata_version_param_uses_version(self) -> None: root = Document.objects.create( title="root", diff --git a/src/documents/tests/test_version_conditionals.py b/src/documents/tests/test_version_conditionals.py index fd24a2a51..81218ed4c 100644 --- a/src/documents/tests/test_version_conditionals.py +++ b/src/documents/tests/test_version_conditionals.py @@ -5,6 +5,7 @@ from django.test import TestCase from documents.conditionals import metadata_etag from documents.conditionals import preview_etag +from documents.conditionals import thumbnail_etag from documents.conditionals import thumbnail_last_modified from documents.models import Document from documents.tests.utils import DirectoriesMixin @@ -30,6 +31,7 @@ class TestConditionals(DirectoriesMixin, TestCase): self.assertEqual(metadata_etag(request, root.id), latest.checksum) self.assertEqual(preview_etag(request, root.id), latest.archive_checksum) + self.assertEqual(thumbnail_etag(request, root.id), latest.checksum) def test_resolve_effective_doc_returns_none_for_invalid_or_unrelated_version( self, diff --git a/src/documents/views.py b/src/documents/views.py index 59c3399fc..37dad973e 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -67,7 +67,6 @@ from django.views import View from django.views.decorators.cache import cache_control from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import condition -from django.views.decorators.http import last_modified from django.views.generic import TemplateView from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.openapi import AutoSchema @@ -124,6 +123,7 @@ from documents.conditionals import preview_etag from documents.conditionals import preview_last_modified from documents.conditionals import suggestions_etag from documents.conditionals import suggestions_last_modified +from documents.conditionals import thumbnail_etag from documents.conditionals import thumbnail_last_modified from documents.data_models import ConsumableDocument from documents.data_models import DocumentMetadataOverrides @@ -1564,7 +1564,12 @@ class DocumentViewSet( @action(methods=["get"], detail=True, filter_backends=[]) @method_decorator(cache_control(no_cache=True)) - @method_decorator(last_modified(thumbnail_last_modified)) + @method_decorator( + condition( + etag_func=thumbnail_etag, + last_modified_func=thumbnail_last_modified, + ), + ) def thumb(self, request, pk=None): resolved = self._resolve_request_and_root_doc(pk, request, include_deleted=True) if isinstance(resolved, HttpResponseForbidden): From 8769dc894e748e65b22f04661c1af8d6df08777a Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 8 May 2026 08:36:07 -0700 Subject: [PATCH 16/53] Fix: only update modified field in notes actions (#12750) --- src/documents/tests/test_api_documents.py | 40 +++++++++++++++++++++++ src/documents/views.py | 4 +-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index 98908b80e..b0ec51d68 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -3112,6 +3112,46 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase): # modified was updated to today self.assertEqual(doc.modified.day, timezone.now().day) + def test_create_note_only_saves_document_modified_field(self) -> None: + """ + GIVEN: + - Existing document with a created date + WHEN: + - API request is made to add a note + THEN: + - Only the document modified field is persisted by the note endpoint + - Other document fields are not rewritten by the note endpoint + """ + doc = Document.objects.create( + title="test", + mime_type="application/pdf", + content="this is a document which will have notes added", + created=datetime.date(2026, 3, 31), + ) + original_save = Document.save + + with mock.patch.object( + Document, + "save", + autospec=True, + side_effect=original_save, + ) as save_mock: + resp = self.client.post( + f"/api/documents/{doc.pk}/notes/", + data={"note": "this is a posted note"}, + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + doc.refresh_from_db() + self.assertEqual(doc.created, datetime.date(2026, 3, 31)) + self.assertTrue( + any( + call.kwargs.get("update_fields") == ["modified"] + for call in save_mock.call_args_list + if call.args and call.args[0].pk == doc.pk + ), + ) + def test_notes_permissions_aware(self) -> None: """ GIVEN: diff --git a/src/documents/views.py b/src/documents/views.py index 37dad973e..d93c23c16 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -1658,7 +1658,7 @@ class DocumentViewSet( ) doc.modified = timezone.now() - doc.save() + doc.save(update_fields=["modified"]) from documents.search import get_backend @@ -1702,7 +1702,7 @@ class DocumentViewSet( note.delete() doc.modified = timezone.now() - doc.save() + doc.save(update_fields=["modified"]) from documents.search import get_backend From 57b91ad2cf1f4059239a4acb009e3421be283921 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 8 May 2026 13:01:44 -0700 Subject: [PATCH 17/53] Fix: use response synthesizer for RAG doc chat (#12751) --- src/paperless_ai/chat.py | 65 +++++++--------------- src/paperless_ai/indexing.py | 37 +++++++++--- src/paperless_ai/tests/test_ai_indexing.py | 18 ++++++ src/paperless_ai/tests/test_chat.py | 4 +- 4 files changed, 69 insertions(+), 55 deletions(-) diff --git a/src/paperless_ai/chat.py b/src/paperless_ai/chat.py index f149a5fc5..40c901db7 100644 --- a/src/paperless_ai/chat.py +++ b/src/paperless_ai/chat.py @@ -4,14 +4,14 @@ import sys from documents.models import Document from paperless_ai.client import AIClient +from paperless_ai.indexing import get_rag_prompt_helper from paperless_ai.indexing import load_or_build_index logger = logging.getLogger("paperless_ai.chat") -MAX_SINGLE_DOC_CONTEXT_CHARS = 15000 -SINGLE_DOC_SNIPPET_CHARS = 800 CHAT_METADATA_DELIMITER = "\n\n__PAPERLESS_CHAT_METADATA__" MAX_CHAT_REFERENCES = 3 +CHAT_RETRIEVER_TOP_K = 5 CHAT_PROMPT_TMPL = """Context information is below. --------------------- @@ -89,66 +89,39 @@ def stream_chat_with_documents(query_str: str, documents: list[Document]): from llama_index.core import VectorStoreIndex from llama_index.core.prompts import PromptTemplate from llama_index.core.query_engine import RetrieverQueryEngine + from llama_index.core.response_synthesizers import get_response_synthesizer local_index = VectorStoreIndex(nodes=nodes) retriever = local_index.as_retriever( - similarity_top_k=3 if len(documents) == 1 else 5, + similarity_top_k=CHAT_RETRIEVER_TOP_K, ) - if len(documents) == 1: - # Just one doc — provide full content - doc = documents[0] - references = [_build_document_reference(doc)] - # TODO: include document metadata in the context - content = doc.content or "" - context_body = content + top_nodes = retriever.retrieve(query_str) + if len(top_nodes) == 0: + logger.warning("Retriever returned no nodes for the given documents.") + yield "Sorry, I couldn't find any content to answer your question." + return - if len(content) > MAX_SINGLE_DOC_CONTEXT_CHARS: - logger.info( - "Truncating single-document context from %s to %s characters", - len(content), - MAX_SINGLE_DOC_CONTEXT_CHARS, - ) - context_body = content[:MAX_SINGLE_DOC_CONTEXT_CHARS] - - top_nodes = retriever.retrieve(query_str) - if len(top_nodes) > 0: - snippets = "\n\n".join( - f"TITLE: {node.metadata.get('title')}\n{node.text[:SINGLE_DOC_SNIPPET_CHARS]}" - for node in top_nodes - ) - context_body = f"{context_body}\n\nTOP MATCHES:\n{snippets}" - - context = f"TITLE: {doc.title or doc.filename}\n{context_body}" - else: - top_nodes = retriever.retrieve(query_str) - - if len(top_nodes) == 0: - logger.warning("Retriever returned no nodes for the given documents.") - yield "Sorry, I couldn't find any content to answer your question." - return - - references = _get_document_references(documents, top_nodes) - context = "\n\n".join( - f"TITLE: {node.metadata.get('title')}\n{node.text[:SINGLE_DOC_SNIPPET_CHARS]}" - for node in top_nodes - ) + references = _get_document_references(documents, top_nodes) prompt_template = PromptTemplate(template=CHAT_PROMPT_TMPL) - prompt = prompt_template.partial_format( - context_str=context, - query_str=query_str, - ).format(llm=client.llm) + response_synthesizer = get_response_synthesizer( + llm=client.llm, + prompt_helper=get_rag_prompt_helper(), + text_qa_template=prompt_template, + streaming=True, + ) query_engine = RetrieverQueryEngine.from_args( retriever=retriever, llm=client.llm, + response_synthesizer=response_synthesizer, streaming=True, ) - logger.debug("Document chat prompt: %s", prompt) + logger.debug("Document chat query: %s", query_str) - response_stream = query_engine.query(prompt) + response_stream = query_engine.query(query_str) for chunk in response_stream.response_gen: yield chunk diff --git a/src/paperless_ai/indexing.py b/src/paperless_ai/indexing.py index b8c865214..7a9796008 100644 --- a/src/paperless_ai/indexing.py +++ b/src/paperless_ai/indexing.py @@ -22,6 +22,11 @@ if TYPE_CHECKING: logger = logging.getLogger("paperless_ai.indexing") +RAG_CONTEXT_WINDOW = 8192 +RAG_NUM_OUTPUT = 512 +RAG_CHUNK_SIZE = 1024 +RAG_CHUNK_OVERLAP = 200 + def queue_llm_index_update_if_needed(*, rebuild: bool, reason: str) -> bool: from documents.tasks import llmindex_index @@ -111,7 +116,10 @@ def build_document_node(document: Document) -> list["BaseNode"]: from llama_index.core.node_parser import SimpleNodeParser doc = LlamaDocument(text=text, metadata=metadata) - parser = SimpleNodeParser() + parser = SimpleNodeParser( + chunk_size=RAG_CHUNK_SIZE, + chunk_overlap=get_rag_chunk_overlap(), + ) return parser.get_nodes_from_documents([doc]) @@ -168,6 +176,21 @@ def vector_store_file_exists(): return Path(settings.LLM_INDEX_DIR / "default__vector_store.json").exists() +def get_rag_chunk_overlap() -> int: + return min(RAG_CHUNK_OVERLAP, RAG_CHUNK_SIZE - 1) + + +def get_rag_prompt_helper(): + from llama_index.core.indices.prompt_helper import PromptHelper + + return PromptHelper( + context_window=RAG_CONTEXT_WINDOW, + num_output=RAG_NUM_OUTPUT, + chunk_overlap_ratio=0.1, + chunk_size_limit=RAG_CHUNK_SIZE, + ) + + def update_llm_index( *, iter_wrapper: IterWrapper[Document] = identity, @@ -277,17 +300,15 @@ def llm_index_remove_document(document: Document): def truncate_content(content: str) -> str: - from llama_index.core.indices.prompt_helper import PromptHelper from llama_index.core.prompts import PromptTemplate from llama_index.core.text_splitter import TokenTextSplitter - prompt_helper = PromptHelper( - context_window=8192, - num_output=512, - chunk_overlap_ratio=0.1, - chunk_size_limit=None, + prompt_helper = get_rag_prompt_helper() + splitter = TokenTextSplitter( + separator=" ", + chunk_size=RAG_CHUNK_SIZE, + chunk_overlap=get_rag_chunk_overlap(), ) - splitter = TokenTextSplitter(separator=" ", chunk_size=512, chunk_overlap=50) content_chunks = splitter.split_text(content) truncated_chunks = prompt_helper.truncate( prompt=PromptTemplate(template="{content}"), diff --git a/src/paperless_ai/tests/test_ai_indexing.py b/src/paperless_ai/tests/test_ai_indexing.py index d02cf3b96..09fbc2038 100644 --- a/src/paperless_ai/tests/test_ai_indexing.py +++ b/src/paperless_ai/tests/test_ai_indexing.py @@ -58,6 +58,24 @@ def test_build_document_node(real_document) -> None: assert nodes[0].metadata["document_id"] == str(real_document.id) +@pytest.mark.django_db +def test_build_document_node_uses_rag_chunk_settings(real_document) -> None: + with patch("llama_index.core.node_parser.SimpleNodeParser") as mock_parser: + mock_parser.return_value.get_nodes_from_documents.return_value = [] + + indexing.build_document_node(real_document) + + mock_parser.assert_called_once_with(chunk_size=1024, chunk_overlap=200) + + +def test_get_rag_chunk_overlap_clamps_to_chunk_size() -> None: + with ( + patch("paperless_ai.indexing.RAG_CHUNK_SIZE", 64), + patch("paperless_ai.indexing.RAG_CHUNK_OVERLAP", 128), + ): + assert indexing.get_rag_chunk_overlap() == 63 + + @pytest.mark.django_db def test_update_llm_index( temp_llm_index_dir, diff --git a/src/paperless_ai/tests/test_chat.py b/src/paperless_ai/tests/test_chat.py index 5e26ca0af..c7beb50d0 100644 --- a/src/paperless_ai/tests/test_chat.py +++ b/src/paperless_ai/tests/test_chat.py @@ -57,7 +57,7 @@ def assert_chat_output( } -def test_stream_chat_with_one_document_full_content(mock_document) -> None: +def test_stream_chat_with_one_document_retrieval(mock_document) -> None: with ( patch("paperless_ai.chat.AIClient") as mock_client_cls, patch("paperless_ai.chat.load_or_build_index") as mock_load_index, @@ -85,6 +85,7 @@ def test_stream_chat_with_one_document_full_content(mock_document) -> None: output = list(stream_chat_with_documents("What is this?", [mock_document])) + mock_query_engine.query.assert_called_once_with("What is this?") assert_chat_output( output, expected_chunks=["chunk1", "chunk2"], @@ -154,6 +155,7 @@ def test_stream_chat_with_multiple_documents_retrieval(patch_embed_nodes) -> Non output = list(stream_chat_with_documents("What's up?", [doc1, doc2])) + mock_query_engine.query.assert_called_once_with("What's up?") assert_chat_output( output, expected_chunks=["chunk1", "chunk2"], From b1e44f5d6bf4721c2e717593880aa9cdeda6e665 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Fri, 8 May 2026 13:37:10 -0700 Subject: [PATCH 18/53] Tweakhancment: Include the last applied 'documents' migration in the log (#12757) --- src/paperless/checks.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/paperless/checks.py b/src/paperless/checks.py index 4d8646112..6cc646f6b 100644 --- a/src/paperless/checks.py +++ b/src/paperless/checks.py @@ -1,3 +1,4 @@ +import logging import os import shutil import stat @@ -239,12 +240,21 @@ def check_v3_minimum_upgrade_version( except (DatabaseError, OperationalError): return [] + logger = logging.getLogger(__name__) + last_applied = sorted(applied)[-1] if applied else "(none)" + logger.error( + "V3 upgrade check failed: last applied documents migration is %r. " + "Expected '1075_workflowaction_order' (v2.20.15). " + "Ensure you have upgraded to v2.20.15 and run 'manage.py migrate' before upgrading to v3.", + last_applied, + ) + return [ Error( "Cannot upgrade to Paperless-ngx v3 from this version.", hint=( - "Upgrading to v3 can only be performed from v2.20.15." - "Please upgrade to v2.20.15, run migrations, then upgrade to v3." + "Upgrading to v3 can only be performed from v2.20.15. " + "Please upgrade to v2.20.15, run migrations, then upgrade to v3. " "See https://docs.paperless-ngx.com/setup/#upgrading for details." ), id="paperless.E002", From 5202dc074857ed2bfdcabbdcf5f0ac896f636b8c Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Fri, 8 May 2026 13:48:47 -0700 Subject: [PATCH 19/53] Fix: Clear ContentType/guardian caches at import and test cases (#12758) --- .../management/commands/document_importer.py | 7 +++++++ src/documents/tests/conftest.py | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/documents/management/commands/document_importer.py b/src/documents/management/commands/document_importer.py index 6cb495777..90986858b 100644 --- a/src/documents/management/commands/document_importer.py +++ b/src/documents/management/commands/document_importer.py @@ -30,6 +30,7 @@ from django.db.models import Model from django.db.models.signals import m2m_changed from django.db.models.signals import post_save from filelock import FileLock +from guardian.shortcuts import clear_ct_cache from documents.file_handling import create_source_path_directory from documents.management.commands.base import PaperlessCommand @@ -429,6 +430,12 @@ class Command(CryptMixin, PaperlessCommand): self.stdout.write(self.style.ERROR(self._import_error_context_message())) raise + # ContentType/Permission rows were deleted and reinserted above; stale + # in-process caches must be invalidated so permission checks use the + # new IDs rather than pre-import PKs. + ContentType.objects.clear_cache() + clear_ct_cache() + def handle(self, *args, **options) -> None: logging.getLogger().handlers[0].level = logging.ERROR diff --git a/src/documents/tests/conftest.py b/src/documents/tests/conftest.py index 88f0e9d76..07c20c944 100644 --- a/src/documents/tests/conftest.py +++ b/src/documents/tests/conftest.py @@ -8,6 +8,8 @@ from typing import TYPE_CHECKING import filelock import pytest from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from guardian.shortcuts import clear_ct_cache from pytest_django.fixtures import SettingsWrapper from rest_framework.test import APIClient @@ -158,6 +160,19 @@ def user_client(rest_api_client: APIClient, regular_user: UserModelT) -> APIClie return rest_api_client +@pytest.fixture(autouse=True) +def _clear_content_type_caches() -> None: + """Clear Django's ContentType cache and guardian's lru_cache before each test. + + Tests that delete and reinsert ContentType/Permission rows (e.g. the + importer) corrupt both caches. Without this fixture a subsequent test on + the same xdist worker sees stale ContentType objects and guardian raises + MixedContentTypeError. + """ + ContentType.objects.clear_cache() + clear_ct_cache() + + @pytest.fixture(scope="session", autouse=True) def faker_session_locale(): """Set Faker locale for reproducibility.""" From 177d81c8d495602efb2591098bc7ee3983c51a58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Moritz=20St=C3=BCckler?= Date: Sat, 9 May 2026 01:38:41 +0200 Subject: [PATCH 20/53] Fix: create LLM_INDEX_DIR before writing meta.json on first run (#12759) Co-authored-by: Claude Sonnet 4.6 --- src/paperless_ai/indexing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/paperless_ai/indexing.py b/src/paperless_ai/indexing.py index 7a9796008..9293633a3 100644 --- a/src/paperless_ai/indexing.py +++ b/src/paperless_ai/indexing.py @@ -70,6 +70,7 @@ def get_or_create_storage_context(*, rebuild=False): from llama_index.core.storage.index_store import SimpleIndexStore from llama_index.vector_stores.faiss import FaissVectorStore + settings.LLM_INDEX_DIR.mkdir(parents=True, exist_ok=True) embedding_dim = get_embedding_dim() faiss_index = faiss.IndexFlatL2(embedding_dim) vector_store = FaissVectorStore(faiss_index=faiss_index) From 79d0a04df0ec249f795aee7c34233a61215f87ab Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 8 May 2026 17:06:14 -0700 Subject: [PATCH 21/53] Enhancement: support ollama embeddings (#12753) --- docs/configuration.md | 15 +++-- pyproject.toml | 1 + src-ui/src/app/data/paperless-config.ts | 9 +++ src/documents/tests/test_api_app_config.py | 17 +++++ src/paperless/config.py | 4 ++ ...tionconfiguration_llm_embedding_backend.py | 38 +++++++++++ src/paperless/models.py | 8 +++ src/paperless/serialisers.py | 2 + src/paperless/settings/__init__.py | 3 +- src/paperless_ai/embedding.py | 30 +++++++-- src/paperless_ai/tests/test_embedding.py | 65 +++++++++++++++++++ uv.lock | 29 +++++++++ 12 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 src/paperless/migrations/0010_alter_applicationconfiguration_llm_embedding_backend.py diff --git a/docs/configuration.md b/docs/configuration.md index 397676a53..089253873 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2014,8 +2014,8 @@ suggestions. This setting is required to be set to true in order to use the AI f #### [`PAPERLESS_AI_LLM_EMBEDDING_BACKEND=`](#PAPERLESS_AI_LLM_EMBEDDING_BACKEND) {#PAPERLESS_AI_LLM_EMBEDDING_BACKEND} -: The embedding backend to use for RAG. This can be either "openai-like" or "huggingface". The -"openai-like" backend uses an OpenAI-compatible embeddings API. +: The embedding backend to use for RAG. This can be "openai-like", "huggingface", or +"ollama". The "openai-like" backend uses an OpenAI-compatible embeddings API. Defaults to None. @@ -2023,8 +2023,15 @@ suggestions. This setting is required to be set to true in order to use the AI f : The model to use for the embedding backend for RAG. This can be set to any of the embedding models supported by the current embedding backend. If not supplied, defaults to -"text-embedding-3-small" for the OpenAI-compatible backend and -"sentence-transformers/all-MiniLM-L6-v2" for Huggingface. +"text-embedding-3-small" for the OpenAI-compatible backend, +"sentence-transformers/all-MiniLM-L6-v2" for Huggingface, and "embeddinggemma" for Ollama. + + Defaults to None. + +#### [`PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT=`](#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) {#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT} + +: The endpoint / url to use for the embedding backend. If not supplied, embeddings use +`PAPERLESS_AI_LLM_ENDPOINT`. Defaults to None. diff --git a/pyproject.toml b/pyproject.toml index 101d82127..1b6a11419 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ dependencies = [ "langdetect~=1.0.9", "llama-index-core>=0.14.21", "llama-index-embeddings-huggingface>=0.6.1", + "llama-index-embeddings-ollama>=0.9", "llama-index-embeddings-openai-like>=0.2.2", "llama-index-llms-ollama>=0.9.1", "llama-index-llms-openai-like>=0.7.1", diff --git a/src-ui/src/app/data/paperless-config.ts b/src-ui/src/app/data/paperless-config.ts index 2df5cac49..52061dd18 100644 --- a/src-ui/src/app/data/paperless-config.ts +++ b/src-ui/src/app/data/paperless-config.ts @@ -57,6 +57,7 @@ export const ConfigCategory = { export const LLMEmbeddingBackendConfig = { OPENAI_LIKE: 'openai-like', HUGGINGFACE: 'huggingface', + OLLAMA: 'ollama', } export const LLMBackendConfig = { @@ -301,6 +302,13 @@ export const PaperlessConfigOptions: ConfigOption[] = [ config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL', category: ConfigCategory.AI, }, + { + key: 'llm_embedding_endpoint', + title: $localize`LLM Embedding Endpoint`, + type: ConfigOptionType.String, + config_key: 'PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT', + category: ConfigCategory.AI, + }, { key: 'llm_backend', title: $localize`LLM Backend`, @@ -363,6 +371,7 @@ export interface PaperlessConfig extends ObjectWithId { ai_enabled: boolean llm_embedding_backend: string llm_embedding_model: string + llm_embedding_endpoint: string llm_backend: string llm_model: string llm_api_key: string diff --git a/src/documents/tests/test_api_app_config.py b/src/documents/tests/test_api_app_config.py index ec1f45391..3372a16eb 100644 --- a/src/documents/tests/test_api_app_config.py +++ b/src/documents/tests/test_api_app_config.py @@ -74,6 +74,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): "ai_enabled": False, "llm_embedding_backend": None, "llm_embedding_model": None, + "llm_embedding_endpoint": None, "llm_backend": None, "llm_model": None, "llm_api_key": None, @@ -868,3 +869,19 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("non-public address", str(response.data).lower()) + + @override_settings(LLM_ALLOW_INTERNAL_ENDPOINTS=False) + def test_update_llm_embedding_endpoint_blocks_internal_endpoint_when_disallowed( + self, + ) -> None: + response = self.client.patch( + f"{self.ENDPOINT}1/", + json.dumps( + { + "llm_embedding_endpoint": "http://127.0.0.1:11434", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("non-public address", str(response.data).lower()) diff --git a/src/paperless/config.py b/src/paperless/config.py index 5204b677b..f4ade5ffe 100644 --- a/src/paperless/config.py +++ b/src/paperless/config.py @@ -194,6 +194,7 @@ class AIConfig(BaseConfig): ai_enabled: bool = dataclasses.field(init=False) llm_embedding_backend: str = dataclasses.field(init=False) llm_embedding_model: str = dataclasses.field(init=False) + llm_embedding_endpoint: str = dataclasses.field(init=False) llm_backend: str = dataclasses.field(init=False) llm_model: str = dataclasses.field(init=False) llm_api_key: str = dataclasses.field(init=False) @@ -210,6 +211,9 @@ class AIConfig(BaseConfig): self.llm_embedding_model = ( app_config.llm_embedding_model or settings.LLM_EMBEDDING_MODEL ) + self.llm_embedding_endpoint = ( + app_config.llm_embedding_endpoint or settings.LLM_EMBEDDING_ENDPOINT + ) self.llm_backend = app_config.llm_backend or settings.LLM_BACKEND self.llm_model = app_config.llm_model or settings.LLM_MODEL self.llm_api_key = app_config.llm_api_key or settings.LLM_API_KEY diff --git a/src/paperless/migrations/0010_alter_applicationconfiguration_llm_embedding_backend.py b/src/paperless/migrations/0010_alter_applicationconfiguration_llm_embedding_backend.py new file mode 100644 index 000000000..f7c64c505 --- /dev/null +++ b/src/paperless/migrations/0010_alter_applicationconfiguration_llm_embedding_backend.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.6 on 2026-05-08 00:00 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("paperless", "0009_alter_applicationconfiguration_options"), + ] + + operations = [ + migrations.AlterField( + model_name="applicationconfiguration", + name="llm_embedding_backend", + field=models.CharField( + blank=True, + choices=[ + ("openai-like", "OpenAI-compatible"), + ("huggingface", "Huggingface"), + ("ollama", "Ollama"), + ], + max_length=128, + null=True, + verbose_name="Sets the LLM embedding backend", + ), + ), + migrations.AddField( + model_name="applicationconfiguration", + name="llm_embedding_endpoint", + field=models.CharField( + blank=True, + max_length=256, + null=True, + verbose_name="Sets the LLM embedding endpoint, optional", + ), + ), + ] diff --git a/src/paperless/models.py b/src/paperless/models.py index 91ab4a496..95e52426e 100644 --- a/src/paperless/models.py +++ b/src/paperless/models.py @@ -77,6 +77,7 @@ class ColorConvertChoices(models.TextChoices): class LLMEmbeddingBackend(models.TextChoices): OPENAI_LIKE = ("openai-like", _("OpenAI-compatible")) HUGGINGFACE = ("huggingface", _("Huggingface")) + OLLAMA = ("ollama", _("Ollama")) class LLMBackend(models.TextChoices): @@ -310,6 +311,13 @@ class ApplicationConfiguration(AbstractSingletonModel): max_length=128, ) + llm_embedding_endpoint = models.CharField( + verbose_name=_("Sets the LLM embedding endpoint, optional"), + blank=True, + null=True, + max_length=256, + ) + llm_backend = models.CharField( verbose_name=_("Sets the LLM backend"), blank=True, diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index 92676df4e..d1597ab13 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -291,6 +291,8 @@ class ApplicationConfigurationSerializer( return value + validate_llm_embedding_endpoint = validate_llm_endpoint + class Meta: model = ApplicationConfiguration fields = "__all__" diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index d82694dd8..d021cffbb 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -1178,8 +1178,9 @@ REMOTE_OCR_ENDPOINT = os.getenv("PAPERLESS_REMOTE_OCR_ENDPOINT") AI_ENABLED = get_bool_from_env("PAPERLESS_AI_ENABLED", "NO") LLM_EMBEDDING_BACKEND = os.getenv( "PAPERLESS_AI_LLM_EMBEDDING_BACKEND", -) # "huggingface" or "openai-like" +) # "huggingface", "openai-like", or "ollama" LLM_EMBEDDING_MODEL = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_MODEL") +LLM_EMBEDDING_ENDPOINT = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT") LLM_BACKEND = os.getenv("PAPERLESS_AI_LLM_BACKEND") # "ollama" or "openai-like" LLM_MODEL = os.getenv("PAPERLESS_AI_LLM_MODEL") LLM_API_KEY = os.getenv("PAPERLESS_AI_LLM_API_KEY") diff --git a/src/paperless_ai/embedding.py b/src/paperless_ai/embedding.py index a96dd2429..7fb594a97 100644 --- a/src/paperless_ai/embedding.py +++ b/src/paperless_ai/embedding.py @@ -22,7 +22,7 @@ def get_embedding_model() -> "BaseEmbedding": case LLMEmbeddingBackend.OPENAI_LIKE: from llama_index.embeddings.openai_like import OpenAILikeEmbedding - endpoint = config.llm_endpoint or None + endpoint = config.llm_embedding_endpoint or config.llm_endpoint or None if endpoint: validate_outbound_http_url( endpoint, @@ -40,6 +40,22 @@ def get_embedding_model() -> "BaseEmbedding": model_name=config.llm_embedding_model or "sentence-transformers/all-MiniLM-L6-v2", ) + case LLMEmbeddingBackend.OLLAMA: + from llama_index.embeddings.ollama import OllamaEmbedding + + endpoint = ( + config.llm_embedding_endpoint + or config.llm_endpoint + or "http://localhost:11434" + ) + validate_outbound_http_url( + endpoint, + allow_internal=config.llm_allow_internal_endpoints, + ) + return OllamaEmbedding( + model_name=config.llm_embedding_model or "embeddinggemma", + base_url=endpoint, + ) case _: raise ValueError( f"Unsupported embedding backend: {config.llm_embedding_backend}", @@ -52,11 +68,15 @@ def get_embedding_dim() -> int: from a dummy embedding and stores it for future use. """ config = AIConfig() - model = config.llm_embedding_model or ( - "text-embedding-3-small" - if config.llm_embedding_backend == LLMEmbeddingBackend.OPENAI_LIKE - else "sentence-transformers/all-MiniLM-L6-v2" + default_model = { + LLMEmbeddingBackend.OPENAI_LIKE: "text-embedding-3-small", + LLMEmbeddingBackend.HUGGINGFACE: "sentence-transformers/all-MiniLM-L6-v2", + LLMEmbeddingBackend.OLLAMA: "embeddinggemma", + }.get( + config.llm_embedding_backend, + "sentence-transformers/all-MiniLM-L6-v2", ) + model = config.llm_embedding_model or default_model meta_path: Path = settings.LLM_INDEX_DIR / "meta.json" if meta_path.exists(): diff --git a/src/paperless_ai/tests/test_embedding.py b/src/paperless_ai/tests/test_embedding.py index e4e80cdf1..3fb5c39ce 100644 --- a/src/paperless_ai/tests/test_embedding.py +++ b/src/paperless_ai/tests/test_embedding.py @@ -14,6 +14,7 @@ from paperless_ai.embedding import get_embedding_model @pytest.fixture def mock_ai_config(): with patch("paperless_ai.embedding.AIConfig") as MockAIConfig: + MockAIConfig.return_value.llm_embedding_endpoint = None MockAIConfig.return_value.llm_allow_internal_endpoints = True yield MockAIConfig @@ -71,6 +72,25 @@ def test_get_embedding_model_openai(mock_ai_config): assert model == MockOpenAIEmbedding.return_value +def test_get_embedding_model_openai_prefers_embedding_endpoint(mock_ai_config): + mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE + mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small" + mock_ai_config.return_value.llm_api_key = "test_api_key" + mock_ai_config.return_value.llm_embedding_endpoint = "http://embedding-url" + mock_ai_config.return_value.llm_endpoint = "http://test-url" + + with patch( + "llama_index.embeddings.openai_like.OpenAILikeEmbedding", + ) as MockOpenAIEmbedding: + model = get_embedding_model() + MockOpenAIEmbedding.assert_called_once_with( + model_name="text-embedding-3-small", + api_key="test_api_key", + api_base="http://embedding-url", + ) + assert model == MockOpenAIEmbedding.return_value + + def test_get_embedding_model_openai_blocks_internal_endpoint_when_disallowed( mock_ai_config, ): @@ -100,6 +120,51 @@ def test_get_embedding_model_huggingface(mock_ai_config): assert model == MockHuggingFaceEmbedding.return_value +def test_get_embedding_model_ollama(mock_ai_config): + mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OLLAMA + mock_ai_config.return_value.llm_embedding_model = "embeddinggemma" + mock_ai_config.return_value.llm_endpoint = "http://test-url" + + with patch( + "llama_index.embeddings.ollama.OllamaEmbedding", + ) as MockOllamaEmbedding: + model = get_embedding_model() + MockOllamaEmbedding.assert_called_once_with( + model_name="embeddinggemma", + base_url="http://test-url", + ) + assert model == MockOllamaEmbedding.return_value + + +def test_get_embedding_model_ollama_prefers_embedding_endpoint(mock_ai_config): + mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OLLAMA + mock_ai_config.return_value.llm_embedding_model = "embeddinggemma" + mock_ai_config.return_value.llm_embedding_endpoint = "http://embedding-url" + mock_ai_config.return_value.llm_endpoint = "http://test-url" + + with patch( + "llama_index.embeddings.ollama.OllamaEmbedding", + ) as MockOllamaEmbedding: + model = get_embedding_model() + MockOllamaEmbedding.assert_called_once_with( + model_name="embeddinggemma", + base_url="http://embedding-url", + ) + assert model == MockOllamaEmbedding.return_value + + +def test_get_embedding_model_ollama_blocks_internal_endpoint_when_disallowed( + mock_ai_config, +): + mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OLLAMA + mock_ai_config.return_value.llm_embedding_model = "embeddinggemma" + mock_ai_config.return_value.llm_endpoint = "http://127.0.0.1:11434" + mock_ai_config.return_value.llm_allow_internal_endpoints = False + + with pytest.raises(ValueError, match="non-public address"): + get_embedding_model() + + def test_get_embedding_model_invalid_backend(mock_ai_config): mock_ai_config.return_value.llm_embedding_backend = "INVALID_BACKEND" diff --git a/uv.lock b/uv.lock index 30f7c8ebc..001438387 100644 --- a/uv.lock +++ b/uv.lock @@ -2213,6 +2213,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/8e/b9ea889f88318f2faa20b615989e12a15a133c9273630f9266fcf69f35a6/llama_index_embeddings_openai_like-0.3.1-py3-none-any.whl", hash = "sha256:167c7e462cde7d53ea907ceaffbbf10a750676c7c9f7bcc9bc9686a41921387a", size = 3631, upload-time = "2026-03-13T16:15:19.58Z" }, ] +[[package]] +name = "llama-index-embeddings-ollama" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llama-index-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/cd/2cff1feac66368a4c60ea7afbdbb3f3fdd49ee8c279fc105457e726a3ad2/llama_index_embeddings_ollama-0.9.0.tar.gz", hash = "sha256:19d2d2a0e3f0934480eae31243ac5f1ce171319578b9c0adad25cf1b6c35659e", size = 6575, upload-time = "2026-03-12T20:21:18.810Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/36/53674403380483510a7f657c5d6f0bdac5b7f9ec5a1a8d06cdfdd6dc47f2/llama_index_embeddings_ollama-0.9.0-py3-none-any.whl", hash = "sha256:92e0ce481e60a9bcbddbe2c369d2f72c6fdd7158d03a34ab9b35d80869b673c3", size = 6250, upload-time = "2026-03-12T20:21:19.441Z" }, +] + [[package]] name = "llama-index-instrumentation" version = "0.5.0" @@ -2909,6 +2923,7 @@ dependencies = [ { name = "llama-index-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "llama-index-embeddings-huggingface", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "llama-index-embeddings-openai-like", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "llama-index-embeddings-ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "llama-index-llms-ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "llama-index-llms-openai-like", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "llama-index-vector-stores-faiss", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -3059,6 +3074,7 @@ requires-dist = [ { name = "llama-index-core", specifier = ">=0.14.21" }, { name = "llama-index-embeddings-huggingface", specifier = ">=0.6.1" }, { name = "llama-index-embeddings-openai-like", specifier = ">=0.2.2" }, + { name = "llama-index-embeddings-ollama", specifier = ">=0.9.0" }, { name = "llama-index-llms-ollama", specifier = ">=0.9.1" }, { name = "llama-index-llms-openai-like", specifier = ">=0.7.1" }, { name = "llama-index-vector-stores-faiss", specifier = ">=0.5.2" }, @@ -3799,6 +3815,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "pytest-cov" version = "7.1.0" From da0f25b546d1312e57a2fc0fae81ebd90977bc84 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Sat, 9 May 2026 18:23:11 -0700 Subject: [PATCH 22/53] Fix: Use a persistent, writeable location for hugging face models (#12771) --- src/paperless_ai/embedding.py | 1 + src/paperless_ai/tests/test_embedding.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/paperless_ai/embedding.py b/src/paperless_ai/embedding.py index 7fb594a97..e03ddae72 100644 --- a/src/paperless_ai/embedding.py +++ b/src/paperless_ai/embedding.py @@ -39,6 +39,7 @@ def get_embedding_model() -> "BaseEmbedding": return HuggingFaceEmbedding( model_name=config.llm_embedding_model or "sentence-transformers/all-MiniLM-L6-v2", + cache_folder=str(settings.DATA_DIR / "hf_cache"), ) case LLMEmbeddingBackend.OLLAMA: from llama_index.embeddings.ollama import OllamaEmbedding diff --git a/src/paperless_ai/tests/test_embedding.py b/src/paperless_ai/tests/test_embedding.py index 3fb5c39ce..6ee1994b8 100644 --- a/src/paperless_ai/tests/test_embedding.py +++ b/src/paperless_ai/tests/test_embedding.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock from unittest.mock import patch import pytest +from django.conf import settings from documents.models import Document from paperless.models import LLMEmbeddingBackend @@ -116,6 +117,7 @@ def test_get_embedding_model_huggingface(mock_ai_config): model = get_embedding_model() MockHuggingFaceEmbedding.assert_called_once_with( model_name="sentence-transformers/all-MiniLM-L6-v2", + cache_folder=str(settings.DATA_DIR / "hf_cache"), ) assert model == MockHuggingFaceEmbedding.return_value From 1527c347e3a0c3b94bf619ad2ba90a878320231e Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Mon, 11 May 2026 08:59:19 -0700 Subject: [PATCH 23/53] Chore: Further dependency minor security updates (#12780) --- uv.lock | 45 +++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/uv.lock b/uv.lock index 001438387..2263af441 100644 --- a/uv.lock +++ b/uv.lock @@ -277,18 +277,19 @@ wheels = [ [[package]] name = "banks" -version = "2.3.0" +version = "2.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecated", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "filetype", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "griffe", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "platformdirs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/64/9a4e17dfe7dc172594ffb877a287859edb40d59e0564bc930941e6c5df9d/banks-2.3.0.tar.gz", hash = "sha256:1ecb439a0b340588fcf9a8072d806540aad03c4b874ab9aff59ac8bc08c112ff", size = 182736, upload-time = "2026-01-21T10:03:15.114Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/51/08fb68d23f4b0f6256fe85dc86e9576941550f890b079352fba719e07b39/banks-2.4.2.tar.gz", hash = "sha256:cda6013bd377ea7b701933578bfb9370fc21ad70bc13cedfc3f5cb2c034ca3dc", size = 188633, upload-time = "2026-04-27T12:15:22.021Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/d6/ccceb03dd5193d180e28411c9f880f2cc9a574251de94b9b8a21ebdf51ec/banks-2.3.0-py3-none-any.whl", hash = "sha256:ac6a5800d468f26a0d80e091c0c6971b69457d580ce34c0217ee2bf6c3f07271", size = 32748, upload-time = "2026-01-21T10:03:14.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/b6/8dc5477681b782e2f99de703e7a99828883364b9e03a60d3e2c47053d56a/banks-2.4.2-py3-none-any.whl", hash = "sha256:5fe407cc48c101f3e13d1cf732b83b8246003337612f13c0705d2e81f6faffb7", size = 35050, upload-time = "2026-04-27T12:15:20.785Z" }, ] [[package]] @@ -2188,6 +2189,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/86/dc43deab70f19db4fcdd82337e18b7f5b828478c486d6308cea10def87c5/llama_index_embeddings_huggingface-0.7.0-py3-none-any.whl", hash = "sha256:cae7e4ffddc1fecb34b83425a00679f338ce03ad0a7f3aca6b927136d6af7131", size = 8904, upload-time = "2026-03-12T20:20:32.449Z" }, ] +[[package]] +name = "llama-index-embeddings-ollama" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llama-index-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/cd/2cff1feac66368a4c60ea7afbdbb3f3fdd49ee8c279fc105457e726a3ad2/llama_index_embeddings_ollama-0.9.0.tar.gz", hash = "sha256:19d2d2a0e3f0934480eae31243ac5f1ce171319578b9c0adad25cf1b6c35659e", size = 6575, upload-time = "2026-03-12T20:21:18.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/36/53674403380483510a7f657c5d6f0bdac5b7f9ec5a1a8d06cdfdd6dc47f2/llama_index_embeddings_ollama-0.9.0-py3-none-any.whl", hash = "sha256:92e0ce481e60a9bcbddbe2c369d2f72c6fdd7158d03a34ab9b35d80869b673c3", size = 6250, upload-time = "2026-03-12T20:21:19.441Z" }, +] + [[package]] name = "llama-index-embeddings-openai" version = "0.6.0" @@ -2213,20 +2228,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/8e/b9ea889f88318f2faa20b615989e12a15a133c9273630f9266fcf69f35a6/llama_index_embeddings_openai_like-0.3.1-py3-none-any.whl", hash = "sha256:167c7e462cde7d53ea907ceaffbbf10a750676c7c9f7bcc9bc9686a41921387a", size = 3631, upload-time = "2026-03-13T16:15:19.58Z" }, ] -[[package]] -name = "llama-index-embeddings-ollama" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "llama-index-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/cd/2cff1feac66368a4c60ea7afbdbb3f3fdd49ee8c279fc105457e726a3ad2/llama_index_embeddings_ollama-0.9.0.tar.gz", hash = "sha256:19d2d2a0e3f0934480eae31243ac5f1ce171319578b9c0adad25cf1b6c35659e", size = 6575, upload-time = "2026-03-12T20:21:18.810Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/36/53674403380483510a7f657c5d6f0bdac5b7f9ec5a1a8d06cdfdd6dc47f2/llama_index_embeddings_ollama-0.9.0-py3-none-any.whl", hash = "sha256:92e0ce481e60a9bcbddbe2c369d2f72c6fdd7158d03a34ab9b35d80869b673c3", size = 6250, upload-time = "2026-03-12T20:21:19.441Z" }, -] - [[package]] name = "llama-index-instrumentation" version = "0.5.0" @@ -2922,8 +2923,8 @@ dependencies = [ { name = "langdetect", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "llama-index-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "llama-index-embeddings-huggingface", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "llama-index-embeddings-openai-like", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "llama-index-embeddings-ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "llama-index-embeddings-openai-like", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "llama-index-llms-ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "llama-index-llms-openai-like", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "llama-index-vector-stores-faiss", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -3073,8 +3074,8 @@ requires-dist = [ { name = "langdetect", specifier = "~=1.0.9" }, { name = "llama-index-core", specifier = ">=0.14.21" }, { name = "llama-index-embeddings-huggingface", specifier = ">=0.6.1" }, + { name = "llama-index-embeddings-ollama", specifier = ">=0.9" }, { name = "llama-index-embeddings-openai-like", specifier = ">=0.2.2" }, - { name = "llama-index-embeddings-ollama", specifier = ">=0.9.0" }, { name = "llama-index-llms-ollama", specifier = ">=0.9.1" }, { name = "llama-index-llms-openai-like", specifier = ">=0.7.1" }, { name = "llama-index-vector-stores-faiss", specifier = ">=0.5.2" }, @@ -5076,7 +5077,7 @@ wheels = [ [[package]] name = "twisted" -version = "25.5.0" +version = "26.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -5087,9 +5088,9 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "zope-interface", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/0f/82716ed849bf7ea4984c21385597c949944f0f9b428b5710f79d0afc084d/twisted-25.5.0.tar.gz", hash = "sha256:1deb272358cb6be1e3e8fc6f9c8b36f78eb0fa7c2233d2dbe11ec6fee04ea316", size = 3545725, upload-time = "2025-06-07T09:52:24.858Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/97/6e9beb1e78247ae6dc34114f27d538cf2cb183c4afcd3609dfdf2b0439c8/twisted-26.4.0.tar.gz", hash = "sha256:dbfd0fe1ee409d0243fdd7a6a6ff14f4948cec1fd78e0376291f805e1501fae9", size = 3575095, upload-time = "2026-05-11T11:24:51.861Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/66/ab7efd8941f0bc7b2bd555b0f0471bff77df4c88e0cc31120c82737fec77/twisted-25.5.0-py3-none-any.whl", hash = "sha256:8559f654d01a54a8c3efe66d533d43f383531ebf8d81d9f9ab4769d91ca15df7", size = 3204767, upload-time = "2025-06-07T09:52:21.428Z" }, + { url = "https://files.pythonhosted.org/packages/a6/57/bcf4e2370dd218c9aa68a9140a65d86729c73f1d529f7e94786c2766fc72/twisted-26.4.0-py3-none-any.whl", hash = "sha256:dc25ea0ebf6511c24f03232ee9f4afa54b291c5d897990e3a39cc4d14a1ef4c0", size = 3230362, upload-time = "2026-05-11T11:24:49.5Z" }, ] [package.optional-dependencies] From 7471fedb432df4aadd348a48beca8b6f994fc8eb Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 11 May 2026 09:16:21 -0700 Subject: [PATCH 24/53] Fix: Update parser contract to require empty strings, not None (#12775) Co-authored-by: stumpylog <797416+stumpylog@users.noreply.github.com> --- src/documents/tests/test_consumer.py | 26 ++++++++++++------- src/paperless/parsers/__init__.py | 6 ++--- src/paperless/parsers/mail.py | 8 +++--- src/paperless/parsers/remote.py | 4 +-- src/paperless/parsers/tesseract.py | 4 +-- src/paperless/parsers/text.py | 8 +++--- src/paperless/parsers/tika.py | 12 ++++----- .../tests/parsers/test_remote_parser.py | 8 +++--- .../tests/parsers/test_text_parser.py | 4 +-- src/paperless/tests/test_registry.py | 4 +-- 10 files changed, 45 insertions(+), 39 deletions(-) diff --git a/src/documents/tests/test_consumer.py b/src/documents/tests/test_consumer.py index 436408886..c8a740a7e 100644 --- a/src/documents/tests/test_consumer.py +++ b/src/documents/tests/test_consumer.py @@ -1120,12 +1120,14 @@ class TestConsumer( self.assertEqual(command[1], "--replace-input") @mock.patch("paperless_mail.models.MailRule.objects.get") + @mock.patch("paperless.parsers.mail.MailDocumentParser.get_thumbnail") @mock.patch("paperless.parsers.mail.MailDocumentParser.parse") @mock.patch("documents.consumer.get_parser_registry") def test_mail_parser_receives_mailrule( self, mock_get_parser_registry: mock.Mock, mock_mail_parser_parse: mock.Mock, + mock_get_thumbnail: mock.Mock, mock_mailrule_get: mock.Mock, ) -> None: """ @@ -1136,6 +1138,7 @@ class TestConsumer( THEN: - The mail parser should receive the mail rule """ + from documents.parsers import ParseError from paperless.parsers.mail import MailDocumentParser mock_get_parser_registry.return_value.get_parser_for_file.return_value = ( @@ -1144,19 +1147,24 @@ class TestConsumer( mock_mailrule_get.return_value = mock.Mock( pdf_layout=MailRule.PdfLayout.HTML_ONLY, ) + mock_get_thumbnail.side_effect = ParseError("no thumbnail") + + src = ( + Path(__file__).parent.parent.parent + / Path("paperless") + / Path("tests") + / Path("samples") + / Path("mail") + / "html.eml" + ) + dst = self.dirs.scratch_dir / "html.eml" + shutil.copy(src, dst) + with self.get_consumer( - filepath=( - Path(__file__).parent.parent.parent - / Path("paperless") - / Path("tests") - / Path("samples") - / Path("mail") - ).resolve() - / "html.eml", + filepath=dst, source=DocumentSource.MailFetch, mailrule_id=1, ) as consumer: - # fails because no gotenberg with self.assertRaises( ConsumerError, ): diff --git a/src/paperless/parsers/__init__.py b/src/paperless/parsers/__init__.py index c9c1530a5..5ba42f1f1 100644 --- a/src/paperless/parsers/__init__.py +++ b/src/paperless/parsers/__init__.py @@ -281,13 +281,13 @@ class ParserProtocol(Protocol): # Result accessors # ------------------------------------------------------------------ - def get_text(self) -> str | None: + def get_text(self) -> str: """Return the plain-text content extracted during parse. Returns ------- - str | None - Extracted text, or None if no text could be found. + str + Extracted text, or an empty string if no text could be found. """ ... diff --git a/src/paperless/parsers/mail.py b/src/paperless/parsers/mail.py index 9914b2ec6..8188b7933 100644 --- a/src/paperless/parsers/mail.py +++ b/src/paperless/parsers/mail.py @@ -285,15 +285,15 @@ class MailDocumentParser: # Result accessors # ------------------------------------------------------------------ - def get_text(self) -> str | None: + def get_text(self) -> str: """Return the plain-text content extracted during parse. Returns ------- - str | None - Extracted text, or None if parse has not been called yet. + str + Extracted text, or an empty string if no text could be found. """ - return self._text + return self._text or "" def get_date(self) -> datetime.datetime | None: """Return the document date detected during parse. diff --git a/src/paperless/parsers/remote.py b/src/paperless/parsers/remote.py index 10e89649e..c851469aa 100644 --- a/src/paperless/parsers/remote.py +++ b/src/paperless/parsers/remote.py @@ -247,9 +247,9 @@ class RemoteDocumentParser: # Result accessors # ------------------------------------------------------------------ - def get_text(self) -> str | None: + def get_text(self) -> str: """Return the plain-text content extracted during parse.""" - return self._text + return self._text or "" def get_date(self) -> datetime.datetime | None: """Return the document date detected during parse. diff --git a/src/paperless/parsers/tesseract.py b/src/paperless/parsers/tesseract.py index e19922dd3..2e0d791ea 100644 --- a/src/paperless/parsers/tesseract.py +++ b/src/paperless/parsers/tesseract.py @@ -144,8 +144,8 @@ class RasterisedDocumentParser: # Result accessors # ------------------------------------------------------------------ - def get_text(self) -> str | None: - return self.text + def get_text(self) -> str: + return self.text or "" def get_date(self) -> datetime.datetime | None: return self.date diff --git a/src/paperless/parsers/text.py b/src/paperless/parsers/text.py index 00d738995..301c67149 100644 --- a/src/paperless/parsers/text.py +++ b/src/paperless/parsers/text.py @@ -189,15 +189,15 @@ class TextDocumentParser: # Result accessors # ------------------------------------------------------------------ - def get_text(self) -> str | None: + def get_text(self) -> str: """Return the plain-text content extracted during parse. Returns ------- - str | None - Extracted text, or None if parse has not been called yet. + str + Extracted text, or an empty string if no text could be found. """ - return self._text + return self._text or "" def get_date(self) -> datetime.datetime | None: """Return the document date detected during parse. diff --git a/src/paperless/parsers/tika.py b/src/paperless/parsers/tika.py index 674d74fe2..53082c8b4 100644 --- a/src/paperless/parsers/tika.py +++ b/src/paperless/parsers/tika.py @@ -265,9 +265,7 @@ class TikaDocumentParser: f"{settings.TIKA_ENDPOINT}: {err}", ) from err - self._text = parsed.content - if self._text is not None: - self._text = self._text.strip() + self._text = (parsed.content or "").strip() self._date = parsed.created if self._date is not None and timezone.is_naive(self._date): @@ -281,15 +279,15 @@ class TikaDocumentParser: # Result accessors # ------------------------------------------------------------------ - def get_text(self) -> str | None: + def get_text(self) -> str: """Return the plain-text content extracted during parse. Returns ------- - str | None - Extracted text, or None if parse has not been called yet. + str + Extracted text, or an empty string if no text could be found. """ - return self._text + return self._text or "" def get_date(self) -> datetime.datetime | None: """Return the document date detected during parse. diff --git a/src/paperless/tests/parsers/test_remote_parser.py b/src/paperless/tests/parsers/test_remote_parser.py index 892915bb5..b9e038e60 100644 --- a/src/paperless/tests/parsers/test_remote_parser.py +++ b/src/paperless/tests/parsers/test_remote_parser.py @@ -319,11 +319,11 @@ class TestRemoteParserParse: assert remote_parser.get_text() == "" assert remote_parser.get_archive_path() is None - def test_get_text_none_before_parse( + def test_get_text_empty_before_parse( self, remote_parser: RemoteDocumentParser, ) -> None: - assert remote_parser.get_text() is None + assert remote_parser.get_text() == "" def test_get_date_always_none( self, @@ -342,7 +342,7 @@ class TestRemoteParserParse: class TestRemoteParserParseError: - def test_parse_returns_none_on_azure_error( + def test_parse_returns_empty_on_azure_error( self, remote_parser: RemoteDocumentParser, simple_digital_pdf_file: Path, @@ -350,7 +350,7 @@ class TestRemoteParserParseError: ) -> None: remote_parser.parse(simple_digital_pdf_file, "application/pdf") - assert remote_parser.get_text() is None + assert remote_parser.get_text() == "" def test_parse_closes_client_on_error( self, diff --git a/src/paperless/tests/parsers/test_text_parser.py b/src/paperless/tests/parsers/test_text_parser.py index fd2a57857..eb94ef9b5 100644 --- a/src/paperless/tests/parsers/test_text_parser.py +++ b/src/paperless/tests/parsers/test_text_parser.py @@ -138,11 +138,11 @@ class TestTextParserParse: assert text_parser.get_text() == "Pantothens\ufffdure\n" - def test_get_text_none_before_parse( + def test_get_text_empty_before_parse( self, text_parser: TextDocumentParser, ) -> None: - assert text_parser.get_text() is None + assert text_parser.get_text() == "" class TestTextParserThumbnail: diff --git a/src/paperless/tests/test_registry.py b/src/paperless/tests/test_registry.py index 5c2d20d50..a371abc35 100644 --- a/src/paperless/tests/test_registry.py +++ b/src/paperless/tests/test_registry.py @@ -74,8 +74,8 @@ def dummy_parser_cls() -> type: Required to exist, but doesn't need to do anything """ - def get_text(self) -> str | None: - return None + def get_text(self) -> str: + return "" def get_date(self) -> None: return None From bc1d2fbccbf258decfef1288b7323549d1d15666 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 11 May 2026 13:46:17 -0700 Subject: [PATCH 25/53] Fix: improve new tasks ui layout across screen sizes (#12784) --- .../admin/tasks/tasks.component.html | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/src-ui/src/app/components/admin/tasks/tasks.component.html b/src-ui/src/app/components/admin/tasks/tasks.component.html index 1a750e1fc..1e6494c7b 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.html +++ b/src-ui/src/app/components/admin/tasks/tasks.component.html @@ -23,7 +23,7 @@
Loading...
} -