From 6955d6c07fbcee243d7c19e6fc4babba9d424776 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:47:19 +0000 Subject: [PATCH 1/6] Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates (#12291) * Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates Bumps the utilities-patch group with 6 updates in the / directory: | Package | From | To | | --- | --- | --- | | llama-index-embeddings-openai | `0.5.1` | `0.5.2` | | llama-index-llms-openai | `0.6.21` | `0.6.26` | | [python-dotenv](https://github.com/theskumar/python-dotenv) | `1.2.1` | `1.2.2` | | [regex](https://github.com/mrabarnett/mrab-regex) | `2026.2.19` | `2026.2.28` | | [prek](https://github.com/j178/prek) | `0.3.3` | `0.3.5` | | [ruff](https://github.com/astral-sh/ruff) | `0.15.4` | `0.15.5` | Updates `llama-index-embeddings-openai` from 0.5.1 to 0.5.2 Updates `llama-index-llms-openai` from 0.6.21 to 0.6.26 Updates `python-dotenv` from 1.2.1 to 1.2.2 - [Release notes](https://github.com/theskumar/python-dotenv/releases) - [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md) - [Commits](https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2) Updates `regex` from 2026.2.19 to 2026.2.28 - [Changelog](https://github.com/mrabarnett/mrab-regex/blob/hg/changelog.txt) - [Commits](https://github.com/mrabarnett/mrab-regex/compare/2026.2.19...2026.2.28) Updates `prek` from 0.3.3 to 0.3.5 - [Release notes](https://github.com/j178/prek/releases) - [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md) - [Commits](https://github.com/j178/prek/compare/v0.3.3...v0.3.5) Updates `ruff` from 0.15.4 to 0.15.5 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.4...0.15.5) --- updated-dependencies: - dependency-name: llama-index-embeddings-openai dependency-version: 0.5.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: utilities-patch - dependency-name: llama-index-llms-openai dependency-version: 0.6.26 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: utilities-patch - dependency-name: python-dotenv dependency-version: 1.2.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: utilities-patch - dependency-name: regex dependency-version: 2026.2.28 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: utilities-patch - dependency-name: prek dependency-version: 0.3.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: utilities-patch - dependency-name: ruff dependency-version: 0.15.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: utilities-patch ... Signed-off-by: dependabot[bot] * Update .pre-commit-config.yaml --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- uv.lock | 240 ++++++++++++++++++++-------------------- 2 files changed, 121 insertions(+), 121 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 54afde612..f8284d574 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,7 +50,7 @@ repos: - 'prettier-plugin-organize-imports@4.1.0' # Python hooks - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.0 + rev: v0.15.5 hooks: - id: ruff-check - id: ruff-format diff --git a/uv.lock b/uv.lock index c69635232..04d9ada55 100644 --- a/uv.lock +++ b/uv.lock @@ -2139,15 +2139,15 @@ wheels = [ [[package]] name = "llama-index-embeddings-openai" -version = "0.5.1" +version = "0.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "llama-index-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/36/90336d054a5061a3f5bc17ac2c18ef63d9d84c55c14d557de484e811ea4d/llama_index_embeddings_openai-0.5.1.tar.gz", hash = "sha256:1c89867a48b0d0daa3d2d44f5e76b394b2b2ef9935932daf921b9e77939ccda8", size = 7020, upload-time = "2025-09-08T20:17:44.681Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/a1/d238dfa453ba8ebc4f6261d6384b663f50b8dba6f4b22d8be800b305863d/llama_index_embeddings_openai-0.5.2.tar.gz", hash = "sha256:091bd0c3e9182748e8827de7d79713a219d5f5e0dc97d1bb7b271cf524520e4b", size = 7630, upload-time = "2026-03-03T11:27:38.127Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/4a/8ab11026cf8deff8f555aa73919be0bac48332683111e5fc4290f352dc50/llama_index_embeddings_openai-0.5.1-py3-none-any.whl", hash = "sha256:a2fcda3398bbd987b5ce3f02367caee8e84a56b930fdf43cc1d059aa9fd20ca5", size = 7011, upload-time = "2025-09-08T20:17:44.015Z" }, + { url = "https://files.pythonhosted.org/packages/1f/5e/da156f9c77443d22287eeaea341fe35fdcc25e59a9250e4cb10d4d5a066a/llama_index_embeddings_openai-0.5.2-py3-none-any.whl", hash = "sha256:37e7967de05b05f16c9b171091110bb1c6e5a0720198ea306d57cd3920cb81b7", size = 7667, upload-time = "2026-03-03T11:27:37.394Z" }, ] [[package]] @@ -2178,15 +2178,15 @@ wheels = [ [[package]] name = "llama-index-llms-openai" -version = "0.6.21" +version = "0.6.26" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "llama-index-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/5b/775289b3064302966cc839bbccfdbe314f706eaf58ad4233b86e5d53343d/llama_index_llms_openai-0.6.21.tar.gz", hash = "sha256:0b92dcfb01cbc7752f5b8bdf6d93430643d295210cf9392b45291d6fdd81e0ee", size = 25961, upload-time = "2026-02-26T04:19:33.604Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/5e/a7a47d46dc2eb30953d83654112c8af6f61821ca78ef3ea22e30729aac3a/llama_index_llms_openai-0.6.26.tar.gz", hash = "sha256:3474602ecbc30c88a8b585cfd5737891d45da78251a5e067c4dbc2d3cc3d08db", size = 27262, upload-time = "2026-03-05T02:53:50.581Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/d7/5b513acbf0bfc2b6ef281b6bbca764062facc431e8f13763c16005fbd34b/llama_index_llms_openai-0.6.21-py3-none-any.whl", hash = "sha256:ef8c048849f844c7db9ff4208cca9878a799bc5fcdd72954197ea11e64b37c97", size = 26965, upload-time = "2026-02-26T04:19:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/2e/8a/f46f59279c078b001374813f69987b43b7c3bd9df01981af545cf2d954d7/llama_index_llms_openai-0.6.26-py3-none-any.whl", hash = "sha256:2062ef505676d0a1c7c116c138c2f890aa7653619fc3ca697e47df7bd2ef8b3f", size = 28330, upload-time = "2026-03-05T02:53:40.421Z" }, ] [[package]] @@ -3285,23 +3285,23 @@ wheels = [ [[package]] name = "prek" -version = "0.3.3" +version = "0.3.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/f1/7613dc8347a33e40fc5b79eec6bc7d458d8bbc339782333d8433b665f86f/prek-0.3.3.tar.gz", hash = "sha256:117bd46ebeb39def24298ce021ccc73edcf697b81856fcff36d762dd56093f6f", size = 343697, upload-time = "2026-02-15T13:33:28.723Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/d6/277e002e56eeab3a9d48f1ca4cc067d249d6326fc1783b770d70ad5ae2be/prek-0.3.5.tar.gz", hash = "sha256:ca40b6685a4192256bc807f32237af94bf9b8799c0d708b98735738250685642", size = 374806, upload-time = "2026-03-09T10:35:18.842Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/8b/dce13d2a3065fd1e8ffce593a0e51c4a79c3cde9c9a15dc0acc8d9d1573d/prek-0.3.3-py3-none-linux_armv6l.whl", hash = "sha256:e8629cac4bdb131be8dc6e5a337f0f76073ad34a8305f3fe2bc1ab6201ede0a4", size = 4644636, upload-time = "2026-02-15T13:33:43.609Z" }, - { url = "https://files.pythonhosted.org/packages/01/30/06ab4dbe7ce02a8ce833e92deb1d9a8e85ae9d40e33d1959a2070b7494c6/prek-0.3.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4b9e819b9e4118e1e785047b1c8bd9aec7e4d836ed034cb58b7db5bcaaf49437", size = 4651410, upload-time = "2026-02-15T13:33:34.277Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fc/da3bc5cb38471e7192eda06b7a26b7c24ef83e82da2c1dbc145f2bf33640/prek-0.3.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bf29db3b5657c083eb8444c25aadeeec5167dc492e9019e188f87932f01ea50a", size = 4273163, upload-time = "2026-02-15T13:33:42.106Z" }, - { url = "https://files.pythonhosted.org/packages/b4/74/47839395091e2937beced81a5dd2f8ea9c8239c853da8611aaf78ee21a8b/prek-0.3.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:ae09736149815b26e64a9d350ca05692bab32c2afdf2939114d3211aaad68a3e", size = 4631808, upload-time = "2026-02-15T13:33:20.076Z" }, - { url = "https://files.pythonhosted.org/packages/e2/89/3f5ef6f7c928c017cb63b029349d6bc03598ab7f6979d4a770ce02575f82/prek-0.3.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:856c2b55c51703c366bb4ce81c6a91102b70573a9fc8637db2ac61c66e4565f9", size = 4548959, upload-time = "2026-02-15T13:33:36.325Z" }, - { url = "https://files.pythonhosted.org/packages/b2/18/80002c4c4475f90ca025f27739a016927a0e5d905c60612fc95da1c56ab7/prek-0.3.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3acdf13a018f685beaff0a71d4b0d2ccbab4eaa1aced6d08fd471c1a654183eb", size = 4862256, upload-time = "2026-02-15T13:33:37.754Z" }, - { url = "https://files.pythonhosted.org/packages/c5/25/648bf084c2468fa7cfcdbbe9e59956bbb31b81f36e113bc9107d80af26a7/prek-0.3.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0f035667a8bd0a77b2bfa2b2e125da8cb1793949e9eeef0d8daab7f8ac8b57fe", size = 5404486, upload-time = "2026-02-15T13:33:39.239Z" }, - { url = "https://files.pythonhosted.org/packages/8b/43/261fb60a11712a327da345912bd8b338dc5a050199de800faafa278a6133/prek-0.3.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d09b2ad14332eede441d977de08eb57fb3f61226ed5fd2ceb7aadf5afcdb6794", size = 4887513, upload-time = "2026-02-15T13:33:40.702Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2c/581e757ee57ec6046b32e0ee25660fc734bc2622c319f57119c49c0cab58/prek-0.3.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c0c3ffac16e37a9daba43a7e8316778f5809b70254be138761a8b5b9ef0df28e", size = 4632336, upload-time = "2026-02-15T13:33:25.867Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d8/aa276ce5d11b77882da4102ca0cb7161095831105043ae7979bbfdcc3dc4/prek-0.3.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a3dc7720b580c07c0386e17af2486a5b4bc2f6cc57034a288a614dcbc4abe555", size = 4679370, upload-time = "2026-02-15T13:33:22.247Z" }, - { url = "https://files.pythonhosted.org/packages/70/19/9d4fa7bde428e58d9f48a74290c08736d42aeb5690dcdccc7a713e34a449/prek-0.3.3-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:60e0fa15da5020a03df2ee40268145ec5b88267ec2141a205317ad4df8c992d6", size = 4540316, upload-time = "2026-02-15T13:33:24.088Z" }, - { url = "https://files.pythonhosted.org/packages/25/b5/973cce29257e0b47b16cc9b4c162772ea01dbb7c080791ea0c068e106e05/prek-0.3.3-py3-none-musllinux_1_1_i686.whl", hash = "sha256:553515da9586d9624dc42db32b744fdb91cf62b053753037a0cadb3c2d8d82a2", size = 4724566, upload-time = "2026-02-15T13:33:29.832Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8b/ad8b2658895a8ed2b0bc630bf38686fe38b7ff2c619c58953a80e4de3048/prek-0.3.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:9512cf370e0d1496503463a4a65621480efb41b487841a9e9ff1661edf14b238", size = 4995072, upload-time = "2026-02-15T13:33:27.417Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a9/16dd8d3a50362ebccffe58518af1f1f571c96f0695d7fcd8bbd386585f58/prek-0.3.5-py3-none-linux_armv6l.whl", hash = "sha256:44b3e12791805804f286d103682b42a84e0f98a2687faa37045e9d3375d3d73d", size = 5105604, upload-time = "2026-03-09T10:35:00.332Z" }, + { url = "https://files.pythonhosted.org/packages/e4/74/bc6036f5bf03860cda66ab040b32737e54802b71a81ec381839deb25df9e/prek-0.3.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3cb451cc51ac068974557491beb4c7d2d41dfde29ed559c1694c8ce23bf53e8", size = 5506155, upload-time = "2026-03-09T10:35:17.64Z" }, + { url = "https://files.pythonhosted.org/packages/02/d9/a3745c2a10509c63b6a118ada766614dd705efefd08f275804d5c807aa4a/prek-0.3.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ad8f5f0d8da53dc94d00b76979af312b3dacccc9dcbc6417756c5dca3633c052", size = 5100383, upload-time = "2026-03-09T10:35:13.302Z" }, + { url = "https://files.pythonhosted.org/packages/43/8e/de965fc515d39309a332789cd3778161f7bc80cde15070bedf17f9f8cb93/prek-0.3.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:4511e15d34072851ac88e4b2006868fbe13655059ad941d7a0ff9ee17138fd9f", size = 5334913, upload-time = "2026-03-09T10:35:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/44f07e8940256059cfd82520e3cbe0764ab06ddb4aa43148465db00b39ad/prek-0.3.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc0b63b8337e2046f51267facaac63ba755bc14aad53991840a5eccba3e5c28", size = 5033825, upload-time = "2026-03-09T10:35:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/94/85/3ff0f96881ff2360c212d310ff23c3cf5a15b223d34fcfa8cdcef203be69/prek-0.3.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5fc0d78c3896a674aeb8247a83bbda7efec85274dbdfbc978ceff8d37e4ed20", size = 5438586, upload-time = "2026-03-09T10:34:58.779Z" }, + { url = "https://files.pythonhosted.org/packages/79/a5/c6d08d31293400fcb5d427f8e7e6bacfc959988e868ad3a9d97b4d87c4b7/prek-0.3.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64cad21cb9072d985179495b77b312f6b81e7b45357d0c68dc1de66e0408eabc", size = 6359714, upload-time = "2026-03-09T10:34:57.454Z" }, + { url = "https://files.pythonhosted.org/packages/ba/18/321dcff9ece8065d42c8c1c7a53a23b45d2b4330aa70993be75dc5f2822f/prek-0.3.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45ee84199bb48e013bdfde0c84352c17a44cc42d5792681b86d94e9474aab6f8", size = 5717632, upload-time = "2026-03-09T10:35:08.634Z" }, + { url = "https://files.pythonhosted.org/packages/a3/7f/1288226aa381d0cea403157f4e6b64b356e1a745f2441c31dd9d8a1d63da/prek-0.3.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f43275e5d564e18e52133129ebeb5cb071af7ce4a547766c7f025aa0955dfbb6", size = 5339040, upload-time = "2026-03-09T10:35:03.665Z" }, + { url = "https://files.pythonhosted.org/packages/22/94/cfec83df9c2b8e7ed1608087bcf9538a6a77b4c2e7365123e9e0a3162cd1/prek-0.3.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:abcee520d31522bcbad9311f21326b447694cd5edba33618c25fd023fc9865ec", size = 5162586, upload-time = "2026-03-09T10:35:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/13/b7/741d62132f37a5f7cc0fad1168bd31f20dea9628f482f077f569547e0436/prek-0.3.5-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:499c56a94a155790c75a973d351a33f8065579d9094c93f6d451ada5d1e469be", size = 5002933, upload-time = "2026-03-09T10:35:16.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/83/630a5671df6550fcfa67c54955e8a8174eb9b4d97ac38fb05a362029245b/prek-0.3.5-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de1065b59f194624adc9dea269d4ff6b50e98a1b5bb662374a9adaa496b3c1eb", size = 5304934, upload-time = "2026-03-09T10:35:09.975Z" }, + { url = "https://files.pythonhosted.org/packages/de/79/67a7afd0c0b6c436630b7dba6e586a42d21d5d6e5778fbd9eba7bbd3dd26/prek-0.3.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a1c4869e45ee341735d07179da3a79fa2afb5959cef8b3c8a71906eb52dc6933", size = 5829914, upload-time = "2026-03-09T10:35:05.39Z" }, ] [[package]] @@ -3800,11 +3800,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -4035,88 +4035,88 @@ wheels = [ [[package]] name = "regex" -version = "2026.2.19" +version = "2026.2.28" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/c0/d8079d4f6342e4cec5c3e7d7415b5cd3e633d5f4124f7a4626908dbe84c7/regex-2026.2.19.tar.gz", hash = "sha256:6fb8cb09b10e38f3ae17cc6dc04a1df77762bd0351b6ba9041438e7cc85ec310", size = 414973, upload-time = "2026-02-19T19:03:47.899Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/93/43f405a98f54cc59c786efb4fc0b644615ed2392fc89d57d30da11f35b5b/regex-2026.2.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:93b16a18cadb938f0f2306267161d57eb33081a861cee9ffcd71e60941eb5dfc", size = 488365, upload-time = "2026-02-19T19:00:17.857Z" }, - { url = "https://files.pythonhosted.org/packages/66/46/da0efce22cd8f5ae28eeb25ac69703f49edcad3331ac22440776f4ea0867/regex-2026.2.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:78af1e499cab704131f6f4e2f155b7f54ce396ca2acb6ef21a49507e4752e0be", size = 290737, upload-time = "2026-02-19T19:00:19.869Z" }, - { url = "https://files.pythonhosted.org/packages/fb/19/f735078448132c1c974974d30d5306337bc297fe6b6f126164bff72c1019/regex-2026.2.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eb20c11aa4c3793c9ad04c19a972078cdadb261b8429380364be28e867a843f2", size = 288654, upload-time = "2026-02-19T19:00:21.307Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/6d7c24a2f423c03ad03e3fbddefa431057186ac1c4cb4fa98b03c7f39808/regex-2026.2.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db5fd91eec71e7b08de10011a2223d0faa20448d4e1380b9daa179fa7bf58906", size = 793785, upload-time = "2026-02-19T19:00:22.926Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/fdb8107504b3122a79bde6705ac1f9d495ed1fe35b87d7cfc1864471999a/regex-2026.2.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fdbade8acba71bb45057c2b72f477f0b527c4895f9c83e6cfc30d4a006c21726", size = 860731, upload-time = "2026-02-19T19:00:25.196Z" }, - { url = "https://files.pythonhosted.org/packages/9a/fd/cc8c6f05868defd840be6e75919b1c3f462357969ac2c2a0958363b4dc23/regex-2026.2.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:31a5f561eb111d6aae14202e7043fb0b406d3c8dddbbb9e60851725c9b38ab1d", size = 907350, upload-time = "2026-02-19T19:00:27.093Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1b/4590db9caa8db3d5a3fe31197c4e42c15aab3643b549ef6a454525fa3a61/regex-2026.2.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4584a3ee5f257b71e4b693cc9be3a5104249399f4116fe518c3f79b0c6fc7083", size = 800628, upload-time = "2026-02-19T19:00:29.392Z" }, - { url = "https://files.pythonhosted.org/packages/76/05/513eaa5b96fa579fd0b813e19ec047baaaf573d7374ff010fa139b384bf7/regex-2026.2.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:196553ba2a2f47904e5dc272d948a746352e2644005627467e055be19d73b39e", size = 773711, upload-time = "2026-02-19T19:00:30.996Z" }, - { url = "https://files.pythonhosted.org/packages/95/65/5aed06d8c54563d37fea496cf888be504879a3981a7c8e12c24b2c92c209/regex-2026.2.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0c10869d18abb759a3317c757746cc913d6324ce128b8bcec99350df10419f18", size = 783186, upload-time = "2026-02-19T19:00:34.598Z" }, - { url = "https://files.pythonhosted.org/packages/2c/57/79a633ad90f2371b4ef9cd72ba3a69a1a67d0cfaab4fe6fa8586d46044ef/regex-2026.2.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e689fed279cbe797a6b570bd18ff535b284d057202692c73420cb93cca41aa32", size = 854854, upload-time = "2026-02-19T19:00:37.306Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2d/0f113d477d9e91ec4545ec36c82e58be25038d06788229c91ad52da2b7f5/regex-2026.2.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0782bd983f19ac7594039c9277cd6f75c89598c1d72f417e4d30d874105eb0c7", size = 762279, upload-time = "2026-02-19T19:00:39.793Z" }, - { url = "https://files.pythonhosted.org/packages/39/cb/237e9fa4f61469fd4f037164dbe8e675a376c88cf73aaaa0aedfd305601c/regex-2026.2.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:dbb240c81cfed5d4a67cb86d7676d9f7ec9c3f186310bec37d8a1415210e111e", size = 846172, upload-time = "2026-02-19T19:00:42.134Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7c/104779c5915cc4eb557a33590f8a3f68089269c64287dd769afd76c7ce61/regex-2026.2.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80d31c3f1fe7e4c6cd1831cd4478a0609903044dfcdc4660abfe6fb307add7f0", size = 789078, upload-time = "2026-02-19T19:00:43.908Z" }, - { url = "https://files.pythonhosted.org/packages/b3/73/13b39c7c9356f333e564ab4790b6cb0df125b8e64e8d6474e73da49b1955/regex-2026.2.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c1665138776e4ac1aa75146669236f7a8a696433ec4e525abf092ca9189247cc", size = 489541, upload-time = "2026-02-19T19:00:52.728Z" }, - { url = "https://files.pythonhosted.org/packages/15/77/fcc7bd9a67000d07fbcc11ed226077287a40d5c84544e62171d29d3ef59c/regex-2026.2.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d792b84709021945597e05656aac059526df4e0c9ef60a0eaebb306f8fafcaa8", size = 291414, upload-time = "2026-02-19T19:00:54.51Z" }, - { url = "https://files.pythonhosted.org/packages/f9/87/3997fc72dc59233426ef2e18dfdd105bb123812fff740ee9cc348f1a3243/regex-2026.2.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db970bcce4d63b37b3f9eb8c893f0db980bbf1d404a1d8d2b17aa8189de92c53", size = 289140, upload-time = "2026-02-19T19:00:56.841Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d0/b7dd3883ed1cff8ee0c0c9462d828aaf12be63bf5dc55453cbf423523b13/regex-2026.2.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03d706fbe7dfec503c8c3cb76f9352b3e3b53b623672aa49f18a251a6c71b8e6", size = 798767, upload-time = "2026-02-19T19:00:59.014Z" }, - { url = "https://files.pythonhosted.org/packages/4a/7e/8e2d09103832891b2b735a2515abf377db21144c6dd5ede1fb03c619bf09/regex-2026.2.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dbff048c042beef60aa1848961384572c5afb9e8b290b0f1203a5c42cf5af65", size = 864436, upload-time = "2026-02-19T19:01:00.772Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2e/afea8d23a6db1f67f45e3a0da3057104ce32e154f57dd0c8997274d45fcd/regex-2026.2.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccaaf9b907ea6b4223d5cbf5fa5dff5f33dc66f4907a25b967b8a81339a6e332", size = 912391, upload-time = "2026-02-19T19:01:02.865Z" }, - { url = "https://files.pythonhosted.org/packages/59/3c/ea5a4687adaba5e125b9bd6190153d0037325a0ba3757cc1537cc2c8dd90/regex-2026.2.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75472631eee7898e16a8a20998d15106cb31cfde21cdf96ab40b432a7082af06", size = 803702, upload-time = "2026-02-19T19:01:05.298Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c5/624a0705e8473a26488ec1a3a4e0b8763ecfc682a185c302dfec71daea35/regex-2026.2.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d89f85a5ccc0cec125c24be75610d433d65295827ebaf0d884cbe56df82d4774", size = 775980, upload-time = "2026-02-19T19:01:07.047Z" }, - { url = "https://files.pythonhosted.org/packages/4d/4b/ed776642533232b5599b7c1f9d817fe11faf597e8a92b7a44b841daaae76/regex-2026.2.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9f81806abdca3234c3dd582b8a97492e93de3602c8772013cb4affa12d1668", size = 788122, upload-time = "2026-02-19T19:01:08.744Z" }, - { url = "https://files.pythonhosted.org/packages/8c/58/e93e093921d13b9784b4f69896b6e2a9e09580a265c59d9eb95e87d288f2/regex-2026.2.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9dadc10d1c2bbb1326e572a226d2ec56474ab8aab26fdb8cf19419b372c349a9", size = 858910, upload-time = "2026-02-19T19:01:10.488Z" }, - { url = "https://files.pythonhosted.org/packages/85/77/ff1d25a0c56cd546e0455cbc93235beb33474899690e6a361fa6b52d265b/regex-2026.2.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6bc25d7e15f80c9dc7853cbb490b91c1ec7310808b09d56bd278fe03d776f4f6", size = 764153, upload-time = "2026-02-19T19:01:12.156Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ef/8ec58df26d52d04443b1dc56f9be4b409f43ed5ae6c0248a287f52311fc4/regex-2026.2.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:965d59792f5037d9138da6fed50ba943162160443b43d4895b182551805aff9c", size = 850348, upload-time = "2026-02-19T19:01:14.147Z" }, - { url = "https://files.pythonhosted.org/packages/f5/b3/c42fd5ed91639ce5a4225b9df909180fc95586db071f2bf7c68d2ccbfbe6/regex-2026.2.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:38d88c6ed4a09ed61403dbdf515d969ccba34669af3961ceb7311ecd0cef504a", size = 789977, upload-time = "2026-02-19T19:01:15.838Z" }, - { url = "https://files.pythonhosted.org/packages/d2/2d/a849835e76ac88fcf9e8784e642d3ea635d183c4112150ca91499d6703af/regex-2026.2.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8df08decd339e8b3f6a2eb5c05c687fe9d963ae91f352bc57beb05f5b2ac6879", size = 489329, upload-time = "2026-02-19T19:01:23.841Z" }, - { url = "https://files.pythonhosted.org/packages/da/aa/78ff4666d3855490bae87845a5983485e765e1f970da20adffa2937b241d/regex-2026.2.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3aa0944f1dc6e92f91f3b306ba7f851e1009398c84bfd370633182ee4fc26a64", size = 291308, upload-time = "2026-02-19T19:01:25.605Z" }, - { url = "https://files.pythonhosted.org/packages/cd/58/714384efcc07ae6beba528a541f6e99188c5cc1bc0295337f4e8a868296d/regex-2026.2.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c13228fbecb03eadbfd8f521732c5fda09ef761af02e920a3148e18ad0e09968", size = 289033, upload-time = "2026-02-19T19:01:27.243Z" }, - { url = "https://files.pythonhosted.org/packages/75/ec/6438a9344d2869cf5265236a06af1ca6d885e5848b6561e10629bc8e5a11/regex-2026.2.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d0e72703c60d68b18b27cde7cdb65ed2570ae29fb37231aa3076bfb6b1d1c13", size = 798798, upload-time = "2026-02-19T19:01:28.877Z" }, - { url = "https://files.pythonhosted.org/packages/c2/be/b1ce2d395e3fd2ce5f2fde2522f76cade4297cfe84cd61990ff48308749c/regex-2026.2.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46e69a4bf552e30e74a8aa73f473c87efcb7f6e8c8ece60d9fd7bf13d5c86f02", size = 864444, upload-time = "2026-02-19T19:01:30.933Z" }, - { url = "https://files.pythonhosted.org/packages/d5/97/a3406460c504f7136f140d9461960c25f058b0240e4424d6fb73c7a067ab/regex-2026.2.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8edda06079bd770f7f0cf7f3bba1a0b447b96b4a543c91fe0c142d034c166161", size = 912633, upload-time = "2026-02-19T19:01:32.744Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d9/e5dbef95008d84e9af1dc0faabbc34a7fbc8daa05bc5807c5cf86c2bec49/regex-2026.2.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cbc69eae834afbf634f7c902fc72ff3e993f1c699156dd1af1adab5d06b7fe7", size = 803718, upload-time = "2026-02-19T19:01:34.61Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e5/61d80132690a1ef8dc48e0f44248036877aebf94235d43f63a20d1598888/regex-2026.2.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bcf57d30659996ee5c7937999874504c11b5a068edc9515e6a59221cc2744dd1", size = 775975, upload-time = "2026-02-19T19:01:36.525Z" }, - { url = "https://files.pythonhosted.org/packages/05/32/ae828b3b312c972cf228b634447de27237d593d61505e6ad84723f8eabba/regex-2026.2.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8e6e77cd92216eb489e21e5652a11b186afe9bdefca8a2db739fd6b205a9e0a4", size = 788129, upload-time = "2026-02-19T19:01:38.498Z" }, - { url = "https://files.pythonhosted.org/packages/cb/25/d74f34676f22bec401eddf0e5e457296941e10cbb2a49a571ca7a2c16e5a/regex-2026.2.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b9ab8dec42afefa6314ea9b31b188259ffdd93f433d77cad454cd0b8d235ce1c", size = 858818, upload-time = "2026-02-19T19:01:40.409Z" }, - { url = "https://files.pythonhosted.org/packages/1e/eb/0bc2b01a6b0b264e1406e5ef11cae3f634c3bd1a6e61206fd3227ce8e89c/regex-2026.2.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:294c0fb2e87c6bcc5f577c8f609210f5700b993151913352ed6c6af42f30f95f", size = 764186, upload-time = "2026-02-19T19:01:43.009Z" }, - { url = "https://files.pythonhosted.org/packages/eb/37/5fe5a630d0d99ecf0c3570f8905dafbc160443a2d80181607770086c9812/regex-2026.2.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c0924c64b082d4512b923ac016d6e1dcf647a3560b8a4c7e55cbbd13656cb4ed", size = 850363, upload-time = "2026-02-19T19:01:45.015Z" }, - { url = "https://files.pythonhosted.org/packages/c3/45/ef68d805294b01ec030cfd388724ba76a5a21a67f32af05b17924520cb0b/regex-2026.2.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:790dbf87b0361606cb0d79b393c3e8f4436a14ee56568a7463014565d97da02a", size = 790026, upload-time = "2026-02-19T19:01:47.51Z" }, - { url = "https://files.pythonhosted.org/packages/a9/a2/e0b4575b93bc84db3b1fab24183e008691cd2db5c0ef14ed52681fbd94dd/regex-2026.2.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:93d881cab5afdc41a005dba1524a40947d6f7a525057aa64aaf16065cf62faa9", size = 492202, upload-time = "2026-02-19T19:01:54.816Z" }, - { url = "https://files.pythonhosted.org/packages/24/b5/b84fec8cbb5f92a7eed2b6b5353a6a9eed9670fee31817c2da9eb85dc797/regex-2026.2.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:80caaa1ddcc942ec7be18427354f9d58a79cee82dea2a6b3d4fd83302e1240d7", size = 292884, upload-time = "2026-02-19T19:01:58.254Z" }, - { url = "https://files.pythonhosted.org/packages/70/0c/fe89966dfae43da46f475362401f03e4d7dc3a3c955b54f632abc52669e0/regex-2026.2.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d793c5b4d2b4c668524cd1651404cfc798d40694c759aec997e196fe9729ec60", size = 291236, upload-time = "2026-02-19T19:01:59.966Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f7/bda2695134f3e63eb5cccbbf608c2a12aab93d261ff4e2fe49b47fabc948/regex-2026.2.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5100acb20648d9efd3f4e7e91f51187f95f22a741dcd719548a6cf4e1b34b3f", size = 807660, upload-time = "2026-02-19T19:02:01.632Z" }, - { url = "https://files.pythonhosted.org/packages/11/56/6e3a4bf5e60d17326b7003d91bbde8938e439256dec211d835597a44972d/regex-2026.2.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e3a31e94d10e52a896adaa3adf3621bd526ad2b45b8c2d23d1bbe74c7423007", size = 873585, upload-time = "2026-02-19T19:02:03.522Z" }, - { url = "https://files.pythonhosted.org/packages/35/5e/c90c6aa4d1317cc11839359479cfdd2662608f339e84e81ba751c8a4e461/regex-2026.2.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8497421099b981f67c99eba4154cf0dfd8e47159431427a11cfb6487f7791d9e", size = 915243, upload-time = "2026-02-19T19:02:05.608Z" }, - { url = "https://files.pythonhosted.org/packages/90/7c/981ea0694116793001496aaf9524e5c99e122ec3952d9e7f1878af3a6bf1/regex-2026.2.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e7a08622f7d51d7a068f7e4052a38739c412a3e74f55817073d2e2418149619", size = 812922, upload-time = "2026-02-19T19:02:08.115Z" }, - { url = "https://files.pythonhosted.org/packages/2d/be/9eda82afa425370ffdb3fa9f3ea42450b9ae4da3ff0a4ec20466f69e371b/regex-2026.2.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8abe671cf0f15c26b1ad389bf4043b068ce7d3b1c5d9313e12895f57d6738555", size = 781318, upload-time = "2026-02-19T19:02:10.072Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d5/50f0bbe56a8199f60a7b6c714e06e54b76b33d31806a69d0703b23ce2a9e/regex-2026.2.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5a8f28dd32a4ce9c41758d43b5b9115c1c497b4b1f50c457602c1d571fa98ce1", size = 795649, upload-time = "2026-02-19T19:02:11.96Z" }, - { url = "https://files.pythonhosted.org/packages/c5/09/d039f081e44a8b0134d0bb2dd805b0ddf390b69d0b58297ae098847c572f/regex-2026.2.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:654dc41a5ba9b8cc8432b3f1aa8906d8b45f3e9502442a07c2f27f6c63f85db5", size = 868844, upload-time = "2026-02-19T19:02:14.043Z" }, - { url = "https://files.pythonhosted.org/packages/ef/53/e2903b79a19ec8557fe7cd21cd093956ff2dbc2e0e33969e3adbe5b184dd/regex-2026.2.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4a02faea614e7fdd6ba8b3bec6c8e79529d356b100381cec76e638f45d12ca04", size = 770113, upload-time = "2026-02-19T19:02:16.161Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e2/784667767b55714ebb4e59bf106362327476b882c0b2f93c25e84cc99b1a/regex-2026.2.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d96162140bb819814428800934c7b71b7bffe81fb6da2d6abc1dcca31741eca3", size = 854922, upload-time = "2026-02-19T19:02:18.155Z" }, - { url = "https://files.pythonhosted.org/packages/59/78/9ef4356bd4aed752775bd18071034979b85f035fec51f3a4f9dea497a254/regex-2026.2.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c227f2922153ee42bbeb355fd6d009f8c81d9d7bdd666e2276ce41f53ed9a743", size = 799636, upload-time = "2026-02-19T19:02:20.04Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e2/7ad4e76a6dddefc0d64dbe12a4d3ca3947a19ddc501f864a5df2a8222ddd/regex-2026.2.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:03d191a9bcf94d31af56d2575210cb0d0c6a054dbcad2ea9e00aa4c42903b919", size = 489306, upload-time = "2026-02-19T19:02:29.058Z" }, - { url = "https://files.pythonhosted.org/packages/14/95/ee1736135733afbcf1846c58671046f99c4d5170102a150ebb3dd8d701d9/regex-2026.2.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:516ee067c6c721d0d0bfb80a2004edbd060fffd07e456d4e1669e38fe82f922e", size = 291218, upload-time = "2026-02-19T19:02:31.083Z" }, - { url = "https://files.pythonhosted.org/packages/ef/08/180d1826c3d7065200a5168c6b993a44947395c7bb6e04b2c2a219c34225/regex-2026.2.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:997862c619994c4a356cb7c3592502cbd50c2ab98da5f61c5c871f10f22de7e5", size = 289097, upload-time = "2026-02-19T19:02:33.485Z" }, - { url = "https://files.pythonhosted.org/packages/28/93/0651924c390c5740f5f896723f8ddd946a6c63083a7d8647231c343912ff/regex-2026.2.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02b9e1b8a7ebe2807cd7bbdf662510c8e43053a23262b9f46ad4fc2dfc9d204e", size = 799147, upload-time = "2026-02-19T19:02:35.669Z" }, - { url = "https://files.pythonhosted.org/packages/a7/00/2078bd8bcd37d58a756989adbfd9f1d0151b7ca4085a9c2a07e917fbac61/regex-2026.2.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c8fb3b19652e425ff24169dad3ee07f99afa7996caa9dfbb3a9106cd726f49a", size = 865239, upload-time = "2026-02-19T19:02:38.012Z" }, - { url = "https://files.pythonhosted.org/packages/2a/13/75195161ec16936b35a365fa8c1dd2ab29fd910dd2587765062b174d8cfc/regex-2026.2.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50f1ee9488dd7a9fda850ec7c68cad7a32fa49fd19733f5403a3f92b451dcf73", size = 911904, upload-time = "2026-02-19T19:02:40.737Z" }, - { url = "https://files.pythonhosted.org/packages/96/72/ac42f6012179343d1c4bd0ffee8c948d841cb32ea188d37e96d80527fcc9/regex-2026.2.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab780092b1424d13200aa5a62996e95f65ee3db8509be366437439cdc0af1a9f", size = 803518, upload-time = "2026-02-19T19:02:42.923Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d1/75a08e2269b007b9783f0f86aa64488e023141219cb5f14dc1e69cda56c6/regex-2026.2.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17648e1a88e72d88641b12635e70e6c71c5136ba14edba29bf8fc6834005a265", size = 775866, upload-time = "2026-02-19T19:02:45.189Z" }, - { url = "https://files.pythonhosted.org/packages/92/41/70e7d05faf6994c2ca7a9fcaa536da8f8e4031d45b0ec04b57040ede201f/regex-2026.2.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f914ae8c804c8a8a562fe216100bc156bfb51338c1f8d55fe32cf407774359a", size = 788224, upload-time = "2026-02-19T19:02:47.804Z" }, - { url = "https://files.pythonhosted.org/packages/c8/83/34a2dd601f9deb13c20545c674a55f4a05c90869ab73d985b74d639bac43/regex-2026.2.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c7e121a918bbee3f12ac300ce0a0d2f2c979cf208fb071ed8df5a6323281915c", size = 859682, upload-time = "2026-02-19T19:02:50.583Z" }, - { url = "https://files.pythonhosted.org/packages/8e/30/136db9a09a7f222d6e48b806f3730e7af6499a8cad9c72ac0d49d52c746e/regex-2026.2.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2fedd459c791da24914ecc474feecd94cf7845efb262ac3134fe27cbd7eda799", size = 764223, upload-time = "2026-02-19T19:02:52.777Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/bb947743c78a16df481fa0635c50aa1a439bb80b0e6dc24cd4e49c716679/regex-2026.2.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ea8dfc99689240e61fb21b5fc2828f68b90abf7777d057b62d3166b7c1543c4c", size = 850101, upload-time = "2026-02-19T19:02:55.87Z" }, - { url = "https://files.pythonhosted.org/packages/25/27/e3bfe6e97a99f7393665926be02fef772da7f8aa59e50bc3134e4262a032/regex-2026.2.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fff45852160960f29e184ec8a5be5ab4063cfd0b168d439d1fc4ac3744bf29e", size = 789904, upload-time = "2026-02-19T19:02:58.523Z" }, - { url = "https://files.pythonhosted.org/packages/49/0b/f901cfeb4efd83e4f5c3e9f91a6de77e8e5ceb18555698aca3a27e215ed3/regex-2026.2.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5ec1d7c080832fdd4e150c6f5621fe674c70c63b3ae5a4454cebd7796263b175", size = 492196, upload-time = "2026-02-19T19:03:08.188Z" }, - { url = "https://files.pythonhosted.org/packages/94/0a/349b959e3da874e15eda853755567b4cde7e5309dbb1e07bfe910cfde452/regex-2026.2.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8457c1bc10ee9b29cdfd897ccda41dce6bde0e9abd514bcfef7bcd05e254d411", size = 292878, upload-time = "2026-02-19T19:03:10.272Z" }, - { url = "https://files.pythonhosted.org/packages/98/b0/9d81b3c2c5ddff428f8c506713737278979a2c476f6e3675a9c51da0c389/regex-2026.2.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cce8027010d1ffa3eb89a0b19621cdc78ae548ea2b49fea1f7bfb3ea77064c2b", size = 291235, upload-time = "2026-02-19T19:03:12.5Z" }, - { url = "https://files.pythonhosted.org/packages/04/e7/be7818df8691dbe9508c381ea2cc4c1153e4fdb1c4b06388abeaa93bd712/regex-2026.2.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11c138febb40546ff9e026dbbc41dc9fb8b29e61013fa5848ccfe045f5b23b83", size = 807893, upload-time = "2026-02-19T19:03:15.064Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b6/b898a8b983190cfa0276031c17beb73cfd1db07c03c8c37f606d80b655e2/regex-2026.2.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:74ff212aa61532246bb3036b3dfea62233414b0154b8bc3676975da78383cac3", size = 873696, upload-time = "2026-02-19T19:03:17.848Z" }, - { url = "https://files.pythonhosted.org/packages/1a/98/126ba671d54f19080ec87cad228fb4f3cc387fff8c4a01cb4e93f4ff9d94/regex-2026.2.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d00c95a2b6bfeb3ea1cb68d1751b1dfce2b05adc2a72c488d77a780db06ab867", size = 915493, upload-time = "2026-02-19T19:03:20.343Z" }, - { url = "https://files.pythonhosted.org/packages/b2/10/550c84a1a1a7371867fe8be2bea7df55e797cbca4709974811410e195c5d/regex-2026.2.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311fcccb76af31be4c588d5a17f8f1a059ae8f4b097192896ebffc95612f223a", size = 813094, upload-time = "2026-02-19T19:03:23.287Z" }, - { url = "https://files.pythonhosted.org/packages/29/fb/ba221d2fc76a27b6b7d7a60f73a7a6a7bac21c6ba95616a08be2bcb434b0/regex-2026.2.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77cfd6b5e7c4e8bf7a39d243ea05882acf5e3c7002b0ef4756de6606893b0ecd", size = 781583, upload-time = "2026-02-19T19:03:26.872Z" }, - { url = "https://files.pythonhosted.org/packages/26/f1/af79231301297c9e962679efc04a31361b58dc62dec1fc0cb4b8dd95956a/regex-2026.2.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6380f29ff212ec922b6efb56100c089251940e0526a0d05aa7c2d9b571ddf2fe", size = 795875, upload-time = "2026-02-19T19:03:29.223Z" }, - { url = "https://files.pythonhosted.org/packages/a0/90/1e1d76cb0a2d0a4f38a039993e1c5cd971ae50435d751c5bae4f10e1c302/regex-2026.2.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:655f553a1fa3ab8a7fd570eca793408b8d26a80bfd89ed24d116baaf13a38969", size = 868916, upload-time = "2026-02-19T19:03:31.415Z" }, - { url = "https://files.pythonhosted.org/packages/9a/67/a1c01da76dbcfed690855a284c665cc0a370e7d02d1bd635cf9ff7dd74b8/regex-2026.2.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:015088b8558502f1f0bccd58754835aa154a7a5b0bd9d4c9b7b96ff4ae9ba876", size = 770386, upload-time = "2026-02-19T19:03:33.972Z" }, - { url = "https://files.pythonhosted.org/packages/49/6f/94842bf294f432ff3836bfd91032e2ecabea6d284227f12d1f935318c9c4/regex-2026.2.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9e6693b8567a59459b5dda19104c4a4dbbd4a1c78833eacc758796f2cfef1854", size = 855007, upload-time = "2026-02-19T19:03:36.238Z" }, - { url = "https://files.pythonhosted.org/packages/ff/93/393cd203ca0d1d368f05ce12d2c7e91a324bc93c240db2e6d5ada05835f4/regex-2026.2.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4071209fd4376ab5ceec72ad3507e9d3517c59e38a889079b98916477a871868", size = 799863, upload-time = "2026-02-19T19:03:38.497Z" }, + { url = "https://files.pythonhosted.org/packages/04/db/8cbfd0ba3f302f2d09dd0019a9fcab74b63fee77a76c937d0e33161fb8c1/regex-2026.2.28-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e621fb7c8dc147419b28e1702f58a0177ff8308a76fa295c71f3e7827849f5d9", size = 488462, upload-time = "2026-02-28T02:16:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/5d/10/ccc22c52802223f2368731964ddd117799e1390ffc39dbb31634a83022ee/regex-2026.2.28-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0d5bef2031cbf38757a0b0bc4298bb4824b6332d28edc16b39247228fbdbad97", size = 290774, upload-time = "2026-02-28T02:16:23.993Z" }, + { url = "https://files.pythonhosted.org/packages/62/b9/6796b3bf3101e64117201aaa3a5a030ec677ecf34b3cd6141b5d5c6c67d5/regex-2026.2.28-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bcb399ed84eabf4282587ba151f2732ad8168e66f1d3f85b1d038868fe547703", size = 288724, upload-time = "2026-02-28T02:16:25.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/02/291c0ae3f3a10cea941d0f5366da1843d8d1fa8a25b0671e20a0e454bb38/regex-2026.2.28-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1b34dfa72f826f535b20712afa9bb3ba580020e834f3c69866c5bddbf10098", size = 791924, upload-time = "2026-02-28T02:16:26.863Z" }, + { url = "https://files.pythonhosted.org/packages/0f/57/f0235cc520d9672742196c5c15098f8f703f2758d48d5a7465a56333e496/regex-2026.2.28-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:851fa70df44325e1e4cdb79c5e676e91a78147b1b543db2aec8734d2add30ec2", size = 860095, upload-time = "2026-02-28T02:16:28.772Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/393c94cbedda79a0f5f2435ebd01644aba0b338d327eb24b4aa5b8d6c07f/regex-2026.2.28-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:516604edd17b1c2c3e579cf4e9b25a53bf8fa6e7cedddf1127804d3e0140ca64", size = 906583, upload-time = "2026-02-28T02:16:30.977Z" }, + { url = "https://files.pythonhosted.org/packages/2c/73/a72820f47ca5abf2b5d911d0407ba5178fc52cf9780191ed3a54f5f419a2/regex-2026.2.28-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7ce83654d1ab701cb619285a18a8e5a889c1216d746ddc710c914ca5fd71022", size = 800234, upload-time = "2026-02-28T02:16:32.55Z" }, + { url = "https://files.pythonhosted.org/packages/34/b3/6e6a4b7b31fa998c4cf159a12cbeaf356386fbd1a8be743b1e80a3da51e4/regex-2026.2.28-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2791948f7c70bb9335a9102df45e93d428f4b8128020d85920223925d73b9e1", size = 772803, upload-time = "2026-02-28T02:16:34.029Z" }, + { url = "https://files.pythonhosted.org/packages/10/e7/5da0280c765d5a92af5e1cd324b3fe8464303189cbaa449de9a71910e273/regex-2026.2.28-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03a83cc26aa2acda6b8b9dfe748cf9e84cbd390c424a1de34fdcef58961a297a", size = 781117, upload-time = "2026-02-28T02:16:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/76/39/0b8d7efb256ae34e1b8157acc1afd8758048a1cf0196e1aec2e71fd99f4b/regex-2026.2.28-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ec6f5674c5dc836994f50f1186dd1fafde4be0666aae201ae2fcc3d29d8adf27", size = 854224, upload-time = "2026-02-28T02:16:38.119Z" }, + { url = "https://files.pythonhosted.org/packages/21/ff/a96d483ebe8fe6d1c67907729202313895d8de8495569ec319c6f29d0438/regex-2026.2.28-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:50c2fc924749543e0eacc93ada6aeeb3ea5f6715825624baa0dccaec771668ae", size = 761898, upload-time = "2026-02-28T02:16:40.333Z" }, + { url = "https://files.pythonhosted.org/packages/89/bd/d4f2e75cb4a54b484e796017e37c0d09d8a0a837de43d17e238adf163f4e/regex-2026.2.28-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ba55c50f408fb5c346a3a02d2ce0ebc839784e24f7c9684fde328ff063c3cdea", size = 844832, upload-time = "2026-02-28T02:16:41.875Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/428a135cf5e15e4e11d1e696eb2bf968362f8ea8a5f237122e96bc2ae950/regex-2026.2.28-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:edb1b1b3a5576c56f08ac46f108c40333f222ebfd5cf63afdfa3aab0791ebe5b", size = 788347, upload-time = "2026-02-28T02:16:43.472Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574, upload-time = "2026-02-28T02:16:50.455Z" }, + { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426, upload-time = "2026-02-28T02:16:52.52Z" }, + { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200, upload-time = "2026-02-28T02:16:54.08Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765, upload-time = "2026-02-28T02:16:55.905Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093, upload-time = "2026-02-28T02:16:58.094Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455, upload-time = "2026-02-28T02:17:00.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037, upload-time = "2026-02-28T02:17:02.842Z" }, + { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113, upload-time = "2026-02-28T02:17:04.506Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194, upload-time = "2026-02-28T02:17:06.888Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846, upload-time = "2026-02-28T02:17:09.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516, upload-time = "2026-02-28T02:17:11.004Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278, upload-time = "2026-02-28T02:17:12.693Z" }, + { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068, upload-time = "2026-02-28T02:17:14.9Z" }, + { url = "https://files.pythonhosted.org/packages/87/f6/dc9ef48c61b79c8201585bf37fa70cd781977da86e466cd94e8e95d2443b/regex-2026.2.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6d63a07e5ec8ce7184452cb00c41c37b49e67dc4f73b2955b5b8e782ea970784", size = 489311, upload-time = "2026-02-28T02:17:22.591Z" }, + { url = "https://files.pythonhosted.org/packages/95/c8/c20390f2232d3f7956f420f4ef1852608ad57aa26c3dd78516cb9f3dc913/regex-2026.2.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e59bc8f30414d283ae8ee1617b13d8112e7135cb92830f0ec3688cb29152585a", size = 291285, upload-time = "2026-02-28T02:17:24.355Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a6/ba1068a631ebd71a230e7d8013fcd284b7c89c35f46f34a7da02082141b1/regex-2026.2.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de0cf053139f96219ccfabb4a8dd2d217c8c82cb206c91d9f109f3f552d6b43d", size = 289051, upload-time = "2026-02-28T02:17:26.722Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1b/7cc3b7af4c244c204b7a80924bd3d85aecd9ba5bc82b485c5806ee8cda9e/regex-2026.2.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb4db2f17e6484904f986c5a657cec85574c76b5c5e61c7aae9ffa1bc6224f95", size = 796842, upload-time = "2026-02-28T02:17:29.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/87/26bd03efc60e0d772ac1e7b60a2e6325af98d974e2358f659c507d3c76db/regex-2026.2.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52b017b35ac2214d0db5f4f90e303634dc44e4aba4bd6235a27f97ecbe5b0472", size = 863083, upload-time = "2026-02-28T02:17:31.363Z" }, + { url = "https://files.pythonhosted.org/packages/ae/54/aeaf4afb1aa0a65e40de52a61dc2ac5b00a83c6cb081c8a1d0dda74f3010/regex-2026.2.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69fc560ccbf08a09dc9b52ab69cacfae51e0ed80dc5693078bdc97db2f91ae96", size = 909412, upload-time = "2026-02-28T02:17:33.248Z" }, + { url = "https://files.pythonhosted.org/packages/12/2f/049901def913954e640d199bbc6a7ca2902b6aeda0e5da9d17f114100ec2/regex-2026.2.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e61eea47230eba62a31f3e8a0e3164d0f37ef9f40529fb2c79361bc6b53d2a92", size = 802101, upload-time = "2026-02-28T02:17:35.053Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/512fb9ff7f5b15ea204bb1967ebb649059446decacccb201381f9fa6aad4/regex-2026.2.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4f5c0b182ad4269e7381b7c27fdb0408399881f7a92a4624fd5487f2971dfc11", size = 775260, upload-time = "2026-02-28T02:17:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/9a92935878aba19bd72706b9db5646a6f993d99b3f6ed42c02ec8beb1d61/regex-2026.2.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96f6269a2882fbb0ee76967116b83679dc628e68eaea44e90884b8d53d833881", size = 784311, upload-time = "2026-02-28T02:17:39.855Z" }, + { url = "https://files.pythonhosted.org/packages/09/d3/fc51a8a738a49a6b6499626580554c9466d3ea561f2b72cfdc72e4149773/regex-2026.2.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b5acd4b6a95f37c3c3828e5d053a7d4edaedb85de551db0153754924cb7c83e3", size = 856876, upload-time = "2026-02-28T02:17:42.317Z" }, + { url = "https://files.pythonhosted.org/packages/08/b7/2e641f3d084b120ca4c52e8c762a78da0b32bf03ef546330db3e2635dc5f/regex-2026.2.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2234059cfe33d9813a3677ef7667999caea9eeaa83fef98eb6ce15c6cf9e0215", size = 763632, upload-time = "2026-02-28T02:17:45.073Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6d/0009021d97e79ee99f3d8641f0a8d001eed23479ade4c3125a5480bf3e2d/regex-2026.2.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c15af43c72a7fb0c97cbc66fa36a43546eddc5c06a662b64a0cbf30d6ac40944", size = 849320, upload-time = "2026-02-28T02:17:47.192Z" }, + { url = "https://files.pythonhosted.org/packages/05/7a/51cfbad5758f8edae430cb21961a9c8d04bce1dae4d2d18d4186eec7cfa1/regex-2026.2.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9185cc63359862a6e80fe97f696e04b0ad9a11c4ac0a4a927f979f611bfe3768", size = 790152, upload-time = "2026-02-28T02:17:49.067Z" }, + { url = "https://files.pythonhosted.org/packages/24/07/6c7e4cec1e585959e96cbc24299d97e4437a81173217af54f1804994e911/regex-2026.2.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:97054c55db06ab020342cc0d35d6f62a465fa7662871190175f1ad6c655c028f", size = 492541, upload-time = "2026-02-28T02:17:56.813Z" }, + { url = "https://files.pythonhosted.org/packages/7c/13/55eb22ada7f43d4f4bb3815b6132183ebc331c81bd496e2d1f3b8d862e0d/regex-2026.2.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d25a10811de831c2baa6aef3c0be91622f44dd8d31dd12e69f6398efb15e48b", size = 292984, upload-time = "2026-02-28T02:17:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/c301f8cb29ce9644a5ef85104c59244e6e7e90994a0f458da4d39baa8e17/regex-2026.2.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d6cfe798d8da41bb1862ed6e0cba14003d387c3c0c4a5d45591076ae9f0ce2f8", size = 291509, upload-time = "2026-02-28T02:18:00.208Z" }, + { url = "https://files.pythonhosted.org/packages/b5/43/aabe384ec1994b91796e903582427bc2ffaed9c4103819ed3c16d8e749f3/regex-2026.2.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd0ce43e71d825b7c0661f9c54d4d74bd97c56c3fd102a8985bcfea48236bacb", size = 809429, upload-time = "2026-02-28T02:18:02.328Z" }, + { url = "https://files.pythonhosted.org/packages/04/b8/8d2d987a816720c4f3109cee7c06a4b24ad0e02d4fc74919ab619e543737/regex-2026.2.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00945d007fd74a9084d2ab79b695b595c6b7ba3698972fadd43e23230c6979c1", size = 869422, upload-time = "2026-02-28T02:18:04.23Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/2c004509e763c0c3719f97c03eca26473bffb3868d54c5f280b8cd4f9e3d/regex-2026.2.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec23c11cbbf09a4df32fe50d57cbdd777bc442269b6e39a1775654f1c95dee2", size = 915175, upload-time = "2026-02-28T02:18:06.791Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/fd429066da487ef555a9da73bf214894aec77fc8c66a261ee355a69871a8/regex-2026.2.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdcc17d935c8f9d3f4db5c2ebe2640c332e3822ad5d23c2f8e0228e6947943a", size = 812044, upload-time = "2026-02-28T02:18:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ca/feedb7055c62a3f7f659971bf45f0e0a87544b6b0cf462884761453f97c5/regex-2026.2.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a448af01e3d8031c89c5d902040b124a5e921a25c4e5e07a861ca591ce429341", size = 782056, upload-time = "2026-02-28T02:18:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/1aa959ed0d25c1dd7dd5047ea8ba482ceaef38ce363c401fd32a6b923e60/regex-2026.2.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:10d28e19bd4888e4abf43bd3925f3c134c52fdf7259219003588a42e24c2aa25", size = 798743, upload-time = "2026-02-28T02:18:13.025Z" }, + { url = "https://files.pythonhosted.org/packages/3b/1f/dadb9cf359004784051c897dcf4d5d79895f73a1bbb7b827abaa4814ae80/regex-2026.2.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:99985a2c277dcb9ccb63f937451af5d65177af1efdeb8173ac55b61095a0a05c", size = 864633, upload-time = "2026-02-28T02:18:16.84Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f1/b9a25eb24e1cf79890f09e6ec971ee5b511519f1851de3453bc04f6c902b/regex-2026.2.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e1e7b24cb3ae9953a560c563045d1ba56ee4749fbd05cf21ba571069bd7be81b", size = 770862, upload-time = "2026-02-28T02:18:18.892Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/c5cb10b7aa6f182f9247a30cc9527e326601f46f4df864ac6db588d11fcd/regex-2026.2.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d8511a01d0e4ee1992eb3ba19e09bc1866fe03f05129c3aec3fdc4cbc77aad3f", size = 854788, upload-time = "2026-02-28T02:18:21.475Z" }, + { url = "https://files.pythonhosted.org/packages/0a/50/414ba0731c4bd40b011fa4703b2cc86879ec060c64f2a906e65a56452589/regex-2026.2.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:aaffaecffcd2479ce87aa1e74076c221700b7c804e48e98e62500ee748f0f550", size = 800184, upload-time = "2026-02-28T02:18:23.492Z" }, + { url = "https://files.pythonhosted.org/packages/cf/03/691015f7a7cb1ed6dacb2ea5de5682e4858e05a4c5506b2839cd533bbcd6/regex-2026.2.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:78454178c7df31372ea737996fb7f36b3c2c92cccc641d251e072478afb4babc", size = 489497, upload-time = "2026-02-28T02:18:30.889Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ba/8db8fd19afcbfa0e1036eaa70c05f20ca8405817d4ad7a38a6b4c2f031ac/regex-2026.2.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d10303dd18cedfd4d095543998404df656088240bcfd3cd20a8f95b861f74bd", size = 291295, upload-time = "2026-02-28T02:18:33.426Z" }, + { url = "https://files.pythonhosted.org/packages/5a/79/9aa0caf089e8defef9b857b52fc53801f62ff868e19e5c83d4a96612eba1/regex-2026.2.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:19a9c9e0a8f24f39d575a6a854d516b48ffe4cbdcb9de55cb0570a032556ecff", size = 289275, upload-time = "2026-02-28T02:18:35.247Z" }, + { url = "https://files.pythonhosted.org/packages/eb/26/ee53117066a30ef9c883bf1127eece08308ccf8ccd45c45a966e7a665385/regex-2026.2.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09500be324f49b470d907b3ef8af9afe857f5cca486f853853f7945ddbf75911", size = 797176, upload-time = "2026-02-28T02:18:37.15Z" }, + { url = "https://files.pythonhosted.org/packages/05/1b/67fb0495a97259925f343ae78b5d24d4a6624356ae138b57f18bd43006e4/regex-2026.2.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb1c4ff62277d87a7335f2c1ea4e0387b8f2b3ad88a64efd9943906aafad4f33", size = 863813, upload-time = "2026-02-28T02:18:39.478Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/93ac9bbafc53618091c685c7ed40239a90bf9f2a82c983f0baa97cb7ae07/regex-2026.2.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b8b3f1be1738feadc69f62daa250c933e85c6f34fa378f54a7ff43807c1b9117", size = 908678, upload-time = "2026-02-28T02:18:41.619Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/a8f5e0561702b25239846a16349feece59712ae20598ebb205580332a471/regex-2026.2.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc8ed8c3f41c27acb83f7b6a9eb727a73fc6663441890c5cb3426a5f6a91ce7d", size = 801528, upload-time = "2026-02-28T02:18:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/96/5d/ed6d4cbde80309854b1b9f42d9062fee38ade15f7eb4909f6ef2440403b5/regex-2026.2.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa539be029844c0ce1114762d2952ab6cfdd7c7c9bd72e0db26b94c3c36dcc5a", size = 775373, upload-time = "2026-02-28T02:18:46.102Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e9/6e53c34e8068b9deec3e87210086ecb5b9efebdefca6b0d3fa43d66dcecb/regex-2026.2.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7900157786428a79615a8264dac1f12c9b02957c473c8110c6b1f972dcecaddf", size = 784859, upload-time = "2026-02-28T02:18:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/48/3c/736e1c7ca7f0dcd2ae33819888fdc69058a349b7e5e84bc3e2f296bbf794/regex-2026.2.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0b1d2b07614d95fa2bf8a63fd1e98bd8fa2b4848dc91b1efbc8ba219fdd73952", size = 857813, upload-time = "2026-02-28T02:18:50.576Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/48c4659ad9da61f58e79dbe8c05223e0006696b603c16eb6b5cbfbb52c27/regex-2026.2.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b389c61aa28a79c2e0527ac36da579869c2e235a5b208a12c5b5318cda2501d8", size = 763705, upload-time = "2026-02-28T02:18:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a1/bc1c261789283128165f71b71b4b221dd1b79c77023752a6074c102f18d8/regex-2026.2.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f467cb602f03fbd1ab1908f68b53c649ce393fde056628dc8c7e634dab6bfc07", size = 848734, upload-time = "2026-02-28T02:18:54.595Z" }, + { url = "https://files.pythonhosted.org/packages/10/d8/979407faf1397036e25a5ae778157366a911c0f382c62501009f4957cf86/regex-2026.2.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c8cb2deba42f5ec1ede46374e990f8adc5e6456a57ac1a261b19be6f28e4e6", size = 789871, upload-time = "2026-02-28T02:18:57.34Z" }, + { url = "https://files.pythonhosted.org/packages/d3/eb/8389f9e940ac89bcf58d185e230a677b4fd07c5f9b917603ad5c0f8fa8fe/regex-2026.2.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a5dac14d0872eeb35260a8e30bac07ddf22adc1e3a0635b52b02e180d17c9c7e", size = 492546, upload-time = "2026-02-28T02:19:05.378Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c7/09441d27ce2a6fa6a61ea3150ea4639c1dcda9b31b2ea07b80d6937b24dd/regex-2026.2.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec0c608b7a7465ffadb344ed7c987ff2f11ee03f6a130b569aa74d8a70e8333c", size = 292986, upload-time = "2026-02-28T02:19:07.24Z" }, + { url = "https://files.pythonhosted.org/packages/fb/69/4144b60ed7760a6bd235e4087041f487aa4aa62b45618ce018b0c14833ea/regex-2026.2.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7815afb0ca45456613fdaf60ea9c993715511c8d53a83bc468305cbc0ee23c7", size = 291518, upload-time = "2026-02-28T02:19:09.698Z" }, + { url = "https://files.pythonhosted.org/packages/2d/be/77e5426cf5948c82f98c53582009ca9e94938c71f73a8918474f2e2990bb/regex-2026.2.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b059e71ec363968671693a78c5053bd9cb2fe410f9b8e4657e88377ebd603a2e", size = 809464, upload-time = "2026-02-28T02:19:12.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/99/2c8c5ac90dc7d05c6e7d8e72c6a3599dc08cd577ac476898e91ca787d7f1/regex-2026.2.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8cf76f1a29f0e99dcfd7aef1551a9827588aae5a737fe31442021165f1920dc", size = 869553, upload-time = "2026-02-28T02:19:15.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/34/daa66a342f0271e7737003abf6c3097aa0498d58c668dbd88362ef94eb5d/regex-2026.2.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:180e08a435a0319e6a4821c3468da18dc7001987e1c17ae1335488dfe7518dd8", size = 915289, upload-time = "2026-02-28T02:19:17.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c7/e22c2aaf0a12e7e22ab19b004bb78d32ca1ecc7ef245949935463c5567de/regex-2026.2.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e496956106fd59ba6322a8ea17141a27c5040e5ee8f9433ae92d4e5204462a0", size = 812156, upload-time = "2026-02-28T02:19:20.011Z" }, + { url = "https://files.pythonhosted.org/packages/7f/bb/2dc18c1efd9051cf389cd0d7a3a4d90f6804b9fff3a51b5dc3c85b935f71/regex-2026.2.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bba2b18d70eeb7b79950f12f633beeecd923f7c9ad6f6bae28e59b4cb3ab046b", size = 782215, upload-time = "2026-02-28T02:19:22.047Z" }, + { url = "https://files.pythonhosted.org/packages/17/1e/9e4ec9b9013931faa32226ec4aa3c71fe664a6d8a2b91ac56442128b332f/regex-2026.2.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6db7bfae0f8a2793ff1f7021468ea55e2699d0790eb58ee6ab36ae43aa00bc5b", size = 798925, upload-time = "2026-02-28T02:19:24.173Z" }, + { url = "https://files.pythonhosted.org/packages/71/57/a505927e449a9ccb41e2cc8d735e2abe3444b0213d1cf9cb364a8c1f2524/regex-2026.2.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0b02e8b7e5874b48ae0f077ecca61c1a6a9f9895e9c6dfb191b55b242862033", size = 864701, upload-time = "2026-02-28T02:19:26.376Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ad/c62cb60cdd93e13eac5b3d9d6bd5d284225ed0e3329426f94d2552dd7cca/regex-2026.2.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:25b6eb660c5cf4b8c3407a1ed462abba26a926cc9965e164268a3267bcc06a43", size = 770899, upload-time = "2026-02-28T02:19:29.38Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5a/874f861f5c3d5ab99633e8030dee1bc113db8e0be299d1f4b07f5b5ec349/regex-2026.2.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5a932ea8ad5d0430351ff9c76c8db34db0d9f53c1d78f06022a21f4e290c5c18", size = 854727, upload-time = "2026-02-28T02:19:31.494Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ca/d2c03b0efde47e13db895b975b2be6a73ed90b8ba963677927283d43bf74/regex-2026.2.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2c95e1a2b0f89d01e821ff4de1be4b5d73d1f4b0bf679fa27c1ad8d2327f1a", size = 800366, upload-time = "2026-02-28T02:19:34.248Z" }, ] [[package]] @@ -4241,24 +4241,24 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.4" +version = "0.15.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, - { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, - { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, - { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, - { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, - { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, - { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, - { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, - { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, + { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, + { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, + { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, ] [[package]] From 63cb75564e2575e1cc33db32c5413c602e498903 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:04:51 -0700 Subject: [PATCH 2/6] Chore: Remove some further old items (encryption passphrase and PNG handling) (#12290) --- scripts/post-consumption-example.sh | 2 -- .../management/commands/document_importer.py | 21 ++++-------------- .../management/commands/loaddata_stdin.py | 22 ------------------- .../tests/test_management_exporter.py | 4 ---- 4 files changed, 4 insertions(+), 45 deletions(-) delete mode 100644 src/documents/management/commands/loaddata_stdin.py diff --git a/scripts/post-consumption-example.sh b/scripts/post-consumption-example.sh index fbcd0b4cf..00517321a 100755 --- a/scripts/post-consumption-example.sh +++ b/scripts/post-consumption-example.sh @@ -19,6 +19,4 @@ following additional information about it: * Correspondent: ${DOCUMENT_CORRESPONDENT} * Tags: ${DOCUMENT_TAGS} -It was consumed with the passphrase ${PASSPHRASE} - " diff --git a/src/documents/management/commands/document_importer.py b/src/documents/management/commands/document_importer.py index fd6941213..20d50e41b 100644 --- a/src/documents/management/commands/document_importer.py +++ b/src/documents/management/commands/document_importer.py @@ -33,7 +33,6 @@ from documents.models import Document from documents.models import DocumentType from documents.models import Note from documents.models import Tag -from documents.parsers import run_convert from documents.settings import EXPORTER_ARCHIVE_NAME from documents.settings import EXPORTER_CRYPTO_SETTINGS_NAME from documents.settings import EXPORTER_FILE_NAME @@ -403,22 +402,10 @@ class Command(CryptMixin, PaperlessCommand): copy_file_with_basic_stats(document_path, document.source_path) if thumbnail_path: - if thumbnail_path.suffix in {".png", ".PNG"}: - run_convert( - density=300, - scale="500x5000>", - alpha="remove", - strip=True, - trim=False, - auto_orient=True, - input_file=f"{thumbnail_path}[0]", - output_file=str(document.thumbnail_path), - ) - else: - copy_file_with_basic_stats( - thumbnail_path, - document.thumbnail_path, - ) + copy_file_with_basic_stats( + thumbnail_path, + document.thumbnail_path, + ) if archive_path: create_source_path_directory(document.archive_path) diff --git a/src/documents/management/commands/loaddata_stdin.py b/src/documents/management/commands/loaddata_stdin.py deleted file mode 100644 index f6feb2e8d..000000000 --- a/src/documents/management/commands/loaddata_stdin.py +++ /dev/null @@ -1,22 +0,0 @@ -import sys - -from django.core.management.commands.loaddata import Command as LoadDataCommand - - -# This class is used to migrate data between databases -# That's difficult to test -class Command(LoadDataCommand): # pragma: no cover - """ - Allow the loading of data from standard in. Sourced originally from: - https://gist.github.com/bmispelon/ad5a2c333443b3a1d051 (MIT licensed) - """ - - def parse_name(self, fixture_name): - self.compression_formats["stdin"] = (lambda x, y: sys.stdin, None) - if fixture_name == "-": - return "-", "json", "stdin" - - def find_fixtures(self, fixture_label): - if fixture_label == "-": - return [("-", None, "-")] - return super().find_fixtures(fixture_label) diff --git a/src/documents/tests/test_management_exporter.py b/src/documents/tests/test_management_exporter.py index fb41bfa07..6cd8cd729 100644 --- a/src/documents/tests/test_management_exporter.py +++ b/src/documents/tests/test_management_exporter.py @@ -147,7 +147,6 @@ class TestExportImport( else: raise ValueError(f"document with id {id} does not exist in manifest") - @override_settings(PASSPHRASE="test") def _do_export( self, *, @@ -441,7 +440,6 @@ class TestExportImport( ) self.assertRaises(FileNotFoundError, call_command, "document_exporter", target) - @override_settings(PASSPHRASE="test") def test_export_zipped(self) -> None: """ GIVEN: @@ -473,7 +471,6 @@ class TestExportImport( self.assertIn("manifest.json", zip.namelist()) self.assertIn("metadata.json", zip.namelist()) - @override_settings(PASSPHRASE="test") def test_export_zipped_format(self) -> None: """ GIVEN: @@ -510,7 +507,6 @@ class TestExportImport( self.assertIn("manifest.json", zip.namelist()) self.assertIn("metadata.json", zip.namelist()) - @override_settings(PASSPHRASE="test") def test_export_zipped_with_delete(self) -> None: """ GIVEN: From 3e32e903559a3f4540c8ce68b496d3b61da1feab Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:36:22 -0700 Subject: [PATCH 3/6] Breaking: drop support for api versions < 9 (#12284) --- docs/api.md | 29 ++--- src/documents/serialisers.py | 115 ------------------ src/documents/tests/test_api_custom_fields.py | 107 ---------------- src/documents/tests/test_api_documents.py | 43 +------ .../tests/test_api_filter_by_custom_fields.py | 2 +- src/paperless/settings/__init__.py | 2 +- 6 files changed, 20 insertions(+), 278 deletions(-) diff --git a/docs/api.md b/docs/api.md index 11ce3a8d5..9703e8eb7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -369,41 +369,38 @@ operations, using the endpoint: `/api/bulk_edit_objects/`, which requires a json ## API Versioning -The REST API is versioned since Paperless-ngx 1.3.0. +The REST API is versioned. - Versioning ensures that changes to the API don't break older clients. - Clients specify the specific version of the API they wish to use with every request and Paperless will handle the request using the specified API version. -- Even if the underlying data model changes, older API versions will - always serve compatible data. -- If no version is specified, Paperless will serve version 1 to ensure - compatibility with older clients that do not request a specific API - version. +- Even if the underlying data model changes, supported older API + versions continue to serve compatible data. +- If no version is specified, Paperless serves the configured default + API version (currently `10`). +- Supported API versions are currently `9` and `10`. API versions are specified by submitting an additional HTTP `Accept` header with every request: ``` -Accept: application/json; version=6 +Accept: application/json; version=10 ``` -If an invalid version is specified, Paperless 1.3.0 will respond with -"406 Not Acceptable" and an error message in the body. Earlier -versions of Paperless will serve API version 1 regardless of whether a -version is specified via the `Accept` header. +If an invalid version is specified, Paperless responds with +`406 Not Acceptable` and an error message in the body. If a client wishes to verify whether it is compatible with any given server, the following procedure should be performed: -1. Perform an _authenticated_ request against any API endpoint. If the - server is on version 1.3.0 or newer, the server will add two custom - headers to the response: +1. Perform an _authenticated_ request against any API endpoint. The + server will add two custom headers to the response: ``` - X-Api-Version: 2 - X-Version: 1.3.0 + X-Api-Version: 10 + X-Version: ``` 2. Determine whether the client is compatible with this server based on diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index a04a8c655..1faa815e5 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -703,15 +703,6 @@ class StoragePathField(serializers.PrimaryKeyRelatedField): class CustomFieldSerializer(serializers.ModelSerializer): - def __init__(self, *args, **kwargs): - context = kwargs.get("context") - self.api_version = int( - context.get("request").version - if context and context.get("request") - else settings.REST_FRAMEWORK["DEFAULT_VERSION"], - ) - super().__init__(*args, **kwargs) - data_type = serializers.ChoiceField( choices=CustomField.FieldDataType, read_only=False, @@ -791,38 +782,6 @@ class CustomFieldSerializer(serializers.ModelSerializer): ) return super().validate(attrs) - def to_internal_value(self, data): - ret = super().to_internal_value(data) - - if ( - self.api_version < 7 - and ret.get("data_type", "") == CustomField.FieldDataType.SELECT - and isinstance(ret.get("extra_data", {}).get("select_options"), list) - ): - ret["extra_data"]["select_options"] = [ - { - "label": option, - "id": get_random_string(length=16), - } - for option in ret["extra_data"]["select_options"] - ] - - return ret - - def to_representation(self, instance): - ret = super().to_representation(instance) - - if ( - self.api_version < 7 - and instance.data_type == CustomField.FieldDataType.SELECT - ): - # Convert the select options with ids to a list of strings - ret["extra_data"]["select_options"] = [ - option["label"] for option in ret["extra_data"]["select_options"] - ] - - return ret - class ReadWriteSerializerMethodField(serializers.SerializerMethodField): """ @@ -937,50 +896,6 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer): return data - def get_api_version(self): - return int( - self.context.get("request").version - if self.context.get("request") - else settings.REST_FRAMEWORK["DEFAULT_VERSION"], - ) - - def to_internal_value(self, data): - ret = super().to_internal_value(data) - - if ( - self.get_api_version() < 7 - and ret.get("field").data_type == CustomField.FieldDataType.SELECT - and ret.get("value") is not None - ): - # Convert the index of the option in the field.extra_data["select_options"] - # list to the options unique id - ret["value"] = ret.get("field").extra_data["select_options"][ret["value"]][ - "id" - ] - - return ret - - def to_representation(self, instance): - ret = super().to_representation(instance) - - if ( - self.get_api_version() < 7 - and instance.field.data_type == CustomField.FieldDataType.SELECT - ): - # return the index of the option in the field.extra_data["select_options"] list - ret["value"] = next( - ( - idx - for idx, option in enumerate( - instance.field.extra_data["select_options"], - ) - if option["id"] == instance.value - ), - None, - ) - - return ret - class Meta: model = CustomFieldInstance fields = [ @@ -1004,20 +919,6 @@ class NotesSerializer(serializers.ModelSerializer): fields = ["id", "note", "created", "user"] ordering = ["-created"] - def to_representation(self, instance): - ret = super().to_representation(instance) - - request = self.context.get("request") - api_version = int( - request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"], - ) - - if api_version < 8 and "user" in ret: - user_id = ret["user"]["id"] - ret["user"] = user_id - - return ret - def _get_viewable_duplicates( document: Document, @@ -1172,22 +1073,6 @@ class DocumentSerializer( doc["content"] = getattr(instance, "effective_content") or "" if self.truncate_content and "content" in self.fields: doc["content"] = doc.get("content")[0:550] - - request = self.context.get("request") - api_version = int( - request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"], - ) - - if api_version < 9 and "created" in self.fields: - # provide created as a datetime for backwards compatibility - from django.utils import timezone - - doc["created"] = timezone.make_aware( - datetime.combine( - instance.created, - datetime.min.time(), - ), - ).isoformat() return doc def to_internal_value(self, data): diff --git a/src/documents/tests/test_api_custom_fields.py b/src/documents/tests/test_api_custom_fields.py index 9d9014b29..945f9e4d7 100644 --- a/src/documents/tests/test_api_custom_fields.py +++ b/src/documents/tests/test_api_custom_fields.py @@ -323,113 +323,6 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): mock_delay.assert_called_once_with(cf_select) - def test_custom_field_select_old_version(self) -> None: - """ - GIVEN: - - Nothing - WHEN: - - API post request is made for custom fields with api version header < 7 - - API get request is made for custom fields with api version header < 7 - THEN: - - The select options are created with unique ids - - The select options are returned in the old format - """ - resp = self.client.post( - self.ENDPOINT, - headers={"Accept": "application/json; version=6"}, - data=json.dumps( - { - "data_type": "select", - "name": "Select Field", - "extra_data": { - "select_options": [ - "Option 1", - "Option 2", - ], - }, - }, - ), - content_type="application/json", - ) - self.assertEqual(resp.status_code, status.HTTP_201_CREATED) - - field = CustomField.objects.get(name="Select Field") - self.assertEqual( - field.extra_data["select_options"], - [ - {"label": "Option 1", "id": ANY}, - {"label": "Option 2", "id": ANY}, - ], - ) - - resp = self.client.get( - f"{self.ENDPOINT}{field.id}/", - headers={"Accept": "application/json; version=6"}, - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - data = resp.json() - self.assertEqual( - data["extra_data"]["select_options"], - [ - "Option 1", - "Option 2", - ], - ) - - def test_custom_field_select_value_old_version(self) -> None: - """ - GIVEN: - - Existing document with custom field select - WHEN: - - API post request is made to add the field for document with api version header < 7 - - API get request is made for document with api version header < 7 - THEN: - - The select value is returned in the old format, the index of the option - """ - custom_field_select = CustomField.objects.create( - name="Select Field", - data_type=CustomField.FieldDataType.SELECT, - extra_data={ - "select_options": [ - {"label": "Option 1", "id": "abc-123"}, - {"label": "Option 2", "id": "def-456"}, - ], - }, - ) - - doc = Document.objects.create( - title="WOW", - content="the content", - checksum="123", - mime_type="application/pdf", - ) - - resp = self.client.patch( - f"/api/documents/{doc.id}/", - headers={"Accept": "application/json; version=6"}, - data=json.dumps( - { - "custom_fields": [ - {"field": custom_field_select.id, "value": 1}, - ], - }, - ), - content_type="application/json", - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - doc.refresh_from_db() - self.assertEqual(doc.custom_fields.first().value, "def-456") - - resp = self.client.get( - f"/api/documents/{doc.id}/", - headers={"Accept": "application/json; version=6"}, - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - data = resp.json() - self.assertEqual(data["custom_fields"][0]["value"], 1) - def test_create_custom_field_monetary_validation(self) -> None: """ GIVEN: diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index 9fa2982c5..2dda91e98 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -177,7 +177,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): results = response.data["results"] self.assertEqual(len(results[0]), 0) - def test_document_fields_api_version_8_respects_created(self) -> None: + def test_document_fields_respects_created(self) -> None: Document.objects.create( title="legacy", checksum="123", @@ -187,7 +187,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): response = self.client.get( "/api/documents/?fields=id", - headers={"Accept": "application/json; version=8"}, format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -197,25 +196,22 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): response = self.client.get( "/api/documents/?fields=id,created", - headers={"Accept": "application/json; version=8"}, format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) results = response.data["results"] self.assertIn("id", results[0]) self.assertIn("created", results[0]) - self.assertRegex(results[0]["created"], r"^2024-01-15T00:00:00.*$") + self.assertEqual(results[0]["created"], "2024-01-15") - def test_document_legacy_created_format(self) -> None: + def test_document_created_format(self) -> None: """ GIVEN: - Existing document WHEN: - - Document is requested with api version ≥ 9 - - Document is requested with api version < 9 + - Document is requested THEN: - Document created field is returned as date - - Document created field is returned as datetime """ doc = Document.objects.create( title="none", @@ -226,14 +222,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): response = self.client.get( f"/api/documents/{doc.pk}/", - headers={"Accept": "application/json; version=8"}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertRegex(response.data["created"], r"^2023-01-01T00:00:00.*$") - - response = self.client.get( - f"/api/documents/{doc.pk}/", - headers={"Accept": "application/json; version=9"}, ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["created"], "2023-01-01") @@ -2803,26 +2791,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): }, ) - def test_docnote_serializer_v7(self) -> None: - doc = Document.objects.create( - title="test", - mime_type="application/pdf", - content="this is a document which will have notes!", - ) - Note.objects.create( - note="This is a note.", - document=doc, - user=self.user, - ) - self.assertEqual( - self.client.get( - f"/api/documents/{doc.pk}/", - headers={"Accept": "application/json; version=7"}, - format="json", - ).data["notes"][0]["user"], - self.user.id, - ) - def test_create_note(self) -> None: """ GIVEN: @@ -3591,14 +3559,13 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): ) -class TestDocumentApiV2(DirectoriesMixin, APITestCase): +class TestDocumentApiTagColors(DirectoriesMixin, APITestCase): def setUp(self) -> None: super().setUp() self.user = User.objects.create_superuser(username="temp_admin") self.client.force_authenticate(user=self.user) - self.client.defaults["HTTP_ACCEPT"] = "application/json; version=2" def test_tag_validate_color(self) -> None: self.assertEqual( diff --git a/src/documents/tests/test_api_filter_by_custom_fields.py b/src/documents/tests/test_api_filter_by_custom_fields.py index 4fd755d58..b28fec777 100644 --- a/src/documents/tests/test_api_filter_by_custom_fields.py +++ b/src/documents/tests/test_api_filter_by_custom_fields.py @@ -152,7 +152,7 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase): context={ "request": types.SimpleNamespace( method="GET", - version="7", + version="9", ), }, ) diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index f247100e6..34d163dd7 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -155,7 +155,7 @@ REST_FRAMEWORK = { "DEFAULT_VERSION": "10", # match src-ui/src/environments/environment.prod.ts # Make sure these are ordered and that the most recent version appears # last. See api.md#api-versioning when adding new versions. - "ALLOWED_VERSIONS": ["2", "3", "4", "5", "6", "7", "8", "9", "10"], + "ALLOWED_VERSIONS": ["9", "10"], # DRF Spectacular default schema "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", } From 1221e7f21cab9d92c238d8a771f4862d9a1f9dd8 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:37:56 +0000 Subject: [PATCH 4/6] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 3de81cb62..9c28ad794 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-09 17:44+0000\n" +"POT-Creation-Date: 2026-03-09 22:37+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -1299,7 +1299,7 @@ msgstr "" msgid "workflow runs" msgstr "" -#: documents/serialisers.py:463 documents/serialisers.py:2482 +#: documents/serialisers.py:463 documents/serialisers.py:2367 msgid "Insufficient permissions." msgstr "" @@ -1307,39 +1307,39 @@ msgstr "" msgid "Invalid color." msgstr "" -#: documents/serialisers.py:2105 +#: documents/serialisers.py:1990 #, python-format msgid "File type %(type)s not supported" msgstr "" -#: documents/serialisers.py:2149 +#: documents/serialisers.py:2034 #, python-format msgid "Custom field id must be an integer: %(id)s" msgstr "" -#: documents/serialisers.py:2156 +#: documents/serialisers.py:2041 #, python-format msgid "Custom field with id %(id)s does not exist" msgstr "" -#: documents/serialisers.py:2173 documents/serialisers.py:2183 +#: documents/serialisers.py:2058 documents/serialisers.py:2068 msgid "" "Custom fields must be a list of integers or an object mapping ids to values." msgstr "" -#: documents/serialisers.py:2178 +#: documents/serialisers.py:2063 msgid "Some custom fields don't exist or were specified twice." msgstr "" -#: documents/serialisers.py:2325 +#: documents/serialisers.py:2210 msgid "Invalid variable detected." msgstr "" -#: documents/serialisers.py:2538 +#: documents/serialisers.py:2423 msgid "Duplicate document identifiers are not allowed." msgstr "" -#: documents/serialisers.py:2568 documents/views.py:3328 +#: documents/serialisers.py:2453 documents/views.py:3328 #, python-format msgid "Documents not found: %(ids)s" msgstr "" From 3856ec19c083613373f9d813e7652a12d18ea711 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:27:06 +0000 Subject: [PATCH 5/6] docker(deps): bump astral-sh/uv (#12265) Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.10.7-python3.12-trixie-slim to 0.10.8-python3.12-trixie-slim. - [Release notes](https://github.com/astral-sh/uv/releases) - [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/uv/compare/0.10.7...0.10.8) --- updated-dependencies: - dependency-name: astral-sh/uv dependency-version: 0.10.8-python3.12-trixie-slim dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ef5cdc257..ac6143162 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,7 @@ RUN set -eux \ # Purpose: Installs s6-overlay and rootfs # Comments: # - Don't leave anything extra in here either -FROM ghcr.io/astral-sh/uv:0.10.7-python3.12-trixie-slim AS s6-overlay-base +FROM ghcr.io/astral-sh/uv:0.10.9-python3.12-trixie-slim AS s6-overlay-base WORKDIR /usr/src/s6 From 86573fc1a05a1ab7b1813c6af9529dff3c3ec584 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:55:36 -0700 Subject: [PATCH 6/6] Chore: separate actions from bulk edit endpoint (#12286) --- docs/api.md | 51 +- .../document-detail.component.spec.ts | 48 +- .../document-detail.component.ts | 44 +- .../bulk-editor/bulk-editor.component.spec.ts | 33 +- .../bulk-editor/bulk-editor.component.ts | 140 ++-- .../services/rest/document.service.spec.ts | 82 +++ .../src/app/services/rest/document.service.ts | 89 ++- src/documents/serialisers.py | 129 +++- src/documents/tests/test_api_bulk_edit.py | 695 ++++++++---------- src/documents/tests/test_api_schema.py | 36 + src/documents/views.py | 389 ++++++++-- src/paperless/urls.py | 36 + 12 files changed, 1119 insertions(+), 653 deletions(-) diff --git a/docs/api.md b/docs/api.md index 9703e8eb7..334b1a75a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -305,52 +305,16 @@ The following methods are supported: - `"merge": true or false` (defaults to false) - The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including removing them) or be merged with existing permissions. -- `edit_pdf` - - Requires `parameters`: - - `"doc_ids": [DOCUMENT_ID]` A list of a single document ID to edit. - - `"operations": [OPERATION, ...]` A list of operations to perform on the documents. Each operation is a dictionary - with the following keys: - - `"page": PAGE_NUMBER` The page number to edit (1-based). - - `"rotate": DEGREES` Optional rotation in degrees (90, 180, 270). - - `"doc": OUTPUT_DOCUMENT_INDEX` Optional index of the output document for split operations. - - Optional `parameters`: - - `"delete_original": true` to delete the original documents after editing. - - `"update_document": true` to add the edited PDF as a new version of the root document. - - `"include_metadata": true` to copy metadata from the original document to the edited document. -- `remove_password` - - Requires `parameters`: - - `"password": "PASSWORD_STRING"` The password to remove from the PDF documents. - - Optional `parameters`: - - `"update_document": true` to add the password-less PDF as a new version of the root document. - - `"delete_original": true` to delete the original document after editing. - - `"include_metadata": true` to copy metadata from the original document to the new password-less document. -- `merge` - - No additional `parameters` required. - - The ordering of the merged document is determined by the list of IDs. - - Optional `parameters`: - - `"metadata_document_id": DOC_ID` apply metadata (tags, correspondent, etc.) from this document to the merged document. - - `"delete_originals": true` to delete the original documents. This requires the calling user being the owner of - all documents that are merged. -- `split` - - Requires `parameters`: - - `"pages": [..]` The list should be a list of pages and/or a ranges, separated by commas e.g. `"[1,2-3,4,5-7]"` - - Optional `parameters`: - - `"delete_originals": true` to delete the original document after consumption. This requires the calling user being the owner of - the document. - - The split operation only accepts a single document. -- `rotate` - - Requires `parameters`: - - `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270 -- `delete_pages` - - Requires `parameters`: - - `"pages": [..]` The list should be a list of integers e.g. `"[2,3,4]"` - - The delete_pages operation only accepts a single document. - `modify_custom_fields` - Requires `parameters`: - `"add_custom_fields": { CUSTOM_FIELD_ID: VALUE }`: JSON object consisting of custom field id:value pairs to add to the document, can also be a list of custom field IDs to add with empty values. - `"remove_custom_fields": [CUSTOM_FIELD_ID]`: custom field ids to remove from the document. +#### Document-editing operations + +Beginning with version 10+, the API supports individual endpoints for document-editing operations (`merge`, `rotate`, `edit_pdf`, etc), thus their documentation can be found in the API spec / viewer. Legacy document-editing methods via `/api/documents/bulk_edit/` are still supported for compatibility, are deprecated and clients should migrate to the individual endpoints before they are removed in a future version. + ### Objects Bulk editing for objects (tags, document types etc.) currently supports set permissions or delete @@ -467,4 +431,9 @@ Initial API version. #### Version 10 - The `show_on_dashboard` and `show_in_sidebar` fields of saved views have been - removed. Relevant settings are now stored in the UISettings model. + removed. Relevant settings are now stored in the UISettings model. Compatibility is maintained + for versions < 10 until support for API v9 is dropped. +- Document-editing operations such as `merge`, `rotate`, and `edit_pdf` have been + moved from the bulk edit endpoint to their own individual endpoints. Using these methods via + the bulk edit endpoint is still supported for compatibility with versions < 10 until support + for API v9 is dropped. diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index 990c6b4d0..753dc7ca8 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -950,8 +950,8 @@ describe('DocumentDetailComponent', () => { it('should support reprocess, confirm and close modal after started', () => { initNormally() - const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit') - bulkEditSpy.mockReturnValue(of(true)) + const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') + reprocessSpy.mockReturnValue(of(true)) let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const modalSpy = jest.spyOn(modalService, 'open') @@ -959,7 +959,7 @@ describe('DocumentDetailComponent', () => { component.reprocess() const modalCloseSpy = jest.spyOn(openModal, 'close') openModal.componentInstance.confirmClicked.next() - expect(bulkEditSpy).toHaveBeenCalledWith([doc.id], 'reprocess', {}) + expect(reprocessSpy).toHaveBeenCalledWith([doc.id]) expect(modalSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled() @@ -967,13 +967,13 @@ describe('DocumentDetailComponent', () => { it('should show error if redo ocr call fails', () => { initNormally() - const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit') + const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const toastSpy = jest.spyOn(toastService, 'showError') component.reprocess() const modalCloseSpy = jest.spyOn(openModal, 'close') - bulkEditSpy.mockReturnValue(throwError(() => new Error('error occurred'))) + reprocessSpy.mockReturnValue(throwError(() => new Error('error occurred'))) openModal.componentInstance.confirmClicked.next() expect(toastSpy).toHaveBeenCalled() expect(modalCloseSpy).not.toHaveBeenCalled() @@ -1669,18 +1669,15 @@ describe('DocumentDetailComponent', () => { modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }] modal.componentInstance.confirm() let req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/edit_pdf/` ) expect(req.request.body).toEqual({ documents: [10], - method: 'edit_pdf', - parameters: { - operations: [{ page: 1, rotate: 0, doc: 0 }], - delete_original: false, - update_document: false, - include_metadata: true, - source_mode: 'explicit_selection', - }, + operations: [{ page: 1, rotate: 0, doc: 0 }], + delete_original: false, + update_document: false, + include_metadata: true, + source_mode: 'explicit_selection', }) req.error(new ErrorEvent('failed')) expect(errorSpy).toHaveBeenCalled() @@ -1691,7 +1688,7 @@ describe('DocumentDetailComponent', () => { modal.componentInstance.deleteOriginal = true modal.componentInstance.confirm() req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/edit_pdf/` ) req.flush(true) expect(closeSpy).toHaveBeenCalled() @@ -1711,18 +1708,15 @@ describe('DocumentDetailComponent', () => { dialog.deleteOriginal = true dialog.confirm() const req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/remove_password/` ) expect(req.request.body).toEqual({ documents: [10], - method: 'remove_password', - parameters: { - password: 'secret', - update_document: false, - include_metadata: false, - delete_original: true, - source_mode: 'explicit_selection', - }, + password: 'secret', + update_document: false, + include_metadata: false, + delete_original: true, + source_mode: 'explicit_selection', }) req.flush(true) }) @@ -1737,7 +1731,7 @@ describe('DocumentDetailComponent', () => { expect(errorSpy).toHaveBeenCalled() httpTestingController.expectNone( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/remove_password/` ) }) @@ -1753,7 +1747,7 @@ describe('DocumentDetailComponent', () => { modal.componentInstance as PasswordRemovalConfirmDialogComponent dialog.confirm() const req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/remove_password/` ) req.error(new ErrorEvent('failed')) @@ -1774,7 +1768,7 @@ describe('DocumentDetailComponent', () => { modal.componentInstance as PasswordRemovalConfirmDialogComponent dialog.confirm() const req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/remove_password/` ) req.flush(true) diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index a2c193770..25c854e53 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -1379,27 +1379,25 @@ export class DocumentDetailComponent modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.buttonsEnabled = false - this.documentsService - .bulkEdit([this.document.id], 'reprocess', {}) - .subscribe({ - next: () => { - this.toastService.showInfo( - $localize`Reprocess operation for "${this.document.title}" will begin in the background.` - ) - if (modal) { - modal.close() - } - }, - error: (error) => { - if (modal) { - modal.componentInstance.buttonsEnabled = true - } - this.toastService.showError( - $localize`Error executing operation`, - error - ) - }, - }) + this.documentsService.reprocessDocuments([this.document.id]).subscribe({ + next: () => { + this.toastService.showInfo( + $localize`Reprocess operation for "${this.document.title}" will begin in the background.` + ) + if (modal) { + modal.close() + } + }, + error: (error) => { + if (modal) { + modal.componentInstance.buttonsEnabled = true + } + this.toastService.showError( + $localize`Error executing operation`, + error + ) + }, + }) }) } @@ -1766,7 +1764,7 @@ export class DocumentDetailComponent .subscribe(() => { modal.componentInstance.buttonsEnabled = false this.documentsService - .bulkEdit([sourceDocumentId], 'edit_pdf', { + .editPdfDocuments([sourceDocumentId], { operations: modal.componentInstance.getOperations(), delete_original: modal.componentInstance.deleteOriginal, update_document: @@ -1824,7 +1822,7 @@ export class DocumentDetailComponent dialog.buttonsEnabled = false this.networkActive = true this.documentsService - .bulkEdit([sourceDocumentId], 'remove_password', { + .removePasswordDocuments([sourceDocumentId], { password: this.password, update_document: dialog.updateDocument, include_metadata: dialog.includeMetadata, diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts index 694899d3d..893dae97f 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts @@ -849,13 +849,11 @@ describe('BulkEditorComponent', () => { expect(modal).not.toBeUndefined() modal.componentInstance.confirm() let req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/delete/` ) req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], - method: 'delete', - parameters: {}, }) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` @@ -868,7 +866,7 @@ describe('BulkEditorComponent', () => { fixture.detectChanges() component.applyDelete() req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/delete/` ) }) @@ -944,13 +942,11 @@ describe('BulkEditorComponent', () => { expect(modal).not.toBeUndefined() modal.componentInstance.confirm() let req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/reprocess/` ) req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], - method: 'reprocess', - parameters: {}, }) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` @@ -979,13 +975,13 @@ describe('BulkEditorComponent', () => { modal.componentInstance.rotate() modal.componentInstance.confirm() let req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/rotate/` ) req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], - method: 'rotate', - parameters: { degrees: 90 }, + degrees: 90, + source_mode: 'latest_version', }) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` @@ -1021,13 +1017,12 @@ describe('BulkEditorComponent', () => { modal.componentInstance.metadataDocumentID = 3 modal.componentInstance.confirm() let req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/merge/` ) req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], - method: 'merge', - parameters: { metadata_document_id: 3 }, + metadata_document_id: 3, }) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` @@ -1040,13 +1035,13 @@ describe('BulkEditorComponent', () => { modal.componentInstance.deleteOriginals = true modal.componentInstance.confirm() req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/merge/` ) req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], - method: 'merge', - parameters: { metadata_document_id: 3, delete_originals: true }, + metadata_document_id: 3, + delete_originals: true, }) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` @@ -1061,13 +1056,13 @@ describe('BulkEditorComponent', () => { modal.componentInstance.archiveFallback = true modal.componentInstance.confirm() req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/merge/` ) req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], - method: 'merge', - parameters: { metadata_document_id: 3, archive_fallback: true }, + metadata_document_id: 3, + archive_fallback: true, }) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts index 8e97b5f9e..c23cd6c5f 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -12,7 +12,7 @@ import { } from '@ng-bootstrap/ng-bootstrap' import { saveAs } from 'file-saver' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' -import { first, map, Subject, switchMap, takeUntil } from 'rxjs' +import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { CustomField } from 'src/app/data/custom-field' import { MatchingModel } from 'src/app/data/matching-model' @@ -29,7 +29,9 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { + DocumentBulkEditMethod, DocumentService, + MergeDocumentsRequest, SelectionDataItem, } from 'src/app/services/rest/document.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service' @@ -255,9 +257,9 @@ export class BulkEditorComponent this.unsubscribeNotifier.complete() } - private executeBulkOperation( + private executeBulkEditMethod( modal: NgbModalRef, - method: string, + method: DocumentBulkEditMethod, args: any, overrideDocumentIDs?: number[] ) { @@ -272,32 +274,55 @@ export class BulkEditorComponent ) .pipe(first()) .subscribe({ - next: () => { - if (args['delete_originals']) { - this.list.selected.clear() - } - this.list.reload() - this.list.reduceSelectionToFilter() - this.list.selected.forEach((id) => { - this.openDocumentService.refreshDocument(id) - }) - this.savedViewService.maybeRefreshDocumentCounts() - if (modal) { - modal.close() - } - }, - error: (error) => { - if (modal) { - modal.componentInstance.buttonsEnabled = true - } - this.toastService.showError( - $localize`Error executing bulk operation`, - error - ) - }, + next: () => this.handleOperationSuccess(modal), + error: (error) => this.handleOperationError(modal, error), }) } + private executeDocumentAction( + modal: NgbModalRef, + request: Observable, + options: { deleteOriginals?: boolean } = {} + ) { + if (modal) { + modal.componentInstance.buttonsEnabled = false + } + request.pipe(first()).subscribe({ + next: () => { + this.handleOperationSuccess(modal, options.deleteOriginals ?? false) + }, + error: (error) => this.handleOperationError(modal, error), + }) + } + + private handleOperationSuccess( + modal: NgbModalRef, + clearSelection: boolean = false + ) { + if (clearSelection) { + this.list.selected.clear() + } + this.list.reload() + this.list.reduceSelectionToFilter() + this.list.selected.forEach((id) => { + this.openDocumentService.refreshDocument(id) + }) + this.savedViewService.maybeRefreshDocumentCounts() + if (modal) { + modal.close() + } + } + + private handleOperationError(modal: NgbModalRef, error: any) { + if (modal) { + modal.componentInstance.buttonsEnabled = true + } + this.toastService.showError( + $localize`Error executing bulk operation`, + error + ) + } + private applySelectionData( items: SelectionDataItem[], selectionModel: FilterableDropdownSelectionModel @@ -446,13 +471,13 @@ export class BulkEditorComponent modal.componentInstance.confirmClicked .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { - this.executeBulkOperation(modal, 'modify_tags', { + this.executeBulkEditMethod(modal, 'modify_tags', { add_tags: changedTags.itemsToAdd.map((t) => t.id), remove_tags: changedTags.itemsToRemove.map((t) => t.id), }) }) } else { - this.executeBulkOperation(null, 'modify_tags', { + this.executeBulkEditMethod(null, 'modify_tags', { add_tags: changedTags.itemsToAdd.map((t) => t.id), remove_tags: changedTags.itemsToRemove.map((t) => t.id), }) @@ -486,12 +511,12 @@ export class BulkEditorComponent modal.componentInstance.confirmClicked .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { - this.executeBulkOperation(modal, 'set_correspondent', { + this.executeBulkEditMethod(modal, 'set_correspondent', { correspondent: correspondent ? correspondent.id : null, }) }) } else { - this.executeBulkOperation(null, 'set_correspondent', { + this.executeBulkEditMethod(null, 'set_correspondent', { correspondent: correspondent ? correspondent.id : null, }) } @@ -524,12 +549,12 @@ export class BulkEditorComponent modal.componentInstance.confirmClicked .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { - this.executeBulkOperation(modal, 'set_document_type', { + this.executeBulkEditMethod(modal, 'set_document_type', { document_type: documentType ? documentType.id : null, }) }) } else { - this.executeBulkOperation(null, 'set_document_type', { + this.executeBulkEditMethod(null, 'set_document_type', { document_type: documentType ? documentType.id : null, }) } @@ -562,12 +587,12 @@ export class BulkEditorComponent modal.componentInstance.confirmClicked .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { - this.executeBulkOperation(modal, 'set_storage_path', { + this.executeBulkEditMethod(modal, 'set_storage_path', { storage_path: storagePath ? storagePath.id : null, }) }) } else { - this.executeBulkOperation(null, 'set_storage_path', { + this.executeBulkEditMethod(null, 'set_storage_path', { storage_path: storagePath ? storagePath.id : null, }) } @@ -624,7 +649,7 @@ export class BulkEditorComponent modal.componentInstance.confirmClicked .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { - this.executeBulkOperation(modal, 'modify_custom_fields', { + this.executeBulkEditMethod(modal, 'modify_custom_fields', { add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id), remove_custom_fields: changedCustomFields.itemsToRemove.map( (f) => f.id @@ -632,7 +657,7 @@ export class BulkEditorComponent }) }) } else { - this.executeBulkOperation(null, 'modify_custom_fields', { + this.executeBulkEditMethod(null, 'modify_custom_fields', { add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id), remove_custom_fields: changedCustomFields.itemsToRemove.map( (f) => f.id @@ -762,10 +787,16 @@ export class BulkEditorComponent .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { modal.componentInstance.buttonsEnabled = false - this.executeBulkOperation(modal, 'delete', {}) + this.executeDocumentAction( + modal, + this.documentService.deleteDocuments(Array.from(this.list.selected)) + ) }) } else { - this.executeBulkOperation(null, 'delete', {}) + this.executeDocumentAction( + null, + this.documentService.deleteDocuments(Array.from(this.list.selected)) + ) } } @@ -804,7 +835,12 @@ export class BulkEditorComponent .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { modal.componentInstance.buttonsEnabled = false - this.executeBulkOperation(modal, 'reprocess', {}) + this.executeDocumentAction( + modal, + this.documentService.reprocessDocuments( + Array.from(this.list.selected) + ) + ) }) } @@ -815,7 +851,7 @@ export class BulkEditorComponent modal.componentInstance.confirmClicked.subscribe( ({ permissions, merge }) => { modal.componentInstance.buttonsEnabled = false - this.executeBulkOperation(modal, 'set_permissions', { + this.executeBulkEditMethod(modal, 'set_permissions', { ...permissions, merge, }) @@ -838,9 +874,13 @@ export class BulkEditorComponent .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { rotateDialog.buttonsEnabled = false - this.executeBulkOperation(modal, 'rotate', { - degrees: rotateDialog.degrees, - }) + this.executeDocumentAction( + modal, + this.documentService.rotateDocuments( + Array.from(this.list.selected), + rotateDialog.degrees + ) + ) }) } @@ -856,18 +896,22 @@ export class BulkEditorComponent mergeDialog.confirmClicked .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { - const args = {} + const args: MergeDocumentsRequest = {} if (mergeDialog.metadataDocumentID > -1) { - args['metadata_document_id'] = mergeDialog.metadataDocumentID + args.metadata_document_id = mergeDialog.metadataDocumentID } if (mergeDialog.deleteOriginals) { - args['delete_originals'] = true + args.delete_originals = true } if (mergeDialog.archiveFallback) { - args['archive_fallback'] = true + args.archive_fallback = true } mergeDialog.buttonsEnabled = false - this.executeBulkOperation(modal, 'merge', args, mergeDialog.documentIDs) + this.executeDocumentAction( + modal, + this.documentService.mergeDocuments(mergeDialog.documentIDs, args), + { deleteOriginals: !!args.delete_originals } + ) this.toastService.showInfo( $localize`Merged document will be queued for consumption.` ) diff --git a/src-ui/src/app/services/rest/document.service.spec.ts b/src-ui/src/app/services/rest/document.service.spec.ts index 78cef69cb..b3a9757ff 100644 --- a/src-ui/src/app/services/rest/document.service.spec.ts +++ b/src-ui/src/app/services/rest/document.service.spec.ts @@ -230,6 +230,88 @@ describe(`DocumentService`, () => { }) }) + it('should call appropriate api endpoint for delete documents', () => { + const ids = [1, 2, 3] + subscription = service.deleteDocuments(ids).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/delete/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + documents: ids, + }) + }) + + it('should call appropriate api endpoint for reprocess documents', () => { + const ids = [1, 2, 3] + subscription = service.reprocessDocuments(ids).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/reprocess/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + documents: ids, + }) + }) + + it('should call appropriate api endpoint for rotate documents', () => { + const ids = [1, 2, 3] + subscription = service.rotateDocuments(ids, 90).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/rotate/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + documents: ids, + degrees: 90, + source_mode: 'latest_version', + }) + }) + + it('should call appropriate api endpoint for merge documents', () => { + const ids = [1, 2, 3] + const args = { metadata_document_id: 1, delete_originals: true } + subscription = service.mergeDocuments(ids, args).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/merge/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + documents: ids, + metadata_document_id: 1, + delete_originals: true, + }) + }) + + it('should call appropriate api endpoint for edit pdf', () => { + const ids = [1] + const args = { operations: [{ page: 1, rotate: 90, doc: 0 }] } + subscription = service.editPdfDocuments(ids, args).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/edit_pdf/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + documents: ids, + operations: [{ page: 1, rotate: 90, doc: 0 }], + }) + }) + + it('should call appropriate api endpoint for remove password', () => { + const ids = [1] + const args = { password: 'secret', update_document: true } + subscription = service.removePasswordDocuments(ids, args).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/remove_password/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + documents: ids, + password: 'secret', + update_document: true, + }) + }) + it('should return the correct preview URL for a single document', () => { let url = service.getPreviewUrl(documents[0].id) expect(url).toEqual( diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts index bc06d571c..971396bac 100644 --- a/src-ui/src/app/services/rest/document.service.ts +++ b/src-ui/src/app/services/rest/document.service.ts @@ -42,6 +42,45 @@ export enum BulkEditSourceMode { EXPLICIT_SELECTION = 'explicit_selection', } +export type DocumentBulkEditMethod = + | 'set_correspondent' + | 'set_document_type' + | 'set_storage_path' + | 'add_tag' + | 'remove_tag' + | 'modify_tags' + | 'modify_custom_fields' + | 'set_permissions' + +export interface MergeDocumentsRequest { + metadata_document_id?: number + delete_originals?: boolean + archive_fallback?: boolean + source_mode?: BulkEditSourceMode +} + +export interface EditPdfOperation { + page: number + rotate?: number + doc?: number +} + +export interface EditPdfDocumentsRequest { + operations: EditPdfOperation[] + delete_original?: boolean + update_document?: boolean + include_metadata?: boolean + source_mode?: BulkEditSourceMode +} + +export interface RemovePasswordDocumentsRequest { + password: string + update_document?: boolean + delete_original?: boolean + include_metadata?: boolean + source_mode?: BulkEditSourceMode +} + @Injectable({ providedIn: 'root', }) @@ -299,7 +338,7 @@ export class DocumentService extends AbstractPaperlessService { return this.http.get(url.toString()) } - bulkEdit(ids: number[], method: string, args: any) { + bulkEdit(ids: number[], method: DocumentBulkEditMethod, args: any) { return this.http.post(this.getResourceUrl(null, 'bulk_edit'), { documents: ids, method: method, @@ -307,6 +346,54 @@ export class DocumentService extends AbstractPaperlessService { }) } + deleteDocuments(ids: number[]) { + return this.http.post(this.getResourceUrl(null, 'delete'), { + documents: ids, + }) + } + + reprocessDocuments(ids: number[]) { + return this.http.post(this.getResourceUrl(null, 'reprocess'), { + documents: ids, + }) + } + + rotateDocuments( + ids: number[], + degrees: number, + sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION + ) { + return this.http.post(this.getResourceUrl(null, 'rotate'), { + documents: ids, + degrees, + source_mode: sourceMode, + }) + } + + mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) { + return this.http.post(this.getResourceUrl(null, 'merge'), { + documents: ids, + ...request, + }) + } + + editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) { + return this.http.post(this.getResourceUrl(null, 'edit_pdf'), { + documents: ids, + ...request, + }) + } + + removePasswordDocuments( + ids: number[], + request: RemovePasswordDocumentsRequest + ) { + return this.http.post(this.getResourceUrl(null, 'remove_password'), { + documents: ids, + ...request, + }) + } + getSelectionData(ids: number[]): Observable { return this.http.post( this.getResourceUrl(null, 'selection_data'), diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 1faa815e5..7361ca70b 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1540,11 +1540,124 @@ class DocumentListSerializer(serializers.Serializer): return documents +class SourceModeValidationMixin: + def validate_source_mode(self, source_mode: str) -> str: + if source_mode not in bulk_edit.SourceModeChoices.__dict__.values(): + raise serializers.ValidationError("Invalid source_mode") + return source_mode + + +class RotateDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin): + degrees = serializers.IntegerField(required=True) + source_mode = serializers.CharField( + required=False, + default=bulk_edit.SourceModeChoices.LATEST_VERSION, + ) + + +class MergeDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin): + metadata_document_id = serializers.IntegerField( + required=False, + allow_null=True, + ) + delete_originals = serializers.BooleanField(required=False, default=False) + archive_fallback = serializers.BooleanField(required=False, default=False) + source_mode = serializers.CharField( + required=False, + default=bulk_edit.SourceModeChoices.LATEST_VERSION, + ) + + +class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin): + operations = serializers.ListField(required=True) + delete_original = serializers.BooleanField(required=False, default=False) + update_document = serializers.BooleanField(required=False, default=False) + include_metadata = serializers.BooleanField(required=False, default=True) + source_mode = serializers.CharField( + required=False, + default=bulk_edit.SourceModeChoices.LATEST_VERSION, + ) + + def validate(self, attrs): + documents = attrs["documents"] + if len(documents) > 1: + raise serializers.ValidationError( + "Edit PDF method only supports one document", + ) + + operations = attrs["operations"] + if not isinstance(operations, list): + raise serializers.ValidationError("operations must be a list") + + for op in operations: + if not isinstance(op, dict): + raise serializers.ValidationError("invalid operation entry") + if "page" not in op or not isinstance(op["page"], int): + raise serializers.ValidationError("page must be an integer") + if "rotate" in op and not isinstance(op["rotate"], int): + raise serializers.ValidationError("rotate must be an integer") + if "doc" in op and not isinstance(op["doc"], int): + raise serializers.ValidationError("doc must be an integer") + + if attrs["update_document"]: + max_idx = max(op.get("doc", 0) for op in operations) + if max_idx > 0: + raise serializers.ValidationError( + "update_document only allowed with a single output document", + ) + + doc = Document.objects.get(id=documents[0]) + if doc.page_count: + for op in operations: + if op["page"] < 1 or op["page"] > doc.page_count: + raise serializers.ValidationError( + f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.", + ) + return attrs + + +class RemovePasswordDocumentsSerializer( + DocumentListSerializer, + SourceModeValidationMixin, +): + password = serializers.CharField(required=True) + update_document = serializers.BooleanField(required=False, default=False) + delete_original = serializers.BooleanField(required=False, default=False) + include_metadata = serializers.BooleanField(required=False, default=True) + source_mode = serializers.CharField( + required=False, + default=bulk_edit.SourceModeChoices.LATEST_VERSION, + ) + + +class DeleteDocumentsSerializer(DocumentListSerializer): + pass + + +class ReprocessDocumentsSerializer(DocumentListSerializer): + pass + + class BulkEditSerializer( SerializerWithPerms, DocumentListSerializer, SetPermissionsMixin, + SourceModeValidationMixin, ): + # TODO: remove this and related backwards compatibility code when API v9 is dropped + # split, delete_pages can be removed entirely + MOVED_DOCUMENT_ACTION_ENDPOINTS = { + "delete": "/api/documents/delete/", + "reprocess": "/api/documents/reprocess/", + "rotate": "/api/documents/rotate/", + "merge": "/api/documents/merge/", + "edit_pdf": "/api/documents/edit_pdf/", + "remove_password": "/api/documents/remove_password/", + "split": "/api/documents/edit_pdf/", + "delete_pages": "/api/documents/edit_pdf/", + } + LEGACY_DOCUMENT_ACTION_METHODS = tuple(MOVED_DOCUMENT_ACTION_ENDPOINTS.keys()) + method = serializers.ChoiceField( choices=[ "set_correspondent", @@ -1554,15 +1667,8 @@ class BulkEditSerializer( "remove_tag", "modify_tags", "modify_custom_fields", - "delete", - "reprocess", "set_permissions", - "rotate", - "merge", - "split", - "delete_pages", - "edit_pdf", - "remove_password", + *LEGACY_DOCUMENT_ACTION_METHODS, ], label="Method", write_only=True, @@ -1640,8 +1746,7 @@ class BulkEditSerializer( return bulk_edit.edit_pdf elif method == "remove_password": return bulk_edit.remove_password - else: # pragma: no cover - # This will never happen as it is handled by the ChoiceField + else: raise serializers.ValidationError("Unsupported method.") def _validate_parameters_tags(self, parameters) -> None: @@ -1751,9 +1856,7 @@ class BulkEditSerializer( "source_mode", bulk_edit.SourceModeChoices.LATEST_VERSION, ) - if source_mode not in bulk_edit.SourceModeChoices.__dict__.values(): - raise serializers.ValidationError("Invalid source_mode") - parameters["source_mode"] = source_mode + parameters["source_mode"] = self.validate_source_mode(source_mode) def _validate_parameters_split(self, parameters) -> None: if "pages" not in parameters: diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py index 6a465e19f..04c3b5ff8 100644 --- a/src/documents/tests/test_api_bulk_edit.py +++ b/src/documents/tests/test_api_bulk_edit.py @@ -422,6 +422,34 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(args[0], [self.doc1.id]) self.assertEqual(len(kwargs), 0) + @mock.patch("documents.views.bulk_edit.delete") + def test_delete_documents_endpoint(self, m) -> None: + self.setup_mock(m, "delete") + response = self.client.post( + "/api/documents/delete/", + json.dumps({"documents": [self.doc1.id]}), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + args, kwargs = m.call_args + self.assertEqual(args[0], [self.doc1.id]) + self.assertEqual(len(kwargs), 0) + + @mock.patch("documents.views.bulk_edit.reprocess") + def test_reprocess_documents_endpoint(self, m) -> None: + self.setup_mock(m, "reprocess") + response = self.client.post( + "/api/documents/reprocess/", + json.dumps({"documents": [self.doc1.id]}), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + args, kwargs = m.call_args + self.assertEqual(args[0], [self.doc1.id]) + self.assertEqual(len(kwargs), 0) + @mock.patch("documents.serialisers.bulk_edit.set_storage_path") def test_api_set_storage_path(self, m) -> None: """ @@ -877,7 +905,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(kwargs["merge"], True) @mock.patch("documents.serialisers.bulk_edit.set_storage_path") - @mock.patch("documents.serialisers.bulk_edit.merge") + @mock.patch("documents.views.bulk_edit.merge") def test_insufficient_global_perms(self, mock_merge, mock_set_storage) -> None: """ GIVEN: @@ -912,12 +940,11 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): mock_set_storage.assert_not_called() response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/merge/", json.dumps( { "documents": [self.doc1.id], - "method": "merge", - "parameters": {"metadata_document_id": self.doc1.id}, + "metadata_document_id": self.doc1.id, }, ), content_type="application/json", @@ -927,15 +954,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): mock_merge.assert_not_called() response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/merge/", json.dumps( { "documents": [self.doc1.id], - "method": "merge", - "parameters": { - "metadata_document_id": self.doc1.id, - "delete_originals": True, - }, + "metadata_document_id": self.doc1.id, + "delete_originals": True, }, ), content_type="application/json", @@ -1052,85 +1076,57 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): m.assert_called_once() - @mock.patch("documents.serialisers.bulk_edit.rotate") + @mock.patch("documents.views.bulk_edit.rotate") def test_rotate(self, m) -> None: self.setup_mock(m, "rotate") response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/rotate/", json.dumps( { "documents": [self.doc2.id, self.doc3.id], - "method": "rotate", - "parameters": {"degrees": 90}, + "degrees": 90, }, ), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) - m.assert_called_once() args, kwargs = m.call_args self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id]) self.assertEqual(kwargs["degrees"], 90) - - @mock.patch("documents.serialisers.bulk_edit.rotate") - def test_rotate_invalid_params(self, m) -> None: - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id, self.doc3.id], - "method": "rotate", - "parameters": {"degrees": "foo"}, - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id, self.doc3.id], - "method": "rotate", - "parameters": {"degrees": 90.5}, - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - m.assert_not_called() - - @mock.patch("documents.serialisers.bulk_edit.merge") - def test_merge(self, m) -> None: - self.setup_mock(m, "merge") - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id, self.doc3.id], - "method": "merge", - "parameters": {"metadata_document_id": self.doc3.id}, - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - - m.assert_called_once() - args, kwargs = m.call_args - self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id]) - self.assertEqual(kwargs["metadata_document_id"], self.doc3.id) + self.assertEqual(kwargs["source_mode"], "latest_version") self.assertEqual(kwargs["user"], self.user) - @mock.patch("documents.serialisers.bulk_edit.merge") - def test_merge_and_delete_insufficient_permissions(self, m) -> None: + @mock.patch("documents.views.bulk_edit.rotate") + def test_rotate_invalid_params(self, m) -> None: + response = self.client.post( + "/api/documents/rotate/", + json.dumps( + { + "documents": [self.doc2.id, self.doc3.id], + "degrees": "foo", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + response = self.client.post( + "/api/documents/rotate/", + json.dumps( + { + "documents": [self.doc2.id, self.doc3.id], + "degrees": 90.5, + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + m.assert_not_called() + + @mock.patch("documents.views.bulk_edit.rotate") + def test_rotate_insufficient_permissions(self, m) -> None: self.doc1.owner = User.objects.get(username="temp_admin") self.doc1.save() user1 = User.objects.create(username="user1") @@ -1138,17 +1134,13 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): user1.save() self.client.force_authenticate(user=user1) - self.setup_mock(m, "merge") + self.setup_mock(m, "rotate") response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/rotate/", json.dumps( { "documents": [self.doc1.id, self.doc2.id], - "method": "merge", - "parameters": { - "metadata_document_id": self.doc2.id, - "delete_originals": True, - }, + "degrees": 90, }, ), content_type="application/json", @@ -1159,15 +1151,11 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.content, b"Insufficient permissions") response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/rotate/", json.dumps( { "documents": [self.doc2.id, self.doc3.id], - "method": "merge", - "parameters": { - "metadata_document_id": self.doc2.id, - "delete_originals": True, - }, + "degrees": 90, }, ), content_type="application/json", @@ -1176,27 +1164,78 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) m.assert_called_once() - @mock.patch("documents.serialisers.bulk_edit.merge") - def test_merge_invalid_parameters(self, m) -> None: - """ - GIVEN: - - API data for merging documents is called - - The parameters are invalid - WHEN: - - API is called - THEN: - - The API fails with a correct error code - """ + @mock.patch("documents.views.bulk_edit.merge") + def test_merge(self, m) -> None: self.setup_mock(m, "merge") response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/merge/", + json.dumps( + { + "documents": [self.doc2.id, self.doc3.id], + "metadata_document_id": self.doc3.id, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + args, kwargs = m.call_args + self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id]) + self.assertEqual(kwargs["metadata_document_id"], self.doc3.id) + self.assertEqual(kwargs["source_mode"], "latest_version") + self.assertEqual(kwargs["user"], self.user) + + @mock.patch("documents.views.bulk_edit.merge") + def test_merge_and_delete_insufficient_permissions(self, m) -> None: + self.doc1.owner = User.objects.get(username="temp_admin") + self.doc1.save() + user1 = User.objects.create(username="user1") + user1.user_permissions.add(*Permission.objects.all()) + user1.save() + self.client.force_authenticate(user=user1) + + self.setup_mock(m, "merge") + response = self.client.post( + "/api/documents/merge/", json.dumps( { "documents": [self.doc1.id, self.doc2.id], - "method": "merge", - "parameters": { - "delete_originals": "not_boolean", - }, + "metadata_document_id": self.doc2.id, + "delete_originals": True, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + m.assert_not_called() + self.assertEqual(response.content, b"Insufficient permissions") + + response = self.client.post( + "/api/documents/merge/", + json.dumps( + { + "documents": [self.doc2.id, self.doc3.id], + "metadata_document_id": self.doc2.id, + "delete_originals": True, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + + @mock.patch("documents.views.bulk_edit.merge") + def test_merge_invalid_parameters(self, m) -> None: + self.setup_mock(m, "merge") + response = self.client.post( + "/api/documents/merge/", + json.dumps( + { + "documents": [self.doc1.id, self.doc2.id], + "delete_originals": "not_boolean", }, ), content_type="application/json", @@ -1205,207 +1244,67 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) m.assert_not_called() - @mock.patch("documents.serialisers.bulk_edit.split") - def test_split(self, m) -> None: - self.setup_mock(m, "split") - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "split", - "parameters": {"pages": "1,2-4,5-6,7"}, - }, - ), - content_type="application/json", - ) + def test_bulk_edit_allows_legacy_file_methods_with_warning(self) -> None: + method_payloads = { + "delete": {}, + "reprocess": {}, + "rotate": {"degrees": 90}, + "merge": {"metadata_document_id": self.doc2.id}, + "edit_pdf": {"operations": [{"page": 1}]}, + "remove_password": {"password": "secret"}, + "split": {"pages": "1,2-4"}, + "delete_pages": {"pages": [1, 2]}, + } - self.assertEqual(response.status_code, status.HTTP_200_OK) + for version in (9, 10): + for method, parameters in method_payloads.items(): + with self.subTest(method=method, version=version): + with mock.patch( + f"documents.views.bulk_edit.{method}", + ) as mocked_method: + self.setup_mock(mocked_method, method) + with self.assertLogs("paperless.api", level="WARNING") as logs: + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id], + "method": method, + "parameters": parameters, + }, + ), + content_type="application/json", + headers={ + "Accept": f"application/json; version={version}", + }, + ) - m.assert_called_once() - args, kwargs = m.call_args - self.assertCountEqual(args[0], [self.doc2.id]) - self.assertEqual(kwargs["pages"], [[1], [2, 3, 4], [5, 6], [7]]) - self.assertEqual(kwargs["user"], self.user) + self.assertEqual(response.status_code, status.HTTP_200_OK) + mocked_method.assert_called_once() + self.assertTrue( + any( + "Deprecated bulk_edit method" in entry + and f"'{method}'" in entry + for entry in logs.output + ), + ) - def test_split_invalid_params(self) -> None: - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "split", - "parameters": {}, # pages not specified - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"pages not specified", response.content) - - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "split", - "parameters": {"pages": "1:7"}, # wrong format - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"invalid pages specified", response.content) - - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [ - self.doc1.id, - self.doc2.id, - ], # only one document supported - "method": "split", - "parameters": {"pages": "1-2,3-7"}, # wrong format - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"Split method only supports one document", response.content) - - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "split", - "parameters": { - "pages": "1", - "delete_originals": "notabool", - }, # not a bool - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"delete_originals must be a boolean", response.content) - - @mock.patch("documents.serialisers.bulk_edit.delete_pages") - def test_delete_pages(self, m) -> None: - self.setup_mock(m, "delete_pages") - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "delete_pages", - "parameters": {"pages": [1, 2, 3, 4]}, - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - - m.assert_called_once() - args, kwargs = m.call_args - self.assertCountEqual(args[0], [self.doc2.id]) - self.assertEqual(kwargs["pages"], [1, 2, 3, 4]) - - def test_delete_pages_invalid_params(self) -> None: - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [ - self.doc1.id, - self.doc2.id, - ], # only one document supported - "method": "delete_pages", - "parameters": { - "pages": [1, 2, 3, 4], - }, - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn( - b"Delete pages method only supports one document", - response.content, - ) - - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "delete_pages", - "parameters": {}, # pages not specified - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"pages not specified", response.content) - - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "delete_pages", - "parameters": {"pages": "1-3"}, # not a list - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"pages must be a list", response.content) - - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "delete_pages", - "parameters": {"pages": ["1-3"]}, # not ints - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"pages must be a list of integers", response.content) - - @mock.patch("documents.serialisers.bulk_edit.edit_pdf") + @mock.patch("documents.views.bulk_edit.edit_pdf") def test_edit_pdf(self, m) -> None: self.setup_mock(m, "edit_pdf") response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": { - "operations": [{"page": 1}], - "source_mode": "explicit_selection", - }, + "operations": [{"page": 1}], + "source_mode": "explicit_selection", }, ), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) - m.assert_called_once() args, kwargs = m.call_args self.assertCountEqual(args[0], [self.doc2.id]) @@ -1414,14 +1313,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(kwargs["user"], self.user) def test_edit_pdf_invalid_params(self) -> None: - # multiple documents response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id, self.doc3.id], - "method": "edit_pdf", - "parameters": {"operations": [{"page": 1}]}, + "operations": [{"page": 1}], }, ), content_type="application/json", @@ -1429,44 +1326,25 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"Edit PDF method only supports one document", response.content) - # no operations specified response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": {}, + "operations": "not_a_list", }, ), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"operations not specified", response.content) + self.assertIn(b"Expected a list of items", response.content) - # operations not a list response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": {"operations": "not_a_list"}, - }, - ), - content_type="application/json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"operations must be a list", response.content) - - # invalid operation - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": {"operations": ["invalid_operation"]}, + "operations": ["invalid_operation"], }, ), content_type="application/json", @@ -1474,14 +1352,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"invalid operation entry", response.content) - # page not an int response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": {"operations": [{"page": "not_an_int"}]}, + "operations": [{"page": "not_an_int"}], }, ), content_type="application/json", @@ -1489,14 +1365,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"page must be an integer", response.content) - # rotate not an int response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": {"operations": [{"page": 1, "rotate": "not_an_int"}]}, + "operations": [{"page": 1, "rotate": "not_an_int"}], }, ), content_type="application/json", @@ -1504,14 +1378,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"rotate must be an integer", response.content) - # doc not an int response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": {"operations": [{"page": 1, "doc": "not_an_int"}]}, + "operations": [{"page": 1, "doc": "not_an_int"}], }, ), content_type="application/json", @@ -1519,53 +1391,13 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"doc must be an integer", response.content) - # update_document not a boolean response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": { - "update_document": "not_a_bool", - "operations": [{"page": 1}], - }, - }, - ), - content_type="application/json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"update_document must be a boolean", response.content) - - # include_metadata not a boolean - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": { - "include_metadata": "not_a_bool", - "operations": [{"page": 1}], - }, - }, - ), - content_type="application/json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"include_metadata must be a boolean", response.content) - - # update_document True but output would be multiple documents - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": { - "update_document": True, - "operations": [{"page": 1, "doc": 1}, {"page": 2, "doc": 2}], - }, + "update_document": True, + "operations": [{"page": 1, "doc": 1}, {"page": 2, "doc": 2}], }, ), content_type="application/json", @@ -1576,17 +1408,13 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): response.content, ) - # invalid source mode response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": { - "operations": [{"page": 1}], - "source_mode": "not_a_mode", - }, + "operations": [{"page": 1}], + "source_mode": "not_a_mode", }, ), content_type="application/json", @@ -1594,42 +1422,70 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"Invalid source_mode", response.content) - @mock.patch("documents.serialisers.bulk_edit.edit_pdf") + @mock.patch("documents.views.bulk_edit.edit_pdf") def test_edit_pdf_page_out_of_bounds(self, m) -> None: - """ - GIVEN: - - API data for editing PDF is called - - The page number is out of bounds - WHEN: - - API is called - THEN: - - The API fails with a correct error code - """ self.setup_mock(m, "edit_pdf") response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": {"operations": [{"page": 99}]}, + "operations": [{"page": 99}], }, ), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"out of bounds", response.content) + m.assert_not_called() - @mock.patch("documents.serialisers.bulk_edit.remove_password") - def test_remove_password(self, m) -> None: - self.setup_mock(m, "remove_password") + @mock.patch("documents.views.bulk_edit.edit_pdf") + def test_edit_pdf_insufficient_permissions(self, m) -> None: + self.doc1.owner = User.objects.get(username="temp_admin") + self.doc1.save() + user1 = User.objects.create(username="user1") + user1.user_permissions.add(*Permission.objects.all()) + user1.save() + self.client.force_authenticate(user=user1) + + self.setup_mock(m, "edit_pdf") response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", + json.dumps( + { + "documents": [self.doc1.id], + "operations": [{"page": 1}], + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + m.assert_not_called() + self.assertEqual(response.content, b"Insufficient permissions") + + response = self.client.post( + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "remove_password", - "parameters": {"password": "secret", "update_document": True}, + "operations": [{"page": 1}], + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + + @mock.patch("documents.views.bulk_edit.remove_password") + def test_remove_password(self, m) -> None: + self.setup_mock(m, "remove_password") + response = self.client.post( + "/api/documents/remove_password/", + json.dumps( + { + "documents": [self.doc2.id], + "password": "secret", + "update_document": True, }, ), content_type="application/json", @@ -1641,36 +1497,69 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertCountEqual(args[0], [self.doc2.id]) self.assertEqual(kwargs["password"], "secret") self.assertTrue(kwargs["update_document"]) + self.assertEqual(kwargs["source_mode"], "latest_version") self.assertEqual(kwargs["user"], self.user) def test_remove_password_invalid_params(self) -> None: response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/remove_password/", json.dumps( { "documents": [self.doc2.id], - "method": "remove_password", - "parameters": {}, }, ), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"password not specified", response.content) response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/remove_password/", json.dumps( { "documents": [self.doc2.id], - "method": "remove_password", - "parameters": {"password": 123}, + "password": 123, }, ), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"password must be a string", response.content) + + @mock.patch("documents.views.bulk_edit.remove_password") + def test_remove_password_insufficient_permissions(self, m) -> None: + self.doc1.owner = User.objects.get(username="temp_admin") + self.doc1.save() + user1 = User.objects.create(username="user1") + user1.user_permissions.add(*Permission.objects.all()) + user1.save() + self.client.force_authenticate(user=user1) + + self.setup_mock(m, "remove_password") + response = self.client.post( + "/api/documents/remove_password/", + json.dumps( + { + "documents": [self.doc1.id], + "password": "secret", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + m.assert_not_called() + self.assertEqual(response.content, b"Insufficient permissions") + + response = self.client.post( + "/api/documents/remove_password/", + json.dumps( + { + "documents": [self.doc2.id], + "password": "secret", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() @override_settings(AUDIT_LOG_ENABLED=True) def test_bulk_edit_audit_log_enabled_simple_field(self) -> None: diff --git a/src/documents/tests/test_api_schema.py b/src/documents/tests/test_api_schema.py index 24d44fbc0..e14762f68 100644 --- a/src/documents/tests/test_api_schema.py +++ b/src/documents/tests/test_api_schema.py @@ -25,3 +25,39 @@ class TestApiSchema(APITestCase): ui_response = self.client.get(self.ENDPOINT + "view/") self.assertEqual(ui_response.status_code, status.HTTP_200_OK) + + def test_schema_includes_dedicated_document_edit_endpoints(self) -> None: + schema_response = self.client.get(self.ENDPOINT) + self.assertEqual(schema_response.status_code, status.HTTP_200_OK) + + paths = schema_response.data["paths"] + self.assertIn("/api/documents/delete/", paths) + self.assertIn("/api/documents/reprocess/", paths) + self.assertIn("/api/documents/rotate/", paths) + self.assertIn("/api/documents/merge/", paths) + self.assertIn("/api/documents/edit_pdf/", paths) + self.assertIn("/api/documents/remove_password/", paths) + + def test_schema_bulk_edit_advertises_legacy_document_action_methods(self) -> None: + schema_response = self.client.get(self.ENDPOINT) + self.assertEqual(schema_response.status_code, status.HTTP_200_OK) + + schema = schema_response.data["components"]["schemas"] + bulk_schema = schema["BulkEditRequest"] + method_schema = bulk_schema["properties"]["method"] + + # drf-spectacular emits the enum as a referenced schema for this field + enum_ref = method_schema["allOf"][0]["$ref"].split("/")[-1] + advertised_methods = schema[enum_ref]["enum"] + + for action_method in [ + "delete", + "reprocess", + "rotate", + "merge", + "edit_pdf", + "remove_password", + "split", + "delete_pages", + ]: + self.assertIn(action_method, advertised_methods) diff --git a/src/documents/views.py b/src/documents/views.py index 9b960a8b4..d51cde25f 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -176,14 +176,20 @@ from documents.serialisers import BulkEditObjectsSerializer from documents.serialisers import BulkEditSerializer from documents.serialisers import CorrespondentSerializer from documents.serialisers import CustomFieldSerializer +from documents.serialisers import DeleteDocumentsSerializer from documents.serialisers import DocumentListSerializer from documents.serialisers import DocumentSerializer from documents.serialisers import DocumentTypeSerializer from documents.serialisers import DocumentVersionLabelSerializer from documents.serialisers import DocumentVersionSerializer +from documents.serialisers import EditPdfDocumentsSerializer from documents.serialisers import EmailSerializer +from documents.serialisers import MergeDocumentsSerializer from documents.serialisers import NotesSerializer from documents.serialisers import PostDocumentSerializer +from documents.serialisers import RemovePasswordDocumentsSerializer +from documents.serialisers import ReprocessDocumentsSerializer +from documents.serialisers import RotateDocumentsSerializer from documents.serialisers import RunTaskViewSerializer from documents.serialisers import SavedViewSerializer from documents.serialisers import SearchResultSerializer @@ -2114,6 +2120,125 @@ class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet): ordering_fields = ("name",) +class DocumentOperationPermissionMixin(PassUserMixin): + permission_classes = (IsAuthenticated,) + parser_classes = (parsers.JSONParser,) + METHOD_NAMES_REQUIRING_USER = { + "split", + "merge", + "rotate", + "delete_pages", + "edit_pdf", + "remove_password", + } + + def _has_document_permissions( + self, + *, + user: User, + documents: list[int], + method, + parameters: dict[str, Any], + ) -> bool: + if user.is_superuser: + return True + + document_objs = Document.objects.select_related("owner").filter( + pk__in=documents, + ) + user_is_owner_of_all_documents = all( + (doc.owner == user or doc.owner is None) for doc in document_objs + ) + + # check global and object permissions for all documents + has_perms = user.has_perm("documents.change_document") and all( + has_perms_owner_aware(user, "change_document", doc) for doc in document_objs + ) + + # check ownership for methods that change original document + if ( + ( + has_perms + and method + in [ + bulk_edit.set_permissions, + bulk_edit.delete, + bulk_edit.rotate, + bulk_edit.delete_pages, + bulk_edit.edit_pdf, + bulk_edit.remove_password, + ] + ) + or ( + method in [bulk_edit.merge, bulk_edit.split] + and parameters.get("delete_originals") + ) + or (method == bulk_edit.edit_pdf and parameters.get("update_document")) + ): + has_perms = user_is_owner_of_all_documents + + # check global add permissions for methods that create documents + if ( + has_perms + and ( + method in [bulk_edit.split, bulk_edit.merge] + or ( + method in [bulk_edit.edit_pdf, bulk_edit.remove_password] + and not parameters.get("update_document") + ) + ) + and not user.has_perm("documents.add_document") + ): + has_perms = False + + # check global delete permissions for methods that delete documents + if ( + has_perms + and ( + method == bulk_edit.delete + or ( + method in [bulk_edit.merge, bulk_edit.split] + and parameters.get("delete_originals") + ) + ) + and not user.has_perm("documents.delete_document") + ): + has_perms = False + + return has_perms + + def _execute_document_action( + self, + *, + method, + validated_data: dict[str, Any], + operation_label: str, + ): + documents = validated_data["documents"] + parameters = {k: v for k, v in validated_data.items() if k != "documents"} + user = self.request.user + + if method.__name__ in self.METHOD_NAMES_REQUIRING_USER: + parameters["user"] = user + + if not self._has_document_permissions( + user=user, + documents=documents, + method=method, + parameters=parameters, + ): + return HttpResponseForbidden("Insufficient permissions") + + try: + result = method(documents, **parameters) + return Response({"result": result}) + except Exception as e: + logger.warning(f"An error occurred performing {operation_label}: {e!s}") + return HttpResponseBadRequest( + f"Error performing {operation_label}, check logs for more detail.", + ) + + @extend_schema_view( post=extend_schema( operation_id="bulk_edit", @@ -2132,7 +2257,7 @@ class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet): }, ), ) -class BulkEditView(PassUserMixin): +class BulkEditView(DocumentOperationPermissionMixin): MODIFIED_FIELD_BY_METHOD = { "set_correspondent": "correspondent", "set_document_type": "document_type", @@ -2154,11 +2279,24 @@ class BulkEditView(PassUserMixin): "remove_password": None, } - permission_classes = (IsAuthenticated,) serializer_class = BulkEditSerializer - parser_classes = (parsers.JSONParser,) def post(self, request, *args, **kwargs): + request_method = request.data.get("method") + api_version = int(request.version or settings.REST_FRAMEWORK["DEFAULT_VERSION"]) + # TODO: remove this and related backwards compatibility code when API v9 is dropped + if request_method in BulkEditSerializer.LEGACY_DOCUMENT_ACTION_METHODS: + endpoint = BulkEditSerializer.MOVED_DOCUMENT_ACTION_ENDPOINTS[ + request_method + ] + logger.warning( + "Deprecated bulk_edit method '%s' requested on API version %s. " + "Use '%s' instead.", + request_method, + api_version, + endpoint, + ) + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -2166,82 +2304,15 @@ class BulkEditView(PassUserMixin): method = serializer.validated_data.get("method") parameters = serializer.validated_data.get("parameters") documents = serializer.validated_data.get("documents") - if method in [ - bulk_edit.split, - bulk_edit.merge, - bulk_edit.rotate, - bulk_edit.delete_pages, - bulk_edit.edit_pdf, - bulk_edit.remove_password, - ]: + if method.__name__ in self.METHOD_NAMES_REQUIRING_USER: parameters["user"] = user - - if not user.is_superuser: - document_objs = Document.objects.select_related("owner").filter( - pk__in=documents, - ) - user_is_owner_of_all_documents = all( - (doc.owner == user or doc.owner is None) for doc in document_objs - ) - - # check global and object permissions for all documents - has_perms = user.has_perm("documents.change_document") and all( - has_perms_owner_aware(user, "change_document", doc) - for doc in document_objs - ) - - # check ownership for methods that change original document - if ( - ( - has_perms - and method - in [ - bulk_edit.set_permissions, - bulk_edit.delete, - bulk_edit.rotate, - bulk_edit.delete_pages, - bulk_edit.edit_pdf, - bulk_edit.remove_password, - ] - ) - or ( - method in [bulk_edit.merge, bulk_edit.split] - and parameters["delete_originals"] - ) - or (method == bulk_edit.edit_pdf and parameters["update_document"]) - ): - has_perms = user_is_owner_of_all_documents - - # check global add permissions for methods that create documents - if ( - has_perms - and ( - method in [bulk_edit.split, bulk_edit.merge] - or ( - method in [bulk_edit.edit_pdf, bulk_edit.remove_password] - and not parameters["update_document"] - ) - ) - and not user.has_perm("documents.add_document") - ): - has_perms = False - - # check global delete permissions for methods that delete documents - if ( - has_perms - and ( - method == bulk_edit.delete - or ( - method in [bulk_edit.merge, bulk_edit.split] - and parameters["delete_originals"] - ) - ) - and not user.has_perm("documents.delete_document") - ): - has_perms = False - - if not has_perms: - return HttpResponseForbidden("Insufficient permissions") + if not self._has_document_permissions( + user=user, + documents=documents, + method=method, + parameters=parameters, + ): + return HttpResponseForbidden("Insufficient permissions") try: modified_field = self.MODIFIED_FIELD_BY_METHOD.get(method.__name__, None) @@ -2298,6 +2369,168 @@ class BulkEditView(PassUserMixin): ) +@extend_schema_view( + post=extend_schema( + operation_id="documents_rotate", + description="Rotate one or more documents", + responses={ + 200: inline_serializer( + name="RotateDocumentsResult", + fields={ + "result": serializers.CharField(), + }, + ), + }, + ), +) +class RotateDocumentsView(DocumentOperationPermissionMixin): + serializer_class = RotateDocumentsSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return self._execute_document_action( + method=bulk_edit.rotate, + validated_data=serializer.validated_data, + operation_label="document rotate", + ) + + +@extend_schema_view( + post=extend_schema( + operation_id="documents_merge", + description="Merge selected documents into a new document", + responses={ + 200: inline_serializer( + name="MergeDocumentsResult", + fields={ + "result": serializers.CharField(), + }, + ), + }, + ), +) +class MergeDocumentsView(DocumentOperationPermissionMixin): + serializer_class = MergeDocumentsSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return self._execute_document_action( + method=bulk_edit.merge, + validated_data=serializer.validated_data, + operation_label="document merge", + ) + + +@extend_schema_view( + post=extend_schema( + operation_id="documents_delete", + description="Move selected documents to trash", + responses={ + 200: inline_serializer( + name="DeleteDocumentsResult", + fields={ + "result": serializers.CharField(), + }, + ), + }, + ), +) +class DeleteDocumentsView(DocumentOperationPermissionMixin): + serializer_class = DeleteDocumentsSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return self._execute_document_action( + method=bulk_edit.delete, + validated_data=serializer.validated_data, + operation_label="document delete", + ) + + +@extend_schema_view( + post=extend_schema( + operation_id="documents_reprocess", + description="Reprocess selected documents", + responses={ + 200: inline_serializer( + name="ReprocessDocumentsResult", + fields={ + "result": serializers.CharField(), + }, + ), + }, + ), +) +class ReprocessDocumentsView(DocumentOperationPermissionMixin): + serializer_class = ReprocessDocumentsSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return self._execute_document_action( + method=bulk_edit.reprocess, + validated_data=serializer.validated_data, + operation_label="document reprocess", + ) + + +@extend_schema_view( + post=extend_schema( + operation_id="documents_edit_pdf", + description="Perform PDF edit operations on a selected document", + responses={ + 200: inline_serializer( + name="EditPdfDocumentsResult", + fields={ + "result": serializers.CharField(), + }, + ), + }, + ), +) +class EditPdfDocumentsView(DocumentOperationPermissionMixin): + serializer_class = EditPdfDocumentsSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return self._execute_document_action( + method=bulk_edit.edit_pdf, + validated_data=serializer.validated_data, + operation_label="PDF edit", + ) + + +@extend_schema_view( + post=extend_schema( + operation_id="documents_remove_password", + description="Remove password protection from selected PDFs", + responses={ + 200: inline_serializer( + name="RemovePasswordDocumentsResult", + fields={ + "result": serializers.CharField(), + }, + ), + }, + ), +) +class RemovePasswordDocumentsView(DocumentOperationPermissionMixin): + serializer_class = RemovePasswordDocumentsSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return self._execute_document_action( + method=bulk_edit.remove_password, + validated_data=serializer.validated_data, + operation_label="password removal", + ) + + @extend_schema_view( post=extend_schema( description="Upload a document via the API", diff --git a/src/paperless/urls.py b/src/paperless/urls.py index 04b0c2200..88e1a3178 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -21,12 +21,18 @@ from documents.views import BulkEditView from documents.views import ChatStreamingView from documents.views import CorrespondentViewSet from documents.views import CustomFieldViewSet +from documents.views import DeleteDocumentsView from documents.views import DocumentTypeViewSet +from documents.views import EditPdfDocumentsView from documents.views import GlobalSearchView from documents.views import IndexView from documents.views import LogViewSet +from documents.views import MergeDocumentsView from documents.views import PostDocumentView from documents.views import RemoteVersionView +from documents.views import RemovePasswordDocumentsView +from documents.views import ReprocessDocumentsView +from documents.views import RotateDocumentsView from documents.views import SavedViewViewSet from documents.views import SearchAutoCompleteView from documents.views import SelectionDataView @@ -132,6 +138,36 @@ urlpatterns = [ BulkEditView.as_view(), name="bulk_edit", ), + re_path( + "^delete/", + DeleteDocumentsView.as_view(), + name="delete_documents", + ), + re_path( + "^reprocess/", + ReprocessDocumentsView.as_view(), + name="reprocess_documents", + ), + re_path( + "^rotate/", + RotateDocumentsView.as_view(), + name="rotate_documents", + ), + re_path( + "^merge/", + MergeDocumentsView.as_view(), + name="merge_documents", + ), + re_path( + "^edit_pdf/", + EditPdfDocumentsView.as_view(), + name="edit_pdf_documents", + ), + re_path( + "^remove_password/", + RemovePasswordDocumentsView.as_view(), + name="remove_password_documents", + ), re_path( "^bulk_download/", BulkDownloadView.as_view(),