From 063f2d2211e16140cbb1b9ec6226ee4c3f844b02 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Fri, 3 Jul 2026 16:19:48 -0700 Subject: [PATCH 01/66] fix(frontend): send passwordConfirm from profile change-password (#793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Profile dialog's self-service password change posted only oldPassword + password to /api/v4/auth/password/change, but that endpoint is rest_registration's ChangePasswordView whose serializer requires password_confirm (camelCased passwordConfirm) — so the request 400'd with "passwordConfirm field is required". The dialog already collects and validates passwordConfirm; forward it in the changePassword payload, matching change-password-dialog.vue and the register/reset flows. Add a regression test asserting the field is sent. Co-Authored-By: Claude Opus 4.8 --- .../src/components/auth/profile-dialog.vue | 1 + frontend/tests/unit/profile-dialog.test.js | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/frontend/src/components/auth/profile-dialog.vue b/frontend/src/components/auth/profile-dialog.vue index 9400dac8e..44b197a47 100644 --- a/frontend/src/components/auth/profile-dialog.vue +++ b/frontend/src/components/auth/profile-dialog.vue @@ -309,6 +309,7 @@ export default { await this.changePassword({ oldPassword: this.profile.oldPassword, password: this.profile.password, + passwordConfirm: this.profile.passwordConfirm, }); } }, diff --git a/frontend/tests/unit/profile-dialog.test.js b/frontend/tests/unit/profile-dialog.test.js index 2c8cf3118..484678c78 100644 --- a/frontend/tests/unit/profile-dialog.test.js +++ b/frontend/tests/unit/profile-dialog.test.js @@ -16,6 +16,7 @@ import { mount } from "@vue/test-utils"; import { describe, expect, test } from "vitest"; import ProfileDialog from "@/components/auth/profile-dialog.vue"; +import { useAuthStore } from "@/stores/auth"; import vuetify from "@/plugins/vuetify"; function mountDialog() { @@ -113,6 +114,38 @@ describe("ProfileDialog Save gating", () => { }); }); +describe("ProfileDialog submit payload", () => { + test("forwards passwordConfirm to changePassword (issue #793)", async () => { + const wrapper = mountDialog(); + const vm = wrapper.vm; + const store = useAuthStore(); + + vm.reset(); + vm.passwordPanel = "password"; + vm.profile.oldPassword = "old-secret"; + vm.profile.password = "new-secret"; + vm.profile.passwordConfirm = "new-secret"; + await vm.$nextTick(); + + // Focus this test on the submitted payload, not Vuetify's async form + // validation (covered by the Save-gating tests above). Assign the raw + // internal refs object; vm.$refs is a readonly proxy. + vm.$.refs.form = { validate: async () => ({ valid: true }) }; + + await vm.submit(); + + // The self-service /auth/password/change endpoint (rest_registration) + // *requires* passwordConfirm; omitting it caused the 400 in issue #793. + expect(store.changePassword).toHaveBeenCalledWith( + expect.objectContaining({ + oldPassword: "old-secret", + password: "new-secret", + passwordConfirm: "new-secret", + }), + ); + }); +}); + describe("ProfileDialog client-side rules", () => { test("email accepts blank or valid, rejects malformed", () => { const rule = mountDialog().vm.rules.email[0]; From 3020d679aca7f7996e70b056a6d2d236cf331db9 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Fri, 3 Jul 2026 16:25:32 -0700 Subject: [PATCH 02/66] update deps and version to v2.1.1. bump news --- NEWS.md | 5 + bun.lock | 8 +- cfg/node_root.mk | 2 +- frontend/bun.lock | 16 +- frontend/package.json | 8 +- package.json | 4 +- pyproject.toml | 2 +- uv.lock | 630 +++++++++++++++++++++--------------------- 8 files changed, 341 insertions(+), 334 deletions(-) diff --git a/NEWS.md b/NEWS.md index 4e87b7afb..5866c0940 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,11 @@ width: 128px; border-radius: 128px; " /> +## v2.1.1 + +- Fixes + - Fix unable to change password. + ## v2.1.0 - Renaming - Features diff --git a/bun.lock b/bun.lock index 45010eccd..8c110c96e 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", "@eslint/js": "^10.0.1", - "@eslint/json": "^2.0.0", + "@eslint/json": "^2.0.1", "@fsouza/prettierd": "^0.28.0", "@prettier/plugin-xml": "^3.4.2", "@stylistic/eslint-plugin": "^5.10.0", @@ -32,7 +32,7 @@ "eslint-plugin-security": "^4.0.1", "eslint-plugin-sonarjs": "^4.1.0", "eslint-plugin-toml": "^1.4.0", - "eslint-plugin-unicorn": "^69.0.0", + "eslint-plugin-unicorn": "^70.0.0", "eslint-plugin-vue": "^10.9.2", "eslint-plugin-vue-scoped-css": "^3.1.1", "eslint-plugin-yml": "^3.5.0", @@ -85,7 +85,7 @@ "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], - "@eslint/json": ["@eslint/json@2.0.0", "", { "dependencies": { "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanwhocodes/momoa": "^3.3.10", "natural-compare": "^1.4.0" } }, "sha512-P32ZJMIopNWQd1SFhd0tgjfA/hgzUuVSqHmMi2273QaLWHWimXq6V+qL4DNKnjGzO/aNECtYW+rEJ/pWB6uP+w=="], + "@eslint/json": ["@eslint/json@2.0.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanwhocodes/momoa": "^3.3.10", "natural-compare": "^1.4.0" } }, "sha512-Thz2j92ceUF3Bq/0TuWb3MWn3Z+Cwc8k5ptF0fakl2D4Mp8mSx07Xr1aQM4R5NoihzarWUdxfOmQ8DesGy4jOg=="], "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], @@ -413,7 +413,7 @@ "eslint-plugin-toml": ["eslint-plugin-toml@1.4.0", "", { "dependencies": { "@eslint/core": "^1.0.1", "@eslint/plugin-kit": "^0.7.0", "@ota-meshi/ast-token-store": "^0.3.0", "debug": "^4.1.1", "toml-eslint-parser": "^1.0.1" }, "peerDependencies": { "eslint": ">=9.38.0" } }, "sha512-3ErTnfUjXq/23f72XeyRcE0Y4Sd/ME1lsZeezczqpn2R4tE7+Sgco/NUKDXm0xAMz15tzcRz/9RfJRm6AqRO+A=="], - "eslint-plugin-unicorn": ["eslint-plugin-unicorn@69.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "@eslint-community/eslint-utils": "^4.9.1", "browserslist": "^4.28.2", "change-case": "^5.4.4", "ci-info": "^4.4.0", "core-js-compat": "^3.49.0", "detect-indent": "^7.0.2", "find-up-simple": "^1.0.1", "globals": "^17.6.0", "indent-string": "^5.0.0", "is-builtin-module": "^5.0.0", "jsesc": "^3.1.0", "pluralize": "^8.0.0", "regjsparser": "^0.13.1", "semver": "^7.8.4", "strip-indent": "^4.1.1" }, "peerDependencies": { "eslint": ">=10.4" } }, "sha512-ZN/KtHr9hQ6AOByANSNJpsDbo/+Nn+EyQ6blK4w+dcmS/xpYkqLLfrUc+NA/wOK6vF5uEUvhn8my5B/3sruB9g=="], + "eslint-plugin-unicorn": ["eslint-plugin-unicorn@70.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "@eslint-community/eslint-utils": "^4.9.1", "browserslist": "^4.28.2", "change-case": "^5.4.4", "ci-info": "^4.4.0", "core-js-compat": "^3.49.0", "detect-indent": "^7.0.2", "find-up-simple": "^1.0.1", "globals": "^17.6.0", "indent-string": "^5.0.0", "is-builtin-module": "^5.0.0", "jsesc": "^3.1.0", "pluralize": "^8.0.0", "regjsparser": "^0.13.1", "semver": "^7.8.4", "strip-indent": "^4.1.1" }, "peerDependencies": { "eslint": ">=10.4" } }, "sha512-uAF9xMcVvvhTfvusCgogJ1wh4To3q2KhVMw3i1Apf/ILTbxsCjscvraAZACsEurb7no2fdXblD3whcbVnjw5zg=="], "eslint-plugin-vue": ["eslint-plugin-vue@10.9.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", "nth-check": "^2.1.1", "postcss-selector-parser": "^7.1.0", "semver": "^7.6.3", "xml-name-validator": "^4.0.0" }, "peerDependencies": { "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "vue-eslint-parser": "^10.3.0" }, "optionalPeers": ["@stylistic/eslint-plugin", "@typescript-eslint/parser"] }, "sha512-4g7ZP3pYcuqd7Zp0pzUKcos0W+RkjBz4EGdhJ92FcYk6v03Ti/GK5NwjgsjxHK+98eXDbHeK7VtX1az7/8doZA=="], diff --git a/cfg/node_root.mk b/cfg/node_root.mk index 1420b6ef0..b12f6c154 100644 --- a/cfg/node_root.mk +++ b/cfg/node_root.mk @@ -3,4 +3,4 @@ export DEVENV_NODE_ROOT # Dummy target for mbake linting .PHONY: all -all: ; \ No newline at end of file +all:: ; \ No newline at end of file diff --git a/frontend/bun.lock b/frontend/bun.lock index b676d418c..e8570a768 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -5,7 +5,7 @@ "": { "name": "codex", "dependencies": { - "@unhead/vue": "^3.1.6", + "@unhead/vue": "^3.1.7", "@vueuse/core": "^14.3.0", "dequal": "^2.0.3", "eslint-import-resolver-oxc": "^0.15.0", @@ -15,15 +15,15 @@ "vue": "^3.5.39", "vue-pdf-embed": "^2.1.5", "vue-router": "^5.1.0", - "vuetify": "^4.1.2", + "vuetify": "^4.1.3", "xior": "^0.8.3", }, "devDependencies": { "@mdi/font": "^7.4.47", "@mdi/js": "^7.4.47", "@pinia/testing": "^1.0.3", - "@types/node": "^26.0.1", - "@unhead/bundler": "^3.1.6", + "@types/node": "^26.1.0", + "@unhead/bundler": "^3.1.7", "@vitejs/plugin-vue": "^6.0.7", "@vue/test-utils": "^2.4.11", "@vue/typescript-plugin": "^3.3.6", @@ -33,7 +33,7 @@ "sass-loader": "^17.0.0", "toml": "^4.1.2", "typeface-roboto": "^1.1.13", - "vite": "^8.1.2", + "vite": "^8.1.3", "vite-plugin-checker": "^0.14.4", "vite-plugin-dynamic-base": "^1.4.1", "vite-plugin-run": "^0.9.0", @@ -324,7 +324,7 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + "@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="], "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], @@ -874,7 +874,7 @@ "valibot": ["valibot@1.4.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g=="], - "vite": ["vite@8.1.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ=="], + "vite": ["vite@8.1.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA=="], "vite-plugin-checker": ["vite-plugin-checker@0.14.4", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "chokidar": "^5.0.0", "npm-run-path": "^6.0.0", "picocolors": "^1.1.1", "picomatch": "^4.0.4", "proper-lockfile": "^4.1.2", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@biomejs/biome": ">=2.4.12", "eslint": ">=9.39.4", "meow": "^13.2.0 || ^14.0.0", "optionator": "^0.9.4", "oxlint": ">=1", "stylelint": ">=16.26.1", "typescript": "*", "vite": ">=5.4.21", "vue-tsc": "~2.2.10 || ^3.0.0" }, "optionalPeers": ["@biomejs/biome", "eslint", "meow", "optionator", "oxlint", "stylelint", "typescript", "vue-tsc"] }, "sha512-Tw0U9UgHIRiZ+Yoe4Gh0RrYoBiCVmO9j4tomVdYr0KUjUsqXMPhqW8ouoSWmOzGp5Iimipbl3bNXZcK7OeP7Qg=="], @@ -900,7 +900,7 @@ "vue-router": ["vue-router@5.1.0", "", { "dependencies": { "@babel/generator": "^8.0.0-rc.4", "@vue-macros/common": "^3.1.1", "@vue/devtools-api": "^8.1.2", "ast-walker-scope": "^0.9.0", "chokidar": "^5.0.0", "json5": "^2.2.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "scule": "^1.3.0", "tinyglobby": "^0.2.16", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1", "yaml": "^2.9.0" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34", "pinia": "^3.0.4", "vite": "^7.0.0 || ^8.0.0", "vue": "^3.5.34" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g=="], - "vuetify": ["vuetify@4.1.2", "", { "peerDependencies": { "typescript": ">=4.7", "vite-plugin-vuetify": ">=2.1.0", "vue": "^3.5.0", "webpack-plugin-vuetify": ">=3.1.0" }, "optionalPeers": ["typescript", "vite-plugin-vuetify", "webpack-plugin-vuetify"] }, "sha512-2SUzXt2Q71/cVmSZhU6kOmAo1ev3NZaI/ynj55TTcu6jXAc9kezoqZKY+Xm+0STnAOcCoTseF0qPSs01LjUaaA=="], + "vuetify": ["vuetify@4.1.3", "", { "peerDependencies": { "typescript": ">=4.7", "vite-plugin-vuetify": ">=2.1.0", "vue": "^3.5.0", "webpack-plugin-vuetify": ">=3.1.0" }, "optionalPeers": ["typescript", "vite-plugin-vuetify", "webpack-plugin-vuetify"] }, "sha512-tI6GHgICixvnoncWkSBNMKfwzjbt6H8FxEtGvUxYirOIBu00u5TUANgUbu1ly6wr9sVjvwqakCjUiAYJFsqpMw=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], diff --git a/frontend/package.json b/frontend/package.json index 86b99bd8e..d18e9aa18 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "codex", - "version": "2.1.0", + "version": "2.1.1", "private": true, "description": "ui for codex api", "type": "module", @@ -26,14 +26,14 @@ "vue": "^3.5.39", "vue-pdf-embed": "^2.1.5", "vue-router": "^5.1.0", - "vuetify": "^4.1.2", + "vuetify": "^4.1.3", "xior": "^0.8.3" }, "devDependencies": { "@mdi/font": "^7.4.47", "@mdi/js": "^7.4.47", "@pinia/testing": "^1.0.3", - "@types/node": "^26.0.1", + "@types/node": "^26.1.0", "@unhead/bundler": "^3.1.7", "@vitejs/plugin-vue": "^6.0.7", "@vue/test-utils": "^2.4.11", @@ -44,7 +44,7 @@ "sass-loader": "^17.0.0", "toml": "^4.1.2", "typeface-roboto": "^1.1.13", - "vite": "^8.1.2", + "vite": "^8.1.3", "vite-plugin-checker": "^0.14.4", "vite-plugin-dynamic-base": "^1.4.1", "vite-plugin-run": "^0.9.0", diff --git a/package.json b/package.json index 8b4a1e27b..235ab73f8 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", "@eslint/js": "^10.0.1", - "@eslint/json": "^2.0.0", + "@eslint/json": "^2.0.1", "@fsouza/prettierd": "^0.28.0", "@prettier/plugin-xml": "^3.4.2", "@stylistic/eslint-plugin": "^5.10.0", @@ -87,7 +87,7 @@ "eslint-plugin-security": "^4.0.1", "eslint-plugin-sonarjs": "^4.1.0", "eslint-plugin-toml": "^1.4.0", - "eslint-plugin-unicorn": "^69.0.0", + "eslint-plugin-unicorn": "^70.0.0", "eslint-plugin-vue": "^10.9.2", "eslint-plugin-vue-scoped-css": "^3.1.1", "eslint-plugin-yml": "^3.5.0", diff --git a/pyproject.toml b/pyproject.toml index d6041e1b6..68ead7117 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ readme = "README.md" requires-python = ">=3.12" license = "GPL-3.0-only" name = "codex" -version = "2.1.0" +version = "2.1.1" [[project.authors]] name = "AJ Slater" email = "aj@slater.net" diff --git a/uv.lock b/uv.lock index 9157bb4b5..240d9cca7 100644 --- a/uv.lock +++ b/uv.lock @@ -551,7 +551,7 @@ wheels = [ [[package]] name = "codex" -version = "2.1.0" +version = "2.1.1" source = { editable = "." } dependencies = [ { name = "adrf" }, @@ -797,62 +797,62 @@ wheels = [ [[package]] name = "complexipy" -version = "6.0.0" +version = "6.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tomli" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/04/2f6763931b09c89b33f58b8562ecb37ee3ec0683b3e8f6e3435cf1d8fbef/complexipy-6.0.0.tar.gz", hash = "sha256:b8a1797cecba2a3f44ccf8a5eb5b080f43ca576100d4d56cc806eb364da00224", size = 352221, upload-time = "2026-06-26T23:28:29.71Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/d9/71ea6005aa68f803763135641a8a667f033415ca100c4de4c7a419ab0547/complexipy-6.0.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:4e95e7199f1b3f3f4fa7981767295aab1ee730cd1affc622744553aa03282129", size = 2330566, upload-time = "2026-06-26T23:26:03.344Z" }, - { url = "https://files.pythonhosted.org/packages/44/99/fe69ffbc26de61723942580d41ed9499976d6c0b7604043ee36d4303bc09/complexipy-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eaa602bd9995585e9439bc069455581261fda7904d919356f211e1dc48552e96", size = 2267100, upload-time = "2026-06-26T23:26:05.208Z" }, - { url = "https://files.pythonhosted.org/packages/47/a3/4a3f6345d084733189bfaf749806ba9d1a2ad3b74ae19996b243c300e437/complexipy-6.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5212ac8464a24a33467ce378333f4edc77d86fcbed0ac6e878b878862b44399a", size = 2451550, upload-time = "2026-06-26T23:26:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/e6/20/c79be2b36df4e80afb947bc679966eb606c8e49766d840047fc26bdd0746/complexipy-6.0.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b36aaecfb199cf3a4e7dfb1f86e85005b44e667e454a2df1e8516c9c1b10cee3", size = 2380956, upload-time = "2026-06-26T23:26:08.582Z" }, - { url = "https://files.pythonhosted.org/packages/27/3c/e8415d235a9b4c09e0521f91bae25d88a53d5ceecfc8cd8b4d87526319bf/complexipy-6.0.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8fde6f56f1e3bcd066d82affea2f5396c52432dd7bad2d1c788b022748dbd88d", size = 2593948, upload-time = "2026-06-26T23:26:10.108Z" }, - { url = "https://files.pythonhosted.org/packages/4a/15/61447323ea0a4f87a3eb20e63c0bda9fc1c16d7e65dbea0665864181a05b/complexipy-6.0.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:61c6ac48f1706beee7bf1a6a2def1b904e37b163425ec4e60448063797ef4728", size = 2831031, upload-time = "2026-06-26T23:26:12.381Z" }, - { url = "https://files.pythonhosted.org/packages/0a/00/249424308ae2129b7d6fa660fe98417d4c1f8a969c0cd0c8866e8ad939b3/complexipy-6.0.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e0ef1ddec08dedf2577a92e761519ec947998e43f19660891ae74001374b702", size = 2510391, upload-time = "2026-06-26T23:26:14.056Z" }, - { url = "https://files.pythonhosted.org/packages/44/30/73cd829832bdc25a011b36ce016d8a848714181b590b7b3fa708ea86074c/complexipy-6.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de951bbc174d5403507e21c0499d53dd365b1eb471d4db0d9df14f4b39155874", size = 2496041, upload-time = "2026-06-26T23:26:16.011Z" }, - { url = "https://files.pythonhosted.org/packages/73/af/44700d1c1ecd8251f0b365ef0ab66db67a5534547b3a2bfd53064a9fb31b/complexipy-6.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e5e254c616bbfee51652a4db894f85223bd6a08d04030cedcd59fec429f9a41", size = 2628994, upload-time = "2026-06-26T23:26:17.768Z" }, - { url = "https://files.pythonhosted.org/packages/d9/d6/1c76e492ebd38b023c424d87523deca05896bef24da2975994952e27cf15/complexipy-6.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:18dedbe4e1d29372bb2a56f947c597624815ed3782b1be345829c1d6a00542c1", size = 2726903, upload-time = "2026-06-26T23:26:19.487Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e8/5d5d2d75205f669f372e8f6e77d513d775622a254e20e313fe77cb7c837b/complexipy-6.0.0-cp312-cp312-win32.whl", hash = "sha256:58115283778e9b17240579109c18109c59302a96a5685c341407de5fe25dd0bb", size = 2030597, upload-time = "2026-06-26T23:26:21.191Z" }, - { url = "https://files.pythonhosted.org/packages/01/a8/e7f8744d3a1b78378ae368ee510e525f2f6dc1f3cd49b166910a09b274e8/complexipy-6.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:07bf7ef9cc2951818527468aba04f55000c2f61f33d5a1fc605df0a40d9f7858", size = 2185127, upload-time = "2026-06-26T23:26:22.872Z" }, - { url = "https://files.pythonhosted.org/packages/8d/8b/4f72e60152690ae2f5125958ce1c3d55733be44e7994d5cbceb0086e46fc/complexipy-6.0.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3f49230e3b1ec3ebd377f5da5cd925474c0f81571dfa8a1ff4363285796c04b7", size = 2329898, upload-time = "2026-06-26T23:26:24.689Z" }, - { url = "https://files.pythonhosted.org/packages/c5/c6/54a336b8d137243849e35851a848a684c4f8564f6237a16980223fe95553/complexipy-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:156d354ff5cea8bb8402738f04822e8dd3cce84b96966124269276271b62680b", size = 2266296, upload-time = "2026-06-26T23:26:26.526Z" }, - { url = "https://files.pythonhosted.org/packages/83/02/e765c59ede1c49372363eae40f4ef11494da8807b57bf91c747d7079503e/complexipy-6.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8022ce76649ea62268de1825d47c1450cfe4e56efbca8eda959bf7967981c2cf", size = 2451585, upload-time = "2026-06-26T23:26:28.345Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ac/71700a3dcc63b9ff0ca9be9db8e0d9d933b1ad375dc997725c33210fdd5d/complexipy-6.0.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0cf7bb0f1eb4d6a8f1d5157421c224c7316be960fb37ded9d0b23f579a9c088d", size = 2380906, upload-time = "2026-06-26T23:26:30.154Z" }, - { url = "https://files.pythonhosted.org/packages/13/d3/24e432d3ae45d6b6eb66a9ccef2089a5712f474d93084db445946cad0f7d/complexipy-6.0.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b191fa3aba9f5108983397f1fac8510563d8b2248e7c5ede87d11949ab9a50f9", size = 2593560, upload-time = "2026-06-26T23:26:31.946Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4f/dd83c579ad5033ba641ff4af7adf5a11529d4e265a0c4c997558c22164f8/complexipy-6.0.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c227c62edb4e57f4f5c8e944e0ffe318576678dcc7ef7d6791d607e0c2ce779a", size = 2831299, upload-time = "2026-06-26T23:26:33.909Z" }, - { url = "https://files.pythonhosted.org/packages/40/af/d972f64d2acb340abcf47eea09835f253777b76fb19609ebbdcf393c3b0f/complexipy-6.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d62af0072479ef0b9656e4f2428a16ee1d5ef9771fe0895af13b1fbd0a89d44b", size = 2510609, upload-time = "2026-06-26T23:26:35.976Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6f/5c01ec1af861113dc42db070830c384df5371183ecd2200ab0a294bd8a94/complexipy-6.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0acbaf5a4156459cb3126d36f4e0dc91ee70fab3a0a30eea42a93bc957954444", size = 2495560, upload-time = "2026-06-26T23:26:37.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/32/d0641a9c5a7dac54586b4f71c921ae15cd2b73230dd429a7d4fa440e63b8/complexipy-6.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd6d9746a376df09d7fce6c88147067e4ec4f3b93c2338ae5d92f31f1475870e", size = 2628513, upload-time = "2026-06-26T23:26:39.179Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8d/4384a2acacd78f0fa2e01741c2331a90ab407be4553823021fa96144446c/complexipy-6.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:496d19ac94bdc3ea7f61b998c10dcb6841ebb3c15c2b9a125c2e9ed4ae8834cc", size = 2726116, upload-time = "2026-06-26T23:26:40.973Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e9/083fe8da2133fc1e155185681059cc09c1e95700ec2f101ba170d78e0b0e/complexipy-6.0.0-cp313-cp313-win32.whl", hash = "sha256:6e248bd6123d48274368a89ff37c0fafcf1d8be7d1643edbd024a99c5c868a1d", size = 2030495, upload-time = "2026-06-26T23:26:43.338Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6d/330bd13defd7e581ecd3b9f7e08abeae394642aecca02afe7ad4befcd020/complexipy-6.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f0823305fbae10bda55e02353c69e983a86d8735f6367f2e8831de8642a51bc", size = 2184557, upload-time = "2026-06-26T23:26:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/04/77/192836d5730ea5011004636fdd2b2bf85ab1c4b36cc8cbdf312ec445dac8/complexipy-6.0.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8576da1b69486719a8fd3ab6532feb998489c3eeccbd39348d2875bb1da6d73", size = 2331520, upload-time = "2026-06-26T23:26:46.664Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/161d9cabfc954d98e18dec71e397022e646118fcf5bac0591628d15dc303/complexipy-6.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4d69423a41ea491ef7518089a0e06b0bce4271a0b048f99d3d5ba0243d298920", size = 2269847, upload-time = "2026-06-26T23:26:48.375Z" }, - { url = "https://files.pythonhosted.org/packages/43/8b/68dd27fb76cafc96ca239bb0bdd33469ad89390c69482b597ea55ee5c77d/complexipy-6.0.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20831a6cc1e34b087508c09cb084548f0e1c4fe8d377f19e808b2bfdbb89fc64", size = 2452747, upload-time = "2026-06-26T23:26:50.17Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8f/952659c358e0fd06cbce62323cbd0223ebebd120f22c1499cd755ed0c39b/complexipy-6.0.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aea6ceae272a0712810193332cba68868d5884d0be880e67499f156d986bfcc2", size = 2378289, upload-time = "2026-06-26T23:26:51.858Z" }, - { url = "https://files.pythonhosted.org/packages/c1/18/e96a1cf00fb81137456ec27ae5cd0ffe674bab1bbda81eae1823f00b9b60/complexipy-6.0.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e96f9d9f92e2d0db534154fa3dd89b7cea78ae880190ddab78c1411c4a09988", size = 2592582, upload-time = "2026-06-26T23:26:53.7Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ee/92304640b66d68c3bdb21420608837b35b07b021af47ef1977146aec7691/complexipy-6.0.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:67a2888ad16d3d6ab6c0e829b384ffc61ebd2e8451854dc03ce9b2c091fb5fb6", size = 2833288, upload-time = "2026-06-26T23:26:55.439Z" }, - { url = "https://files.pythonhosted.org/packages/0f/68/5769aa10ae3b54c4c7c9836824f9145cf09600c6dd1145e29d7fdc0c6ee3/complexipy-6.0.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26cb2fc2f9773e18f893236e451d15af50e3f2afb63368dc831f1ff6e63a9ae6", size = 2511513, upload-time = "2026-06-26T23:26:57.134Z" }, - { url = "https://files.pythonhosted.org/packages/2e/15/e81ca129fd35e4b9fe31edbd2d44e6192694f19e8a10ea5b51308f7e78af/complexipy-6.0.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e07e03c9fdc392a0cb7cf1173e77b230e1680679de4dc6f19e56546c299d89f0", size = 2496350, upload-time = "2026-06-26T23:26:58.873Z" }, - { url = "https://files.pythonhosted.org/packages/14/d9/65b603f6396efd48a470414dd59be047d5ef05d1cc61a2f22d4e73ca2dc2/complexipy-6.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08809b985e62d420914625ab457e8a08d2ab3b7abba911204745c109b44e9378", size = 2631755, upload-time = "2026-06-26T23:27:00.538Z" }, - { url = "https://files.pythonhosted.org/packages/3c/c4/172aa80e24ce12216dda9574a3c0c291fa6e1b329b468f8f115b59b018c7/complexipy-6.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:27f06383023ef1ebcb7f589078aae2bc0b1d0548b53940e2f77e88b38022a566", size = 2727050, upload-time = "2026-06-26T23:27:02.278Z" }, - { url = "https://files.pythonhosted.org/packages/1f/11/1120c854d6baed175e6222786515ebffef178f65f21a17093892953e28a3/complexipy-6.0.0-cp314-cp314-win32.whl", hash = "sha256:d60f0ab5a97e2f0e90bde40f00cca94ecaa9d2c2aa5932e390cc245205d3637d", size = 2030624, upload-time = "2026-06-26T23:27:04.549Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e4/342572d6124b2a621f0d795c870bb56ec68ceba58594f98d7ed582a4aafa/complexipy-6.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:f633bd905ac2525d22eed5f6562159925157ff8f29098989f85f4c207e39bc89", size = 2184811, upload-time = "2026-06-26T23:27:06.575Z" }, - { url = "https://files.pythonhosted.org/packages/48/bd/d748456c30b2b91111555d8281480938a6911e1679bf73622646e612bbc6/complexipy-6.0.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46507fdcba82de9f99e060de400a0d1262903f762fbcc31319b2c3c28dc57ce1", size = 2451132, upload-time = "2026-06-26T23:27:08.262Z" }, - { url = "https://files.pythonhosted.org/packages/e1/af/1056f696a13831d5478f265156a2970b0568c228adf4902bcd7d2df23acf/complexipy-6.0.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1822aa5c7687ce0f7c28be3b585ac7d41bf8a3c7f7a0faacdb9d1e8c4498fef3", size = 2377139, upload-time = "2026-06-26T23:27:11.153Z" }, - { url = "https://files.pythonhosted.org/packages/65/a1/342d704b242afacb81a0e1d757fc0d975e6514e11b43f4b6b1c45e8f3d63/complexipy-6.0.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c42d83bf580fe94a6c03c6416b824c8ae0d5ee5adadd5ce9bbe3d4a2dde4e453", size = 2596046, upload-time = "2026-06-26T23:27:12.868Z" }, - { url = "https://files.pythonhosted.org/packages/30/f8/a6db45359e6dfe085cfa2c5f6543f9a5f260bf82b1c505ace1bfade67906/complexipy-6.0.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f13adaf927fb89076c6ab8bfd7fe6f5357ba6971010c36c42a6028a8b3bd5575", size = 2827908, upload-time = "2026-06-26T23:27:14.61Z" }, - { url = "https://files.pythonhosted.org/packages/26/a4/914862a47d46df6624179afe99d549a57bc6373e1be34bb3c394060a574d/complexipy-6.0.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:908db0568d8206f8297a6c35815db558c5373341d619b0b7237772ed88e137b4", size = 2510973, upload-time = "2026-06-26T23:27:16.633Z" }, - { url = "https://files.pythonhosted.org/packages/4d/2c/6751c897fe28c0c35ed1d46e911447c40609560c0a374b39dc70aac7d65d/complexipy-6.0.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dad7934ad3f26048860fe40fec6d19cc7a3a672fd39fa612b0046320d43ae34d", size = 2495872, upload-time = "2026-06-26T23:27:18.401Z" }, - { url = "https://files.pythonhosted.org/packages/5c/56/6a159d83a2311299bc647b637d679f7a5b044408ec908b2b3e866558375f/complexipy-6.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0b5e2740d220b0d30c62146d8922713032e2dd970dfd68c75aa5fef63aad1be1", size = 2629430, upload-time = "2026-06-26T23:27:20.484Z" }, - { url = "https://files.pythonhosted.org/packages/65/d3/75f2076e7e2a2f467eeee81582bf7fc469628a5ee36231baaf8173a33793/complexipy-6.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d397b65ac058983753ac4b168a05847eab63ef8098a227e02deed0f693e45b9", size = 2726734, upload-time = "2026-06-26T23:27:22.162Z" }, - { url = "https://files.pythonhosted.org/packages/d3/39/1e90bef2211f180cb14f2df6e63b422c50457291545c9476a9b5702d13fa/complexipy-6.0.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae060afa16576db02b751581090c9a067ebfdb9f60c9ab2192ea4290091ba6c7", size = 2593077, upload-time = "2026-06-26T23:27:23.959Z" }, - { url = "https://files.pythonhosted.org/packages/63/86/902f397c6713b32aa26c9a45aed9483219e835f0d34e60ee70751f9ba372/complexipy-6.0.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd99e401033e06eb1028fc64582e45ab26b20ba62172156b9b2df98844273ef0", size = 2496657, upload-time = "2026-06-26T23:27:25.755Z" }, - { url = "https://files.pythonhosted.org/packages/10/d1/a5ea773f3bf77e0d6b436bc9544fc28cf146f42123468254e4b6ec47012f/complexipy-6.0.0-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c57abc9706ad0a96bc8979a6b04e37075ec3eced9a949344370da398ffa6cec0", size = 2595739, upload-time = "2026-06-26T23:27:27.484Z" }, - { url = "https://files.pythonhosted.org/packages/e3/00/fa2cf32da5702fcf462b88d405ea3842e70579d61f9a5a9bd4beba3bc033/complexipy-6.0.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:975340f50683d7047680a0ded036306a2e098c50a72bf3e3ca11c1e0a1ba3315", size = 2496306, upload-time = "2026-06-26T23:27:29.425Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/b3/5b/8ad2d572e42e222d8127b37cad8595d6b7bb1d1d8e0a5bd3cb378f0f1d2c/complexipy-6.0.1.tar.gz", hash = "sha256:ab0ab0c38962a3f58062a16badfb30b88c4a68029040fc5333e1d644b41dc744", size = 352346, upload-time = "2026-07-03T03:25:52.548Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/a8/ee7a6bfe3d54a62f604918b2de7e66f342a33414aa38d0584531762b3286/complexipy-6.0.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a00a018244c5e6f0b6e145517ff59f40c8455775cf0be1337e47d8ba8fc08478", size = 2333561, upload-time = "2026-07-03T03:23:31.696Z" }, + { url = "https://files.pythonhosted.org/packages/74/42/07aba6c0fcf8db82027d3f915d121c52cdf6927722489bc2c8f81ee31f5b/complexipy-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a4a6158431bbccfc5d033c46cafe683b204090a7d62e2d708fc3e7ff0009c09", size = 2270718, upload-time = "2026-07-03T03:23:33.5Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1e/5b869db5eb6213623b9d6f8e0a97c7f53bec0963d8ea2a66ac4ce63b772b/complexipy-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc2fd8ed07086b9f1d669da6f901733984721800854807ecead2bdc79414bfac", size = 2452189, upload-time = "2026-07-03T03:23:35.067Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a7/a6c2c0d28da6caa292a44ffe6bb99bc8217c5222511fdb773de60c6e70e8/complexipy-6.0.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02d4045421fc5f1fb5cfb04ba3a7dac705be76fd45e80da5c699a98735d0b3fe", size = 2381579, upload-time = "2026-07-03T03:23:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/e3/21/fca14853331bd5ce966db33bbda982a6c0b640d14f53c0281e1c81f72cdd/complexipy-6.0.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7a4b4058b19b832a43ca4bf370ce8c618489fa3bc5354c247a4d9a9f7593e37", size = 2598711, upload-time = "2026-07-03T03:23:38.862Z" }, + { url = "https://files.pythonhosted.org/packages/b3/01/1686158d43bd8397171a3c2f0e7a7938b861fc822c6346b02bb72e305dd8/complexipy-6.0.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cbfa2957ea052d3749bed14b0ef78b93c8b443b2fd558a1cb585f574d2f68e3c", size = 2837911, upload-time = "2026-07-03T03:23:40.491Z" }, + { url = "https://files.pythonhosted.org/packages/a3/8c/19f4a3ca2cf4245fefb681a872ebca4a52ab2ebb1d94893da97c1bad684d/complexipy-6.0.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7df5faeb40b1d3b9020fb42acb29370571661715d3cb853f98aa5d6a242d545a", size = 2514337, upload-time = "2026-07-03T03:23:42.044Z" }, + { url = "https://files.pythonhosted.org/packages/62/39/81423ffe22709d8f4c3918f03cbf9e3d7e0dffaf73cc1c167769bc08a06e/complexipy-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3aa9b1efec9dd32714a40af3ff545bd6024def2dcef37940c91643fc35486f4", size = 2496146, upload-time = "2026-07-03T03:23:43.68Z" }, + { url = "https://files.pythonhosted.org/packages/59/c8/438b45da13ed43a44135da34abdb9e48ba8d3332ee1166bd58c59cd03675/complexipy-6.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15bdef5eff10cd801f5fa44243b5e110bc4f44d194f62b80c196245e798a896c", size = 2628582, upload-time = "2026-07-03T03:23:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/36/47/f5fa4218d0acd80f1a17fb4dca64c8c7a278bcef40755c8340a68e546e9c/complexipy-6.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f7e89d0f286c3d327e257b2e874c646e9726f9e09caa59b903a9f9f15b46d962", size = 2728422, upload-time = "2026-07-03T03:23:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/69/dc/94feddb3cc37c6bdcc445b37e7ee1e99151644511a07bdd03c98fc8103d5/complexipy-6.0.1-cp312-cp312-win32.whl", hash = "sha256:3513b6b6afe73f71ca8c1ff52c5b1a8d1aaebb9934d2279bd002e9fc4b6fbf8b", size = 2031415, upload-time = "2026-07-03T03:23:49.124Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8e/ea9b294b9fca952076a1edd826f6ff964913f36b28654f757587a7207273/complexipy-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0ba9cf20affaa91979cc9a664d833f29fc0c02c44b91a889930c434e4c3e8f65", size = 2185216, upload-time = "2026-07-03T03:23:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/97/b5/8cbadf95c24c08c784ecb6c965d6fda10f332cddf7ba6a5221af1c835a33/complexipy-6.0.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b51ef6cc39fb678d417a2145a65596ad5904a4e87137089e32d409ec38e13eff", size = 2332682, upload-time = "2026-07-03T03:23:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a4/78dda753999d5706a05489ed37fc11f1140fce93ea6db993a4ec738bd7ed/complexipy-6.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d7b2c57c166fc72d8f5fcd2f84b2577201ec3546b2c95c2a637b074d2e462c14", size = 2270641, upload-time = "2026-07-03T03:23:54.157Z" }, + { url = "https://files.pythonhosted.org/packages/32/b2/3621279ba8ffe008dd7d6ab77132a4a9d9c7f5558eecbdb9c06f689a1cb4/complexipy-6.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01970de229707df12d78e2b0dcd4227e863d8224790ed0cb43a5501821736a6a", size = 2451874, upload-time = "2026-07-03T03:23:55.698Z" }, + { url = "https://files.pythonhosted.org/packages/91/d0/ca2b5d3d0e7ea098c8171ca4c14e791a5d47e3cca2fa1067b4bce83c0610/complexipy-6.0.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f300897b20ab75c8bb5c26aafc69a6feff022355be0af59d8c8314b1d408a4e3", size = 2381901, upload-time = "2026-07-03T03:23:57.512Z" }, + { url = "https://files.pythonhosted.org/packages/a4/72/b8d11386924ee291f86d60a58bc5e9cae50c739a569b9abab3be23d6e477/complexipy-6.0.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b65d9673d15e372629f9b564fbb4ce46f9293cba721c9be3def490aa8b1eb7", size = 2597418, upload-time = "2026-07-03T03:23:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/f0/60/bec4001c8f384d636aa8489309278d59e1699c8864bef321703238f2d872/complexipy-6.0.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:13c51a3ee518011a554624ded5bb8665eac3c9080636c3cc08efd7282eb718b2", size = 2837794, upload-time = "2026-07-03T03:24:00.953Z" }, + { url = "https://files.pythonhosted.org/packages/d3/33/158945d9fec78d854c15147b2cf1ecd8ea6f6410ff75ca2aa0d8e79f2784/complexipy-6.0.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71549e2b14a3fbdc73c3d87f2b8afea86a196d727ac0d9b07c0c3edf92b6afef", size = 2514715, upload-time = "2026-07-03T03:24:02.546Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/10689195791286c69a6dc5d15e75675d4dd5aa3682f330b7239bbd63c182/complexipy-6.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4199549e08ba3e8d40877da74225efcad39caf7eb35f07ff3cc4ba8a6bbcc00", size = 2496272, upload-time = "2026-07-03T03:24:04.178Z" }, + { url = "https://files.pythonhosted.org/packages/57/3a/9bd04f1985686f926ee146a78749e08363ba2a7c6d101d49d4a358f719c0/complexipy-6.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9ad6516ede6ba71d8b68cee1b8f670d6ac6d9de88cebd82951d2f893587f4ae5", size = 2628251, upload-time = "2026-07-03T03:24:05.998Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e4/b338966c3106344e49cd9abe650840f67969a8abf5d6445e185e1019bc84/complexipy-6.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d86e41ca6264778344fc7bf9ad101de740d94ec0e51bc1c7d5eb06b29d62f56", size = 2727617, upload-time = "2026-07-03T03:24:07.804Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a4/f4b756595968ddf76c70219a253271eaab83ddedc7000296b7410dbb090b/complexipy-6.0.1-cp313-cp313-win32.whl", hash = "sha256:3ad75ba1918af0b88dee5aa5e35c99d3aae705e0fb55b9c518dffbd9bbf5cab1", size = 2031258, upload-time = "2026-07-03T03:24:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ed/a4a55b9cefec4f8e5b0630614e0fdd6f854d96ad2afe5fd6a5c67cad10da/complexipy-6.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:914da63e59780fd28f29ad84d51473ddfb4d35cfa600d9231eb92f1ccc3c83d5", size = 2184770, upload-time = "2026-07-03T03:24:10.991Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/819f85bc6598b3228461e3e0870300fe93b989ad02d59a25a7e647af0203/complexipy-6.0.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9cdd4b30b7f2151a16f5827dae20ac4be8544c13ced4a9fdb4daf37bd38c7b82", size = 2334443, upload-time = "2026-07-03T03:24:12.733Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2e/84ebf2c02f65d9e518a364f58779c46e118ca8b2ad8a39f342cee932811b/complexipy-6.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dd7656fe1c3e6bd5ea2ad3e042e2de4e26c2fb60e6f8ec1871ed75d9d1adef7d", size = 2271854, upload-time = "2026-07-03T03:24:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/43/34/ad2b796d21b05f0b9ae2507c9c4cf57a3abbe4c4806c856f5f12e1bda274/complexipy-6.0.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83c790634d70faaaa90f01124e305ed0db6f30668e260fb578d7db3c35a106e0", size = 2451616, upload-time = "2026-07-03T03:24:17.044Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d6/da63d7b1557546c14294a47ee7617838b5d35508738c5cf65b37b38e0df1/complexipy-6.0.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cbad719a6a74db3cb952abed96c5ea172ffb78416a143271e2736c3660b3e354", size = 2381811, upload-time = "2026-07-03T03:24:18.684Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d7/cf08b7155f38fda311aef87b67ed6f47bb74f56f878bf1af7635e074f6db/complexipy-6.0.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf32d9bde0fe90b7c0c36c56fcb8adf7810d4b0eda0ee1d37019ff31ced33c3a", size = 2598714, upload-time = "2026-07-03T03:24:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/8d/57/c363a9a6b3efd98b775fb9bf138dfdc5cc035edc15f501f2865f189aa0cd/complexipy-6.0.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf4602eb9896cd21145fd6dbe40c101dfd45b9d55a316e93dca256b2939738cc", size = 2840964, upload-time = "2026-07-03T03:24:22.804Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/50d536bf67323e0abe2ae5ac929750402ea48dc0275ef826e604230a4356/complexipy-6.0.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d4903b4cf14bce2e5b51e417c1875cd64b72d03c1886dd959da00ce69772fee", size = 2514328, upload-time = "2026-07-03T03:24:24.616Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6b/635c0291acb531cb15ce138d976c8ecc021c9d8c84286da4efa66e4f68d1/complexipy-6.0.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7fa5e803bcece8e95f98570e03a11455c889ab780c6b43108aee0c48340191", size = 2496921, upload-time = "2026-07-03T03:24:26.292Z" }, + { url = "https://files.pythonhosted.org/packages/5b/39/fb86363098388a4c93bedbe4fa1e1ccb250e8f3d3ca3dd5aae0b1e147ed4/complexipy-6.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fac0f09da5c831ead52c060c49b0cfdfa9f59b9f7d76648f00d8aa320cd598da", size = 2626855, upload-time = "2026-07-03T03:24:27.964Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/2f23cf237eb8b948494e1cf323c3d8b25ff570aede04d0ddbb4465dda604/complexipy-6.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4cee67c6fb1e98bbc30ba44dceff10f64822449d7ee46d71aaa4239f381e6825", size = 2728611, upload-time = "2026-07-03T03:24:29.783Z" }, + { url = "https://files.pythonhosted.org/packages/06/34/e5bcd00f0581430f7ff10a5a0b21e7bee5ee385a2bc6c19fdbf1404075a7/complexipy-6.0.1-cp314-cp314-win32.whl", hash = "sha256:f50d2b62383715f88e6e9a28705ce341e6c8b4d80ec699a823c6b079a8c7ca03", size = 2031432, upload-time = "2026-07-03T03:24:31.815Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e9/d474bcc4de54bc972088069c66a20be78ea0bd080d2b0b971897dfd77a3b/complexipy-6.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:d5e31cc445a9fc787a47b438f495069a303072b03608e649900b4c0865f4f6c4", size = 2185680, upload-time = "2026-07-03T03:24:33.587Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f7/a00bad292745623d1b16fcdc3f5b933fda31b1f69ff8c2bdc5c3922cf996/complexipy-6.0.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635bc9fe5aa26ab07599872ce529da7770af1870417712f8954e0ca7e08a2de5", size = 2451689, upload-time = "2026-07-03T03:24:35.189Z" }, + { url = "https://files.pythonhosted.org/packages/eb/70/5021c3f8dd6645e00ff8b6f9da549ab3f1d75dd036b1517acb790cf2b261/complexipy-6.0.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92697f7536418244c4d0286c0cf4aa76c86b37835aea438a530956e5fc53c7ec", size = 2378153, upload-time = "2026-07-03T03:24:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/cd42a9a4d7b6c488fa1bf1f0f030da87931fb770a3ea0aef13d5cd614839/complexipy-6.0.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a69df4a46b79dce380979e07d4c6e87d0aaf7943669c20672635d45c612e7bf", size = 2597988, upload-time = "2026-07-03T03:24:39.429Z" }, + { url = "https://files.pythonhosted.org/packages/53/d6/ea382f6f43f79a25264813de5ba90f20edd654063403123fe5b8013afd1c/complexipy-6.0.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be35712c7d5ebdcc91fcfc8c986540f40c22c7fe08d8233df5406e91a08b5918", size = 2836822, upload-time = "2026-07-03T03:24:41.209Z" }, + { url = "https://files.pythonhosted.org/packages/56/3c/44d0191e59f6f65477b5c7f8940cecc498574b2f34067fbc83be08452df5/complexipy-6.0.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87a19d4baac23883198867e48f3f84af8bb5d87b3e0a6d67e34e328576a7aafe", size = 2513728, upload-time = "2026-07-03T03:24:44.073Z" }, + { url = "https://files.pythonhosted.org/packages/06/f3/58e50e2b4031be7b0ca09a44d013d7b367888a4d5e74fc16daa3b09b49fb/complexipy-6.0.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a15c2167caa2d187c060869dc20b9cacb2bcfccd557cced4326b4696ad2941a", size = 2496613, upload-time = "2026-07-03T03:24:45.782Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a0/1aad33f322c7f25f0bfa168c19ee0df9e95423c01827c7f4f9b89666c9bf/complexipy-6.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9232716d9314f12165b32b5ef533af0526b2ee64fd768dd7c816e3bc020a385", size = 2628105, upload-time = "2026-07-03T03:24:47.59Z" }, + { url = "https://files.pythonhosted.org/packages/df/27/4d1ad63f222d218f8513cd5bf88baf2388a4cdc00488d0b4f22e461fe57e/complexipy-6.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3c57db8b2b4150cf5ee6738d2048f4cee0d674f18ff1d124f063cdba6ae4ac67", size = 2727809, upload-time = "2026-07-03T03:24:49.844Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d0/746d70761b0d8b06ecd0ef8e1343a173c15c09135142ffe26f528063dfee/complexipy-6.0.1-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e49e032cc28d194f44cff1e9eb6660385007eebdfde9e5b748a7a6c70878dbb", size = 2599630, upload-time = "2026-07-03T03:24:51.71Z" }, + { url = "https://files.pythonhosted.org/packages/5f/65/629a909a351d4690a52e435630d527f143b319b181eb2cce20c47fa6e0e2/complexipy-6.0.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78ca8529285881e2deddd72e245e4ff1daa3a9e72eec0371112e4146364dfbd8", size = 2497198, upload-time = "2026-07-03T03:24:53.498Z" }, + { url = "https://files.pythonhosted.org/packages/ae/14/4ff4defe8517d33db6eecdb55d418678dac0f3b03e805ce3a2565e2323a5/complexipy-6.0.1-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb94e4d203eb2a3a5c5b9588d911450b76d817e04bf81900d225a72a53a17b48", size = 2598463, upload-time = "2026-07-03T03:24:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/695c48cbb030235e1bf7b93e98ae37535da533a7de49fd81a5230673e3bc/complexipy-6.0.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:393aa190df961a17cf7ef582b652a07c4b8402cc00a40f06098bf119eaa764b0", size = 2496649, upload-time = "2026-07-03T03:24:56.885Z" }, ] [[package]] @@ -869,71 +869,71 @@ wheels = [ [[package]] name = "coverage" -version = "7.14.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" }, - { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" }, - { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" }, - { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" }, - { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" }, - { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" }, - { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" }, - { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" }, - { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" }, - { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" }, - { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" }, - { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" }, - { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, - { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, - { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, - { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, - { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, - { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, - { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, - { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, - { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, - { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" }, - { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" }, - { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" }, - { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" }, - { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" }, - { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" }, - { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" }, - { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" }, - { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" }, - { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" }, - { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" }, - { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" }, - { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" }, - { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" }, - { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" }, - { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" }, - { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" }, - { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" }, - { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" }, - { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" }, - { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" }, - { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, +version = "7.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, + { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, + { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, + { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, + { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, + { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" }, + { url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" }, + { url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" }, + { url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" }, + { url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" }, + { url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" }, + { url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" }, + { url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" }, + { url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" }, + { url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" }, + { url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" }, + { url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" }, + { url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" }, + { url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" }, + { url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, ] [[package]] @@ -1188,7 +1188,7 @@ wheels = [ [[package]] name = "djlint" -version = "1.40.0" +version = "1.40.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1199,45 +1199,45 @@ dependencies = [ { name = "pyyaml" }, { name = "regex" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/b3/1f89f154905e67fc8777d668dfaeb0f6a817b7d4579b024aa9d7d4ad711f/djlint-1.40.0.tar.gz", hash = "sha256:d9f51a2f906a8eb0e53335d2f93f971c35cc04f37ba5447cf78daca0fd761473", size = 57282, upload-time = "2026-07-01T02:51:34.293Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/e2/fa08ab453f38fcbc0e7ad9ea3b2373996306d5f6635c936e6cc4f201fa50/djlint-1.40.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ac5e123846c316bd48cfaa6aec0264a0a25964f704ed19929079d39703aed1ff", size = 540920, upload-time = "2026-07-01T02:50:48.876Z" }, - { url = "https://files.pythonhosted.org/packages/6c/3b/fe79c2349778d87c6169adf7c8310c278de1623df60608fcffd368884e88/djlint-1.40.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dbcaf528b85c0fe9c7b5bf022a599731550c89da8d2c480b35021094a22a77ee", size = 511977, upload-time = "2026-07-01T02:50:49.975Z" }, - { url = "https://files.pythonhosted.org/packages/83/b3/fe0048e6d025a0cb08ccc9ec32de248e6886b2b43f5741d6f664b8ff0887/djlint-1.40.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18937b21182388c9fa19bd135c978cc66dd7d0492eb670bb5fc9dd88f15348e9", size = 542379, upload-time = "2026-07-01T02:50:51.141Z" }, - { url = "https://files.pythonhosted.org/packages/03/85/9d09113396991c6c04696b7c76174bdfabd0adc0a8f1509fe23a04a427ca/djlint-1.40.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc7caeca3d947ed37b45e32dbdeb35bae615a01d080f06f9469ca3300f3037a0", size = 567077, upload-time = "2026-07-01T02:50:52.29Z" }, - { url = "https://files.pythonhosted.org/packages/53/f2/aad40d337880d86294e967ae92295852ac16bfeb87be09b95a092504c478/djlint-1.40.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2f4d241cf5661112465d12dbdce310b30d90f1a30a2577a82c99b53042f88888", size = 549798, upload-time = "2026-07-01T02:50:53.582Z" }, - { url = "https://files.pythonhosted.org/packages/bf/54/d5effa3b5f1e39e7414ff700a1463c484b6660dd6945b49030e756c1b59f/djlint-1.40.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1c632aef08a3bc07298853077beb283786987fb8c8626b0055319a23b0297d13", size = 576762, upload-time = "2026-07-01T02:50:55.054Z" }, - { url = "https://files.pythonhosted.org/packages/5a/dd/48f9cf365be5d1b8207b33343d0ed769f73126e185ccbc92cd5df662f002/djlint-1.40.0-cp312-cp312-win32.whl", hash = "sha256:411a590f123603c90a3204eb9a06aeb19dc280cff1ce13abe8ccb6392d1a405c", size = 415689, upload-time = "2026-07-01T02:50:56.64Z" }, - { url = "https://files.pythonhosted.org/packages/4f/9c/e219b04ceb59e372f89d79ab60f6a45986a59982a51971819ae18a99e185/djlint-1.40.0-cp312-cp312-win_amd64.whl", hash = "sha256:7846ac8b5c6b4de5208ef4c4eb086b80fef9d128c28733cd393b30a30a96112c", size = 462298, upload-time = "2026-07-01T02:50:57.764Z" }, - { url = "https://files.pythonhosted.org/packages/73/e4/f20e97cc6ea609a05f630cdfd468806d8cdf6246f8f02a042448009a4fd6/djlint-1.40.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f31eacfc4ab696c4febbe3d640fb19e9ce9a24950541472e732ed086fc07d96", size = 408327, upload-time = "2026-07-01T02:50:58.921Z" }, - { url = "https://files.pythonhosted.org/packages/75/68/649899766a126404ef4efb17ea603742ad99e7c86b8e46a1d5959a3e9455/djlint-1.40.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e9ee699fc4aa3325a7ed9542eb8780b53e4169041e36d9e4e6d86272dc70c8c", size = 537688, upload-time = "2026-07-01T02:51:00.212Z" }, - { url = "https://files.pythonhosted.org/packages/ec/29/8fea3fbfc11cd160226d623de150ff84dc5cd7aef8667863e9e9a7e20697/djlint-1.40.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0db420847928b2b62f0f440506cf7538678e89f0c67bfbcb1da8a1728bf56f2", size = 510806, upload-time = "2026-07-01T02:51:01.398Z" }, - { url = "https://files.pythonhosted.org/packages/93/1d/8b03816e329c87445669e4424c934e8933862cda822121c3b89c60b958a9/djlint-1.40.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c8542f60ceec900527ed54ad5efed893e440a7f3dc66a1e0605ecf086e03237", size = 540814, upload-time = "2026-07-01T02:51:02.537Z" }, - { url = "https://files.pythonhosted.org/packages/23/65/b98a865ff4e464c3f3657c891ce9b5a8bb11a156e281dcb87f06a182c439/djlint-1.40.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac34ede821c08c9ee52f46fa52e1e9ecfec05e37ee6c4c795f1cc9a94fc6f65b", size = 565362, upload-time = "2026-07-01T02:51:03.706Z" }, - { url = "https://files.pythonhosted.org/packages/47/8d/4dacb70a67f9cb560b7c5be8177fef38b0fbdaffc433aac534dfc49ad8fd/djlint-1.40.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:95e1dc37aeabff9395e7534ad8427fd44e3b6d13dc34f772310732728c8f0484", size = 548034, upload-time = "2026-07-01T02:51:04.819Z" }, - { url = "https://files.pythonhosted.org/packages/8d/5a/a113206698bcb06c3fe2ff22686d4287754dd24b51179f89a21f8075520d/djlint-1.40.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c5d7596faefa8fa0f9d6757cb4ce748aa0b95a733975be6ac6d878e751eab9e", size = 575356, upload-time = "2026-07-01T02:51:05.937Z" }, - { url = "https://files.pythonhosted.org/packages/02/37/6f5d9068e3f21dd1ad79129581f0e71a5353e90dba8590d7075d4cb8899d/djlint-1.40.0-cp313-cp313-win32.whl", hash = "sha256:f67168ed8229a4ae81c391eb5dcefce36495e7df3f90f12cfa132bfa1f57983e", size = 415334, upload-time = "2026-07-01T02:51:07.47Z" }, - { url = "https://files.pythonhosted.org/packages/bb/6b/f4dd6c5865ee4ab71b6dd9a8cb264742219924e92939345bc2977e3ef777/djlint-1.40.0-cp313-cp313-win_amd64.whl", hash = "sha256:dec6873d1765b1a9ef3e9f8dd30b76e9b9e3e556cf01ce0c5c0bc0a5901cbd64", size = 461822, upload-time = "2026-07-01T02:51:08.599Z" }, - { url = "https://files.pythonhosted.org/packages/36/27/fc325fe06dcba8e05f6b0a9e84c65dbf76e6789fb8429029402c6da2dc05/djlint-1.40.0-cp313-cp313-win_arm64.whl", hash = "sha256:02816499e712ff9de9bb0ed8c9d21c6ce27a6997a7265e9cc628a625f8c97f9e", size = 407834, upload-time = "2026-07-01T02:51:09.842Z" }, - { url = "https://files.pythonhosted.org/packages/67/a0/ccfa6fe87fa83df572d5d641121f8ac881787c4751719e6ba963c58cf15a/djlint-1.40.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa86ae4c3dd6215d4857bc871b0748789f80e793c5d3b51edec3f6a941a25a87", size = 536644, upload-time = "2026-07-01T02:51:11.422Z" }, - { url = "https://files.pythonhosted.org/packages/26/87/124ebdeb4b0699085596894b5d749182387408cf3b4a1c50834dd4af37d4/djlint-1.40.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7e712a46833e48452ed3ef42325d45658dc12d516aa050eb670d5a04ec730408", size = 510447, upload-time = "2026-07-01T02:51:12.753Z" }, - { url = "https://files.pythonhosted.org/packages/e6/de/d3d5435e9b08364d4e1405f85486568b1d05c3cbb280859822f6c1c4f90d/djlint-1.40.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37bac4e0fea5790c1da958440b1cf8a5f7469e29b630779592222b7dfac92bc4", size = 543448, upload-time = "2026-07-01T02:51:13.864Z" }, - { url = "https://files.pythonhosted.org/packages/22/cc/80f9de5e5890b246814eb1b2eb8fda8975f8563023d4c2377d2120e03bb8/djlint-1.40.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:403dda65e3703bc960425f297f706219d8bfe35f315aaee7b728f83b024530cb", size = 564472, upload-time = "2026-07-01T02:51:15.04Z" }, - { url = "https://files.pythonhosted.org/packages/55/38/61ccabfd6e521a471f661962c5c1a5061a08372e411cbfd42459ebe60da7/djlint-1.40.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e7a9e15e7c69cb5b9fa77979b1efb596fcb43bb92ba5aa115171f1604a93cc7d", size = 551686, upload-time = "2026-07-01T02:51:16.407Z" }, - { url = "https://files.pythonhosted.org/packages/03/34/6f3aab22596cc365a27e07beb2335d9a05489a2bc2878b5d9affa86cf9f0/djlint-1.40.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c25acfec0cb92e98ce6141f421b4a2f298b5370cbf0c3389c3d465d3c63dad50", size = 574685, upload-time = "2026-07-01T02:51:17.767Z" }, - { url = "https://files.pythonhosted.org/packages/63/4e/04cb7ef51707808cf44db6b7d0161645e8c3d0cbea9bd1a41e3c43244fa4/djlint-1.40.0-cp314-cp314-win32.whl", hash = "sha256:52b55054d819c6d12aab52a151990d37d0c0cf8762312c14c9320dafcb91d69d", size = 420275, upload-time = "2026-07-01T02:51:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/4e/eb/e7faf63fef26e2720c2244f9aa4b001568edec193a977e6145cfbe5b8c6c/djlint-1.40.0-cp314-cp314-win_amd64.whl", hash = "sha256:386c038261f38398309279902d1569b5a4cc3d35d290f8914ebe2600eeed1858", size = 469454, upload-time = "2026-07-01T02:51:20.338Z" }, - { url = "https://files.pythonhosted.org/packages/40/45/a3e0f4b5e1df090796f39e386506c7caad17a2be2f04042be85fbc7ee093/djlint-1.40.0-cp314-cp314-win_arm64.whl", hash = "sha256:1375508369c0823791e3de2f35b269b7154721c50e155409e732e552e4bc2188", size = 416231, upload-time = "2026-07-01T02:51:21.421Z" }, - { url = "https://files.pythonhosted.org/packages/b1/14/3bca60c13c25630c35421b27ba37e52b4926466775111de01dd469616340/djlint-1.40.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:333bbd3ff1d5fb6060b79e51f6fa3226bc0a72a4420d34a3cc116c21b1571077", size = 583182, upload-time = "2026-07-01T02:51:22.64Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ba/c055ed80b62ca7b64df346d9d0184747a7631c149dadbdc31e06243c449a/djlint-1.40.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:72e54b420454a1c8a75e2948ca1b4343036e1f44072b508626961d6b563b90d9", size = 558137, upload-time = "2026-07-01T02:51:23.824Z" }, - { url = "https://files.pythonhosted.org/packages/b6/09/b1c7d1c4025954e2ccc18c7cffe9ceb44c67a57a56d7cd8ee2eaa547233b/djlint-1.40.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c455be937397f6d9a0ea5b4c6cb8cff609a7c3e0dae8d41589d813a17e1bf1f", size = 581288, upload-time = "2026-07-01T02:51:25.038Z" }, - { url = "https://files.pythonhosted.org/packages/fa/da/942df736bf17688e2d6d4bbe2cadf9986d811675e821852a80514dabadf5/djlint-1.40.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3681d2171f768bd123b2a55843bfc60d35bb9d642f4bca65169c751d25f33af6", size = 601433, upload-time = "2026-07-01T02:51:26.251Z" }, - { url = "https://files.pythonhosted.org/packages/90/59/d5d6ebef0825df4845fed30835248cf352d2fefc8128dc96b7b7c8b32029/djlint-1.40.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33d1b4425675d21d31d807d1646a9b10a9cf5b4f0ec6db574bb1d6bde7a0b871", size = 588058, upload-time = "2026-07-01T02:51:27.526Z" }, - { url = "https://files.pythonhosted.org/packages/e6/c3/0cd92ba21bc61134e8cc29b428db7da86776900d44cd4d4f75637cbaa5a0/djlint-1.40.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:57f977770ddc6e7b71e43fc05378086b38f539975fcc574707762ab6cac42f6d", size = 610206, upload-time = "2026-07-01T02:51:28.697Z" }, - { url = "https://files.pythonhosted.org/packages/a6/0e/6e4b289d372eb20c65f2463a30208699aa5dd1a929ce8262f60fc22b6f79/djlint-1.40.0-cp314-cp314t-win32.whl", hash = "sha256:2b4bfc6270bf649cf9278ba1bff70bd7bd432db501f094964204688415c7788c", size = 442087, upload-time = "2026-07-01T02:51:29.823Z" }, - { url = "https://files.pythonhosted.org/packages/f1/9c/b22bbfccca581c9e86869fd0ecaaddf39557585fd16d16ae27b2ac9b4afb/djlint-1.40.0-cp314-cp314t-win_amd64.whl", hash = "sha256:cdab141e407d854062b4e47d383f05733f6d9e9fd86762c373d3ab42632ee096", size = 486583, upload-time = "2026-07-01T02:51:31.209Z" }, - { url = "https://files.pythonhosted.org/packages/27/2e/966f4334272fa3693c6a38b7a300af87cc10631404d840071b57a2ed6d12/djlint-1.40.0-cp314-cp314t-win_arm64.whl", hash = "sha256:70f5da2b7e6381e9cdaa0ba4b5f60e6785176bf0c26f1f2e9fbca436ce42ebe5", size = 432818, upload-time = "2026-07-01T02:51:32.351Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b4/22413d43c66237e60e2d82a3c09be3017c6802e848401b325f29bf4544d9/djlint-1.40.0-py3-none-any.whl", hash = "sha256:c10cf5abdd7e13af4cb7d6f1b9a3bdaa76317a11f101c2f4e71f0176d62eca67", size = 63291, upload-time = "2026-07-01T02:51:33.411Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/98/ff/63a4ce9608c7051ab20954a150f6c0f2bfe7332b14cb4f11ee77036b3c6d/djlint-1.40.2.tar.gz", hash = "sha256:b39ff27a9806e4c4ce319b76a058c94811ef73dbba4a21dbec4d8b5009b48d5f", size = 58974, upload-time = "2026-07-03T00:50:27.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/60/8d7c37b6dce0d04eebf07c8c6b7de2941a8db06babbba69a04feea115d6a/djlint-1.40.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:775df87f239927afead55eba79e041a75cdffb5000eb09db5f959122b79062f5", size = 572293, upload-time = "2026-07-03T00:49:35.313Z" }, + { url = "https://files.pythonhosted.org/packages/c1/26/58fb1338c832e183c797d553949557810ba5e65aabd232c1445d2bfc3bb1/djlint-1.40.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f0d6c97c99b1e538ad36d604b2fdbad31142ce3a116389f3b9c3a56db0ae8ce6", size = 539842, upload-time = "2026-07-03T00:49:36.666Z" }, + { url = "https://files.pythonhosted.org/packages/b2/aa/4524a5409344d8221aad7f0bdb1002555c4c8f0577aead459eeb335b9b43/djlint-1.40.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25f7dfbd4a8073a171a5ffcc01269a9e5b8f7a7225f37fa43168c5172951a5dd", size = 571205, upload-time = "2026-07-03T00:49:38.022Z" }, + { url = "https://files.pythonhosted.org/packages/da/a2/58772dfb57f5e49d10aea99dcc4d6180497d9f3cc9759aa96a54fea244ea/djlint-1.40.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20a50da7799653163ddafcc3af43c2179e0a8657d402b772e10e3b628b24deca", size = 598632, upload-time = "2026-07-03T00:49:39.29Z" }, + { url = "https://files.pythonhosted.org/packages/48/a2/e1e4f31340580bc2a5308f9e75c55277e7273919e4c7b3e4e3b443f03fb7/djlint-1.40.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afc96e355b8d8b60215fab201465201c2595040b2f36095954416b0652119d65", size = 579727, upload-time = "2026-07-03T00:49:40.938Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7a/ea6729fcb39b053d1f5df0a7b1f7f268c2e792a5a36bf31c029f350ad1a5/djlint-1.40.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2cb90f921636a5cfab27f672cca38cee6823bad66f657eeb5c79d542f42c29d", size = 608067, upload-time = "2026-07-03T00:49:42.344Z" }, + { url = "https://files.pythonhosted.org/packages/91/af/9dcd98a260e5929b0690352300f2d37c56a0acf53ce25a7f881b59a022b4/djlint-1.40.2-cp312-cp312-win32.whl", hash = "sha256:9bd4cb9e47e7be21aee243f8be6013dea7fd5c8ef8556876ff6eb8ee94cc216d", size = 434780, upload-time = "2026-07-03T00:49:43.565Z" }, + { url = "https://files.pythonhosted.org/packages/b8/26/fa11eb09ea47005d33da21370b509f3fb065d65499478b4b92e6661436ed/djlint-1.40.2-cp312-cp312-win_amd64.whl", hash = "sha256:b139459a6843d07123182b84d27c1dd4da0e9b9ba52dab00c3a6d3a0b6cf0541", size = 484621, upload-time = "2026-07-03T00:49:44.857Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d6/0d42dbba8e13bdc1defdb3d213596c69b4da8751426399f5d8ba36a31b9c/djlint-1.40.2-cp312-cp312-win_arm64.whl", hash = "sha256:914ebed15d84276021bb720e7e9d66eb8c6587e9724af23a0875dae101e141a5", size = 428267, upload-time = "2026-07-03T00:49:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/86/24/3cbb5bf5681af9d68d5dcc23c0cf2421500a086f7da389287387441ce410/djlint-1.40.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0496e44b61c7581c6e02df9c3b621d04172405dc7186ced9376e0a6a356a5861", size = 569009, upload-time = "2026-07-03T00:49:47.615Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b7/af7eb51a428081116e2aa464b1df190bb7955049e7f39bd1bb358d9c1f14/djlint-1.40.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a4a98dcfc7af894603f64bba29ef9572e3e447d0c0466198eb4a7be5fefd3e4", size = 538693, upload-time = "2026-07-03T00:49:49.042Z" }, + { url = "https://files.pythonhosted.org/packages/d9/60/f1c7c3b8223cc01fdd3bf9bb06afa07a7935e4fbf97d72b69d4e5c7b8300/djlint-1.40.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffa475bab3e8be757d8b75d3e0ae6327d44937209951925c64042f7d89cb17", size = 569448, upload-time = "2026-07-03T00:49:50.407Z" }, + { url = "https://files.pythonhosted.org/packages/d7/23/90554b25ef09c6048659fa7fc44c1267a965962854dc7aa7632913303ebf/djlint-1.40.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49886bdf08e1d896f91eb008b1f7c754031242feb39036cc09a13a7daea41e7b", size = 597012, upload-time = "2026-07-03T00:49:51.675Z" }, + { url = "https://files.pythonhosted.org/packages/2d/dc/317374b75b8085d33fdb103c8490c575050798d6991e7876cc4446e3852a/djlint-1.40.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0476479e5ad676a89d9a8ead2c74d26f608750bbf146dd4530dc571a601940c9", size = 577408, upload-time = "2026-07-03T00:49:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/ea93e7f48d3e8a36410c596796c6f132baed90e241edcb235693caba00a9/djlint-1.40.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:109f89b1ceb3e1c37bf9e02cb2d73c4a65a020a3fafc4fda91e8128ea87c0388", size = 606770, upload-time = "2026-07-03T00:49:54.457Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b8/dc7c3c1fb6903652187fcaa562998560d821097a715978bf8c54ec8dea08/djlint-1.40.2-cp313-cp313-win32.whl", hash = "sha256:9c548033768d51701b3f561c3dffdf5f09a718f9332d58a7039a437778e0cfc3", size = 434326, upload-time = "2026-07-03T00:49:55.675Z" }, + { url = "https://files.pythonhosted.org/packages/97/9a/4cb35d83c165661d494603b1a16c8eaad28f2dc2825ca218f93c1a5a1ff3/djlint-1.40.2-cp313-cp313-win_amd64.whl", hash = "sha256:e9d97bfcc3e35c991ed7b76cbf0d2a50b68dd68ce96fb1e4a1845fb1d1182642", size = 484132, upload-time = "2026-07-03T00:49:57.096Z" }, + { url = "https://files.pythonhosted.org/packages/09/dd/22a2699bf7e0907a78c258138a74d9f3c0709448c8397f3ca29d6f4d5a66/djlint-1.40.2-cp313-cp313-win_arm64.whl", hash = "sha256:e46012fa48495717db33e1e9f9d4472e2413c3f165016691b42b20377b1f4cf6", size = 427278, upload-time = "2026-07-03T00:49:58.354Z" }, + { url = "https://files.pythonhosted.org/packages/61/55/1769a60325e5a4a93305a69e424e747cb2088fe99fa9a0961010fbff1a2e/djlint-1.40.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a2732aa3d8513d7b9c8fbaad70a3eb572267594e94c555aa099ca5c194e42782", size = 567625, upload-time = "2026-07-03T00:49:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/47/38/8512f315ad0b74453bfec16b85964f8ef586f2a07a7b7b85624b89e51e05/djlint-1.40.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e074858fa9fe8ff005b567a2325717b9bcd0e7f067d86ee513d92459c1d22209", size = 538175, upload-time = "2026-07-03T00:50:01.188Z" }, + { url = "https://files.pythonhosted.org/packages/fe/28/a0625a3f251b4299a40477ac1cc5fa70ddcd2988e4f8622011e583ab70c4/djlint-1.40.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af9c71981467ddd2eadd415889638b0f73c3a320747e899268045ab36f9610c0", size = 572323, upload-time = "2026-07-03T00:50:02.466Z" }, + { url = "https://files.pythonhosted.org/packages/d6/0a/d2968b2c62248e1052da7b6af037e6b89cea68a016e65af82c9ca6377db8/djlint-1.40.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0a6156f2de3705c43132100995af254296983831ec4fbb7c6ae25983163e23f", size = 595952, upload-time = "2026-07-03T00:50:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/57/5f/1b2153a08b058bdf2a633543ca1592ec9d84ccac3e725e7917175d686b91/djlint-1.40.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:71435a4a6adffeef0c849bb523d22a27f4d9c433895d1ea2e0db112355c3b469", size = 580515, upload-time = "2026-07-03T00:50:05.185Z" }, + { url = "https://files.pythonhosted.org/packages/15/bb/d1383799594d5f2556caa2ae97cebf9aaeba22cc131c54d773ab03b5a433/djlint-1.40.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6e7ddbe49919093ef386f013e79b55bd5b923ef224cfc19bfb090da6f8555cda", size = 605908, upload-time = "2026-07-03T00:50:06.483Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e5/5e66780ad129ec8b80d4eb410eb1e381c406356ed1f2de4aea1d4b667ba4/djlint-1.40.2-cp314-cp314-win32.whl", hash = "sha256:b6b10d2e1d7400453695257eb9b343a52a6814e2203778d5f9b9b5bbf7228033", size = 440078, upload-time = "2026-07-03T00:50:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/c9/3c/dc2d5dd03b47b39f37e125d9d8f2e8faaa033a9121d7b9423bd888538d6e/djlint-1.40.2-cp314-cp314-win_amd64.whl", hash = "sha256:ee825c58347ab0ba7a8383402840c3294a9c03461cf957753bf7254eef8a87a6", size = 492181, upload-time = "2026-07-03T00:50:09.468Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9b/c5e9b2dad272acfa13f50bfaf6eab33165591d9970a75d8fe003a8d5c373/djlint-1.40.2-cp314-cp314-win_arm64.whl", hash = "sha256:69fc1c7c5704964977e2a12eb870840c5819fa0e47fc49adb2d36e4db1707ade", size = 436170, upload-time = "2026-07-03T00:50:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a3/a639d6b9126e8d62bbeed3122a9f13f0f4f427f5bb6603b0e7a38b2a8323/djlint-1.40.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:94dc6e7be625c86ca7395c2bcf750e65587d10eecf65752ff3b11fdb6790fee1", size = 617749, upload-time = "2026-07-03T00:50:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/d3/fb/49fea811fc720a6e588259ecb95d158edd5a80ed9d6244119ae6f2e6c9f5/djlint-1.40.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4f3d5835adbc05eddb9b05feff1465630015b0639a5a3360e7f194ae80ddecf4", size = 589156, upload-time = "2026-07-03T00:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5c/d750f26136d433f76398469e09ccd778aa6383915c91fa511a9b0ff2f5e3/djlint-1.40.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36c0c55caee9843895acb6032d72e67f8af1e60c2153c20eded6b07cd873f4bb", size = 611560, upload-time = "2026-07-03T00:50:15.142Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4f/2cb74536eb817b6ccabeb19b508f40319cab7ce805c3cb92dc3d873671e2/djlint-1.40.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:828e443e9b1edd8b73047e1f4e1f81dc93e7f6ece36c8fc0500b457eab040088", size = 633415, upload-time = "2026-07-03T00:50:16.556Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9c/6e5ae6a41b6406389ee0b1ec05491cf35957858694dc1b0f39122984b35d/djlint-1.40.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d116c1d51a5034fb1d2fa8403ae5c544b3f4c3fcffff4f62fbf82e4211b7a2fb", size = 618263, upload-time = "2026-07-03T00:50:18.579Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7e/19d7c473c6e023c419cc196688bdd1958902a1aafbc9d4524c3e9b5ddc93/djlint-1.40.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d8e8853f35c24b86bec1259b85c5f3ef6d6dbf6b261cfbe2c5bc8652919aeb24", size = 642095, upload-time = "2026-07-03T00:50:20.031Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d9/882a4f5027f12b2a3ce09bd68f4d922a27e8af132e2ff7af46a597d9556a/djlint-1.40.2-cp314-cp314t-win32.whl", hash = "sha256:e4ca9de0e528695f9486566066f664ec994928f3d68741ebf63cc6ed3531655e", size = 462707, upload-time = "2026-07-03T00:50:21.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/393f3b78735a4d6e41e2ee2bb0f4b9207c93244667d77ab286e46ae91ed3/djlint-1.40.2-cp314-cp314t-win_amd64.whl", hash = "sha256:17d3e40e9a84d55708214ec57db1f6f5ddeb01feedd56696fad85b872a148d67", size = 511638, upload-time = "2026-07-03T00:50:23.204Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/3a44bbb2a918c23e93846895e19ccbce7baab8e2e5be1cca757b3bcd6215/djlint-1.40.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5712c2ac633317b039ef7123ebac197f177d49dc0ddc8a3891edb688ccb5dde9", size = 454371, upload-time = "2026-07-03T00:50:25.063Z" }, + { url = "https://files.pythonhosted.org/packages/0d/8f/463b58ad48f7ebcf1d72d0ce34b9a4c86cf056885240f66e286ba83f4c64/djlint-1.40.2-py3-none-any.whl", hash = "sha256:861ae83cf2aa0754d401e0eea074a8d2c9b5a13b1c930e46a494979f7d112c7e", size = 65070, upload-time = "2026-07-03T00:50:26.193Z" }, ] [[package]] @@ -1351,63 +1351,63 @@ wheels = [ [[package]] name = "granian" -version = "2.7.8" +version = "2.7.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/d5/70694e4d7ed4e065a1b3a53695a02fe575bad5cca91c4a0107dfb02711ed/granian-2.7.8.tar.gz", hash = "sha256:e384b1f08ed3deebdee3516c6db583a26aa20d5e893ce8e8867301fda3902c10", size = 128783, upload-time = "2026-06-30T16:28:27.522Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/59/d5aa3d0866b9ac5437b628a6661584522b5c54ec67247f13beebcf524502/granian-2.7.8-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7ea0f5e70bae07da53435e43dd2e0c4313dbcdd79cb02fb6e5b22d6740a7b389", size = 6424099, upload-time = "2026-06-30T16:26:34.188Z" }, - { url = "https://files.pythonhosted.org/packages/b6/54/87cdd465be35ee3eb3cf8202c2823c6d65d3d903de5ed1fed305b74819c0/granian-2.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7be22a64a52322a7521bdd427197478c54158136093e38eddc6f23c102897134", size = 6157004, upload-time = "2026-06-30T16:26:35.981Z" }, - { url = "https://files.pythonhosted.org/packages/69/6b/b4bb56842b2798c3d03dd4e69c7d31ae8b2b837a7a48fa7b5ff2ed7195cc/granian-2.7.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:806451ace414b5069d683486340d697a68bc0c67347c7fbc2eaa9c2f97aad4f0", size = 7238218, upload-time = "2026-06-30T16:26:37.735Z" }, - { url = "https://files.pythonhosted.org/packages/57/b0/5ce2ad7fc6621f7bc8c32dcbaacbb432912c09b8c9f31355bedbf561e3cf/granian-2.7.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba4b7d0b058a562cbce184076999df929c2a2fad3686532f4260dd8c19e8ad4f", size = 6456624, upload-time = "2026-06-30T16:26:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/90/e1/e9535c775dcdbea52a7ac9ca46b7be788bde2050a12b11d3f99eb5ce4427/granian-2.7.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b212dc338595d3ee0b25af172413e5de7497ce2120c75e03f5808c8ba68b987", size = 6921518, upload-time = "2026-06-30T16:26:41.211Z" }, - { url = "https://files.pythonhosted.org/packages/16/a7/49b0a0a0bc8457148518f87a776098abe5ff374f82ca5d48788ee94478e6/granian-2.7.8-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7a5832921130cdfbc67a893e284ccc90e4ed09208eee9574e110cd18506ad676", size = 7167834, upload-time = "2026-06-30T16:26:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e6/686452abe5a46708047d7e3ed50798534bd9745163fd830ae8b7843af98a/granian-2.7.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:cf58286ae4a34b8d58d327d82943e92221343e2a65b85f0c6d1ce41e2ed93b3d", size = 7126269, upload-time = "2026-06-30T16:26:44.928Z" }, - { url = "https://files.pythonhosted.org/packages/16/a3/606da026cd23dfcf566a786a35cf780dc9ba15c0dd46c506253934ab0f03/granian-2.7.8-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:bca090674248ce007f9996458b88746980a415432301fb6cad7ca9cfd7b19db5", size = 7345953, upload-time = "2026-06-30T16:26:46.871Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f6/6601110db897c76c69025ede25041f174090810b735ffa90ca16f48406ba/granian-2.7.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:6714233ac6041ae201cfbf9b5978b18979268604ac2616278a4281a973ddfe18", size = 6956904, upload-time = "2026-06-30T16:26:48.787Z" }, - { url = "https://files.pythonhosted.org/packages/0c/04/dccd3f261affa376adf679b88264cd3456ab19c0e408435e3107acaf581b/granian-2.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:46c4b81ad590af60aed5727926edcc80f0f8ebf16e6e2effb3c78eb4399c5be6", size = 4027708, upload-time = "2026-06-30T16:26:51.273Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7e/d00becd226e5076da180e9a735a397c6c750590e49f04004411949fd5b53/granian-2.7.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d75394668a8465cc053266b589593d43b185f986ec67d324d91d02a227a35f8c", size = 6423620, upload-time = "2026-06-30T16:26:52.985Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d7/b52fdb8202afac25ee638a07561464b598b26ff1542186d54113d5b13724/granian-2.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8c532e2d58a6d7078caabbbd725d3d08414d0929438b1c28af88255d0fdde615", size = 6157326, upload-time = "2026-06-30T16:26:55.242Z" }, - { url = "https://files.pythonhosted.org/packages/d1/17/25a622b68afe99f81a60f2e94232e4bedc888beea415d806fbc8d2e37bf1/granian-2.7.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20378307e8705cc328ab512de6a7d72590e3406bbd7094f90b1124c2d402224f", size = 7238012, upload-time = "2026-06-30T16:26:57.141Z" }, - { url = "https://files.pythonhosted.org/packages/a0/5a/c0e616da2ada06cb455b81833d2ca5ac567c762082c88f285489beaca183/granian-2.7.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab76cb78726adee52cd2565e0cc5e26508fd3519a16b634fb0207c95405c9814", size = 6456801, upload-time = "2026-06-30T16:26:59.012Z" }, - { url = "https://files.pythonhosted.org/packages/20/82/61859412d613c29f4a8ffe9fdcaf1393958eaf4932891ec3b7ee45464344/granian-2.7.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d22943980c7516a64411e04d7119c0ae7cf540fc4a6ec09c60f4c8d633581df", size = 6921466, upload-time = "2026-06-30T16:27:00.682Z" }, - { url = "https://files.pythonhosted.org/packages/90/c1/a4f84be3a4ae7fe3c028ad7ab2cbec683b79ee21be7020ab3adbeff3abb2/granian-2.7.8-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:46feb2cc36bbf262ac29e79496bd42554d036a35ab45f7e517c315925c8f8034", size = 7167926, upload-time = "2026-06-30T16:27:02.586Z" }, - { url = "https://files.pythonhosted.org/packages/36/5e/737d0d84456d5be9a5249bcf7cc9eba731a70a0a03ccd7cdecd2addddccb/granian-2.7.8-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:53f588a59db89c422aeaf6f0a5d1144dd688a753223abdb5a22c980792c80962", size = 7126258, upload-time = "2026-06-30T16:27:04.832Z" }, - { url = "https://files.pythonhosted.org/packages/65/3e/7aa8a1968e309f79ef9c1a196485392cbb4ea90886f067a0060ab5f88029/granian-2.7.8-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:055ab90bf11b7e3e149f104a4e33069748cb09997f57b68a248f447dd9f8a9ec", size = 7346786, upload-time = "2026-06-30T16:27:06.877Z" }, - { url = "https://files.pythonhosted.org/packages/07/89/57b6dc76eb0a686c5ddd383f68a7d301e717e73b0867399071b8c86b02dc/granian-2.7.8-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:0572d0fc41c7c6429ed8adf0c37979e2fe661a9410d795991e32c06771ed972f", size = 6956971, upload-time = "2026-06-30T16:27:09.119Z" }, - { url = "https://files.pythonhosted.org/packages/e8/3e/a0bada2a74d67166f872251f66195cd0d369f2a500abf161f2766c8f3197/granian-2.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:b40187b6bf8b98c3c5f55989b443280a234c66b3b40e4cf2a8e76303a1e78658", size = 4027664, upload-time = "2026-06-30T16:27:11.03Z" }, - { url = "https://files.pythonhosted.org/packages/b9/95/1d1384785249012d4d91ad14594fc5609017bd864474919c6dc463f873bb/granian-2.7.8-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:c49c42d50a89ac66dede690b8a5b41d84973e9df3bd5f604e4338c134e9a2619", size = 6320512, upload-time = "2026-06-30T16:27:12.692Z" }, - { url = "https://files.pythonhosted.org/packages/df/9d/5310ff5c409c45e5b4265ef8b7845aeef1b8748ef53e5b3451bb4b416a8a/granian-2.7.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfa1e8745d3a9f855eb6213fba33a6fab0fafe28aa0bf12d511e1174415f7066", size = 6056082, upload-time = "2026-06-30T16:27:14.383Z" }, - { url = "https://files.pythonhosted.org/packages/48/88/4d6834d5a7eb646c8a79f9a2ac9fe9bac1a68058ba199fecfb7b9013c74a/granian-2.7.8-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a0c2448ddc8eb96db67531d4fdd71d9349de57b923756b54060f90ec0fe7bdc9", size = 6280702, upload-time = "2026-06-30T16:27:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/11/41/d22e4b7dead21558da9c07a2cd8436912c77b49f114a59a71fe7bd49c3d2/granian-2.7.8-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff6e3d4eabef33d76cf02afc50b7b77460cf0ba8e0bd00e9b705a6c2d6b7c6c6", size = 7209010, upload-time = "2026-06-30T16:27:18.412Z" }, - { url = "https://files.pythonhosted.org/packages/23/bb/bc1b836e1a7efb03e78c8ac94e9ae2dd76606ffd48b5ac1db4756ea45eb1/granian-2.7.8-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fbd4910078f950944f418e49d244a4a8bb0ac7bf548deb6b0c3ae41726ed16f", size = 6767695, upload-time = "2026-06-30T16:27:20.221Z" }, - { url = "https://files.pythonhosted.org/packages/e1/8a/5c6e47be75f8af62d942d574e114318a96116e0ff79da49bbd477c7b0022/granian-2.7.8-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:4282c77e583c7e69701c94e590cbdd5f3b7e9cfbe2f3a018cd14f428cf9587e6", size = 6887708, upload-time = "2026-06-30T16:27:22.011Z" }, - { url = "https://files.pythonhosted.org/packages/ea/0f/68769de4e9ab2bcc845e889dee149c86df1b455ecb8af011d624f87c4498/granian-2.7.8-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:85570cc41db97a11dbd6c2e393d6438f62a649de11f11bafb09c99b8127e2aae", size = 7056044, upload-time = "2026-06-30T16:27:23.767Z" }, - { url = "https://files.pythonhosted.org/packages/40/2d/81fe4cddceaabc6bf454a64e3141725cbad93043445c34519fed0a984237/granian-2.7.8-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:1f861fdb01aa86682f786d62320c60d114101945c8a6c4d46e1a5133ed170fa5", size = 7291952, upload-time = "2026-06-30T16:27:25.503Z" }, - { url = "https://files.pythonhosted.org/packages/76/7c/971c4ebd7e7bf68d8731a822d720911c1252fd0cfe1b77c951c81a6c0985/granian-2.7.8-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:6c27288f561689bfbc91275ce4fe3596d1e1f6923158b528673014f6d52f63bd", size = 6982314, upload-time = "2026-06-30T16:27:27.979Z" }, - { url = "https://files.pythonhosted.org/packages/ee/98/26b0c48e120cea36762b738751dea42b5b5529c74c0c48e9bd524c064252/granian-2.7.8-cp313-cp313t-win_amd64.whl", hash = "sha256:6fb67bf33cd6a7d30df2b21c973699d932cb15ee84f5573d13a985dacb5e7261", size = 3989007, upload-time = "2026-06-30T16:27:29.755Z" }, - { url = "https://files.pythonhosted.org/packages/02/f4/0a54b6d7b244938e16bef7ab35501c9946012305c00d1438fc78d060d606/granian-2.7.8-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6e044d41e3610c79c23694dd780225745da743df77085bdc02db0aa9bb1616e0", size = 6441789, upload-time = "2026-06-30T16:27:32.221Z" }, - { url = "https://files.pythonhosted.org/packages/a3/a9/b84432d92ebba56402ca82a8482da4bf4b229411a17b806547da0d4830e3/granian-2.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2b667b2af53e1536535d41232593640603d1098be192efa1bb20a0aa53f4efad", size = 6140933, upload-time = "2026-06-30T16:27:34.216Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/0d9d5cd84e7ee3003d7fed448b4547c0ceaea649e6500ca69541c35c420a/granian-2.7.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c20c6dbbb0027b7c07eb19d24bbe497632e1f07537f922f5feebecb36f2d0d6b", size = 7272554, upload-time = "2026-06-30T16:27:36.25Z" }, - { url = "https://files.pythonhosted.org/packages/74/18/f14b882c99446a6fcdcc0317cc4e35cea939122dbd4e86da400cf9315a7a/granian-2.7.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f87f8dc11fc4bfcdffb7dfa2f6927cb08cd1f34d688dbdcb9a399f457f170949", size = 6460220, upload-time = "2026-06-30T16:27:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/12/cf/437bbddc90c8d93121df8ba8c33a45e7f839d5d1d9f62cb0599eeb6f4570/granian-2.7.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee71a2e3da5fb8c673eaa9a3cdbfe231555a62a5dd2ffbbdd45dad3640747fd9", size = 6958690, upload-time = "2026-06-30T16:27:40.085Z" }, - { url = "https://files.pythonhosted.org/packages/91/2c/9dac305c16276cc8b2b4a74ab47a8deaeff9ae8e4d9455d1322cf7477429/granian-2.7.8-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:df2f19313a399b02df5edcd4113bd8a9e08dcc05a1dc92c7f6f72517121389c1", size = 7174405, upload-time = "2026-06-30T16:27:42.022Z" }, - { url = "https://files.pythonhosted.org/packages/d1/16/9e7d1aff7ff58ed6c746dfb27398c91995d78844f46229e63c406403a3ed/granian-2.7.8-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:d8cf6e5b0f13f09136f3573f8317eeccf625c65daf28b35e54ea3378938739cc", size = 7133570, upload-time = "2026-06-30T16:27:43.891Z" }, - { url = "https://files.pythonhosted.org/packages/01/10/986ae9776f346aaf2d1ca6c96f4ea4bd2ceb64e0b348d747161f231d2ce5/granian-2.7.8-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:28167bbd887a91e537e66a4b52e117b270bc67b5c9ce6e21734fd200acfdae24", size = 7351717, upload-time = "2026-06-30T16:27:45.71Z" }, - { url = "https://files.pythonhosted.org/packages/03/2c/f8031160c11464fc9f9c4a87dbbda69cc86afa52f6e93866a0b65851958d/granian-2.7.8-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:805a4cf75dff1ff4f8ac0bd46016e74893e43f28e319bed50ab46149fabf2ea7", size = 7005529, upload-time = "2026-06-30T16:27:47.578Z" }, - { url = "https://files.pythonhosted.org/packages/4c/09/e21ca0e8174dd6502151a9420d780c2acb94ee90a1b22ce0a26a479f32a6/granian-2.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:22096b38513b142e1bccc42c2a8fac013250e47f26c1bf1514c24a5ac33ccb87", size = 4036355, upload-time = "2026-06-30T16:27:49.444Z" }, - { url = "https://files.pythonhosted.org/packages/ac/62/f56c030b7f6f912b7b7227771b247a7bceffe9b7ee513d9822c4e1b0a0b3/granian-2.7.8-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:0508113eaf4d74079e110b267927b843dc8528f56ca1a84bc9a7e5438507b449", size = 6357646, upload-time = "2026-06-30T16:27:51.248Z" }, - { url = "https://files.pythonhosted.org/packages/86/7f/84ac8f8e7258d1e70864e11f7f8146538ffe7e45d2dbcb698d5f97251805/granian-2.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5e79f15e461e5cf94e3f13d6f1eba6d777db128e5b64c284c077dabdc7d879fa", size = 6082427, upload-time = "2026-06-30T16:27:53.115Z" }, - { url = "https://files.pythonhosted.org/packages/51/00/17e53f13c6f27f96fa8566b5585d25b5ed27a2658ddc7166bdd03d09019c/granian-2.7.8-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7697330c3a6316d29dcc92de264a6b89d9bb45a1601ee76652f43dab6ac03126", size = 6281317, upload-time = "2026-06-30T16:27:55.14Z" }, - { url = "https://files.pythonhosted.org/packages/e7/32/e99264e9074533a00bb8c2f0586e1895e898a3f57212d79f34aadda22e23/granian-2.7.8-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:686aac81a28f6285a8748f847a6650e2096da6c93e022b4fafbd2c4bd1a27469", size = 7251923, upload-time = "2026-06-30T16:27:56.933Z" }, - { url = "https://files.pythonhosted.org/packages/9a/b4/3d81ef6a048ca2c860be884b141db65945326edd31f95d841fdd03be7bee/granian-2.7.8-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5333919dc120abb09e5fefc8d61a56a7729d30ddd612974a0e8589cfccea8ad3", size = 6714603, upload-time = "2026-06-30T16:27:58.997Z" }, - { url = "https://files.pythonhosted.org/packages/02/68/ac3333fa30bce76092797b5b2abcc095c0af412a958201778da63808df18/granian-2.7.8-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3ed3e3f0c0813a6df13da2a1f738afaab41183d7171e65eeb0088204e5fb8ec9", size = 6823097, upload-time = "2026-06-30T16:28:00.958Z" }, - { url = "https://files.pythonhosted.org/packages/97/47/24d84410f76eb5abd6c96d982ffeed02369bce98543487cd37407639aa63/granian-2.7.8-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:5e4583a1b013eb7eb4032cfdf7f755714d2df1fb876f73357239a8fff22e579e", size = 7027152, upload-time = "2026-06-30T16:28:03.078Z" }, - { url = "https://files.pythonhosted.org/packages/d7/7c/8621d04b92eb1380ef0b2539b9dcac161af494681e479328556aaa39fb34/granian-2.7.8-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:efe6bf9cb1e220d6eefb529f34c7a7edf30003a4cd023e65b8a58f2c9feca769", size = 7330281, upload-time = "2026-06-30T16:28:05.166Z" }, - { url = "https://files.pythonhosted.org/packages/51/99/1976784d46e78452555b2c552e7ec636b5c10f9f4a7e18dddc0d9718e235/granian-2.7.8-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a2c0be25fc3c55a6cad9b6c2e9f54fd55937591e708ca0bf5db394cc0b34bf7c", size = 6919657, upload-time = "2026-06-30T16:28:07.641Z" }, - { url = "https://files.pythonhosted.org/packages/e9/20/c14507ee2e62800bb356ca782757e2bb7acc65cff8ff80b83fffe71e3301/granian-2.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:b0d588fd0769730b68d767bdc9013e8069a1a29bbe16b67699bf8500227f8f2b", size = 3958245, upload-time = "2026-06-30T16:28:09.471Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/b0/cc/9c752e6173df02c5e37c0df7bffd50c1341109e4b4f8e5073bfd3a72dc82/granian-2.7.9.tar.gz", hash = "sha256:096d9a3396b13826bc63d2cf424ed04daf1ea077beed361d48dca2d55cb4b527", size = 129789, upload-time = "2026-07-03T12:42:03.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/7d/ea14f692056fc08d625aa62b404c10781393f98eadef0208d2fade1f5028/granian-2.7.9-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e04d86c24948dbf3de0b6b1ac9a9d6c82514f5a6c4f27fe49991e5178a1d0d9f", size = 6534691, upload-time = "2026-07-03T12:40:33.299Z" }, + { url = "https://files.pythonhosted.org/packages/68/6e/6e172bad9a56ee79b9eb530dc45f993159872eb948868c6eb529828d46d8/granian-2.7.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3d741def2ad09c8e30880c909023c6d6b78ebe528c5c2e95de8523d967f625f1", size = 6209984, upload-time = "2026-07-03T12:40:34.666Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/f580baa79fc38c6d0ac83e6047150e66d842e5b2e393acc5188f356c73e4/granian-2.7.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:497c99fc3d4fe0342add24e3c53da78f0d9505ec332dae9075527e00918617d4", size = 7161937, upload-time = "2026-07-03T12:40:36.203Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ed/600a820132ce60a987091b8688e60fad3276fd63c1b726c329958d5a4148/granian-2.7.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e49f859672fe1e8e062f3be791bec2b3169505719ccf83459ff48aac52ee1d0f", size = 6440102, upload-time = "2026-07-03T12:40:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/e9/c2/f38c504bdd150a5f8dc4907309d5467df827401614edabe0d7453e384770/granian-2.7.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a720940970ce47b4b348d4e3c9fefe6ce0a40c46ac851c2e4cc0dcf5bd6e2a7", size = 6951056, upload-time = "2026-07-03T12:40:38.953Z" }, + { url = "https://files.pythonhosted.org/packages/78/01/f6ece64a623e604eac9024751aaf1fab11bf38483a8563a7e009ffa55e00/granian-2.7.9-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:809ec8a9f44c69c49b158811ad78a3195d5e361662ce93dcb7afb848f5f2fd10", size = 7106920, upload-time = "2026-07-03T12:40:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/62/18/e6f612945ab888cd7a4ff0d42a51f0d3d274bb1860ef90dfa7386d25ffbb/granian-2.7.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ea963e616edba406969579e296f853bee164db23c1a63b8d2268459797c64810", size = 7053385, upload-time = "2026-07-03T12:40:41.6Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/25eda803061400b4176d7bb3f144948a065dc76299112ac9ec1d91871cc4/granian-2.7.9-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:61270c22d6172fbb6f8e363bad19e8dcaefde27a9b0685bbffaa09835fdf44e3", size = 7346261, upload-time = "2026-07-03T12:40:43.286Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e3/b3b049d22fc1dedf771410036da06c9bf05f9d24b63474147970ebbb99a0/granian-2.7.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b3000658f76ef2069aee455d3a0835e36fb073e8fd6e95d777cc0179eb97f5b2", size = 6991885, upload-time = "2026-07-03T12:40:44.742Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/d71bf6b7e40f9d44f67b9b0ed861fb89d0912daf88cdcb2b7c69feb7f6c2/granian-2.7.9-cp312-cp312-win_amd64.whl", hash = "sha256:eecd2aa017aa92bc165a9ad33c494e1cfcbfc68a5f055b43e729749899590867", size = 4066976, upload-time = "2026-07-03T12:40:46.135Z" }, + { url = "https://files.pythonhosted.org/packages/34/c4/a66d5c6daf849d012e871ed2684a2e7f81c518ce9f9e827f5900d99d5e7e/granian-2.7.9-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:23aa08e4d7563f0acb45424676a7d892a146493b5db0895efb5bc8e5121e3051", size = 6534637, upload-time = "2026-07-03T12:40:47.541Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/27fe98fabcae553e3f5087b46f3185a257ed9ee5d7f145d2fd03309bcb60/granian-2.7.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:233baf237d4d3768b4ca6cb6d26546de242e437e1af6050405dcc4673032977f", size = 6210219, upload-time = "2026-07-03T12:40:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b3/9f72bd1f36bd3825d35eff3bd8d00c7a01affdec91387fec6e33e314dc6d/granian-2.7.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:64eb08bcf4aca0375fae95a27ca2e78e9dfef7ccae8e12daaac8b2f0bd1cc718", size = 7162144, upload-time = "2026-07-03T12:40:50.497Z" }, + { url = "https://files.pythonhosted.org/packages/67/e3/21e699362e4ecd56d254519d042316444b34c2048e32869101db9bfb6db7/granian-2.7.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff89b203cebba0780efa0360b6e23520323d05f799a610d4a07a5681067b7249", size = 6440457, upload-time = "2026-07-03T12:40:51.826Z" }, + { url = "https://files.pythonhosted.org/packages/25/d4/4afb3e49ca32b7b6528c2cde626ff60d43663c3567b500bcfbe40af3ca73/granian-2.7.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4ddd82390b6fb9c025007f7ccd92e99d244211898a7851103f28688889dd905", size = 6950842, upload-time = "2026-07-03T12:40:53.317Z" }, + { url = "https://files.pythonhosted.org/packages/9c/1d/2a19bce46d187296752c9a54cf833f0472232eb6ad4152e99439163fbd1a/granian-2.7.9-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:18e3f870a8c42500fe6c8d73d24a6888920e2a8740810ef1ddb83b0b67ce643e", size = 7106973, upload-time = "2026-07-03T12:40:54.916Z" }, + { url = "https://files.pythonhosted.org/packages/e2/36/091e26516a7d9093068665dfeffc61dd87c0cb9b9869ffdf452296ab237b/granian-2.7.9-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5dda81fbf58d8f173b9990e6300525359dc2641b88f03bbbe31cc693db5f530b", size = 7052872, upload-time = "2026-07-03T12:40:56.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/a4dca67e6b68014d548d957735521ce68b2e99a5474722663d048c740063/granian-2.7.9-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:cae7ccfd34519241e92311e966763650f839b07b1195541cb0f5c180ef22df99", size = 7346092, upload-time = "2026-07-03T12:40:58.276Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/842e71b39a3a02a16d49b4495c20150055e0888aec6ceb1b965df3a872a5/granian-2.7.9-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed501eafd6cb3c63924466860bf478ad23691d7ad16678b2069e75b5328a074c", size = 6991558, upload-time = "2026-07-03T12:41:00.072Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2f/51a4db315adbc5e28bf5ac97cb93e04c5e7e6705dc0ef6c8220851453074/granian-2.7.9-cp313-cp313-win_amd64.whl", hash = "sha256:e79316015dd68624e021281b3d0e2d9446f04160db4f14140561fe4c0ffeaaa0", size = 4066887, upload-time = "2026-07-03T12:41:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7f/62673800ed73a85d5b629f493d06a387834196e9463b115ef4fee35d1928/granian-2.7.9-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:9aa2087ae2103e99ba169f53d718258205bf2ace7df87c9b6dd52e3f71a90335", size = 6331598, upload-time = "2026-07-03T12:41:03.07Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/08878d6a5b010e39f168b3cffe5011183eab4464a58144ea5711d22d9888/granian-2.7.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d52c262b5e0d345c0c0684b7536ea42f370a7bbb4720b1b625d60d84d87c81be", size = 6099199, upload-time = "2026-07-03T12:41:04.617Z" }, + { url = "https://files.pythonhosted.org/packages/34/b3/fc344b4bc668d5f4eae9bddbd0eb8f2517214470dd40d178c6c51d14be22/granian-2.7.9-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6d08149dfa9777ba281ddf7d7df7ae5473cf6c2fa8815ac33301a24acf33e875", size = 6299537, upload-time = "2026-07-03T12:41:05.99Z" }, + { url = "https://files.pythonhosted.org/packages/4f/32/65a9b41f107bf76fd23c6c6102eee6ac1112cb84250408d034517cb415aa/granian-2.7.9-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d019f88e7944c3e3967da833e758c85cb6c35c7a82a8185eba23243b5cf24b7c", size = 7210150, upload-time = "2026-07-03T12:41:07.633Z" }, + { url = "https://files.pythonhosted.org/packages/29/f3/60413bf1a6f6da32b10a3dcfb45c2d09de2dc2f6829555323239635b6398/granian-2.7.9-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e155eb6d3c8827ef3821c80ae6a5f7a61f37562d121240128ce099e3db06ad2f", size = 6673489, upload-time = "2026-07-03T12:41:09.429Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a0/6cd1b05e0a868b224232fb203838272cdd3165d16338a847f726b21926e2/granian-2.7.9-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:ac516f3cbdce733578ee91a8139d624a1dccf9ab782fb9f3ab58254ebe6c29ad", size = 6829812, upload-time = "2026-07-03T12:41:10.945Z" }, + { url = "https://files.pythonhosted.org/packages/9e/72/cbb85914df3e09e9ad6a59406157622a50e64df960e378d1091195a5a0a2/granian-2.7.9-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:73e8fe3d162c66d1eddaee825e59379bbf7a384ff776ae984baf796cf1b81fe3", size = 6976912, upload-time = "2026-07-03T12:41:12.412Z" }, + { url = "https://files.pythonhosted.org/packages/57/7c/ce3d1ef6f8999106a41d828c88f154b6bc6f700950894613e62a3bb46e2e/granian-2.7.9-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:12da3d84a9dce7706a19f6a98e8f7c3624721ff66bc34a60cfc4d58e2d6593b9", size = 7418223, upload-time = "2026-07-03T12:41:13.956Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2f/38daac752e7384c5da56803a65428ef29a196df8e436082813b391877ef5/granian-2.7.9-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:887f0f1e045314044712a2e3799991c7be0ba4129f6806b82e40f502417694d9", size = 6951268, upload-time = "2026-07-03T12:41:15.646Z" }, + { url = "https://files.pythonhosted.org/packages/e4/95/4cbe8728c9e609f33ec676e194a6296cd628bfb9d6c1310f4ab06634f09a/granian-2.7.9-cp313-cp313t-win_amd64.whl", hash = "sha256:2836fd595a144be7e70faebddf1ce7b4e0c136a7017d75660b88c8da63c0f0ff", size = 4003202, upload-time = "2026-07-03T12:41:17.072Z" }, + { url = "https://files.pythonhosted.org/packages/43/fa/b388d36bef00f28f2c99df3ab7a08878116d4d1d654b6f93f7b0ef9cde5b/granian-2.7.9-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:17e6975266757b05fd59163a3b5ccd7a9d50c227b13e58b9e5b47eff0609c17f", size = 6451170, upload-time = "2026-07-03T12:41:18.484Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ac/2fdc222d98960c5d0a1d1970ee52f9f4e3a953b2862d7be8fb043e74e0c1/granian-2.7.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:554543e1085ff6bf60eb0f0e995a8a412e92165e92113f9bae9e1fdfeec2ca72", size = 6169769, upload-time = "2026-07-03T12:41:20.31Z" }, + { url = "https://files.pythonhosted.org/packages/b2/79/beaa9a25285aea37b7bf9c2fa7357871b51ae1a7ee79501d570386e188ab/granian-2.7.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1f06eb3a1c5ce9bf050e94f37310a6add0410ddee75fc65563e86d1619eea384", size = 7269037, upload-time = "2026-07-03T12:41:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/be/e4/ef1ca00cc17664548427908da36d9a16e29e7adb34318d5173d1c00ecd1c/granian-2.7.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d17720e112e40563bfefa7b8031d37372234b568784c07250322887631f168f8", size = 6539295, upload-time = "2026-07-03T12:41:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c5/80826359e26079665562b362f0a5f05261183a8b423e31c954f110313bb4/granian-2.7.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:607a02fbda1905cc0b0764f09fab1d601299e482ebb1d9722e61ca08742fb0df", size = 6981221, upload-time = "2026-07-03T12:41:24.861Z" }, + { url = "https://files.pythonhosted.org/packages/ab/77/d595c9698b7799e28c4dc9a3091d30dbea9e20e9e92926e47f9c974b7a77/granian-2.7.9-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e2eb74974682bf8635dff79f356d3fd86193c8c1ebed1baffa75ad1793fddbc7", size = 7131507, upload-time = "2026-07-03T12:41:26.347Z" }, + { url = "https://files.pythonhosted.org/packages/99/46/5200e2b09feae19b7a96371eb2f1523faf2e99aae60584f286db0263cea6/granian-2.7.9-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:33ba7e10bfd30b196b866a9b4b828c9a108a22325936f7d07def89a6b9f21dce", size = 7067731, upload-time = "2026-07-03T12:41:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/432aaa769de91176944d3210734e27c223c77b76da359699067cd90d4c65/granian-2.7.9-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:e2cf4c926e99718576e4ba201d884a56b0e62d8ede39c7468b26cbe2d98c30e5", size = 7449207, upload-time = "2026-07-03T12:41:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/d8/83/8e5e429ebb1465c998403a60e0078784d28b819954c387a9558d99b6c3c5/granian-2.7.9-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:665a82ce3f48a4b5e1f4cc83115d3b9a2647b3f222d072db6716223e6dfc86d2", size = 7031706, upload-time = "2026-07-03T12:41:31.209Z" }, + { url = "https://files.pythonhosted.org/packages/51/5c/b82a31436c740dd0a87e091998f9c350739cbd260aa3880db6f5418112a7/granian-2.7.9-cp314-cp314-win_amd64.whl", hash = "sha256:74d6e50277621e2faa41931d4ddaeae0735e39a20ebaccc49a2282549a4d5297", size = 4080721, upload-time = "2026-07-03T12:41:32.64Z" }, + { url = "https://files.pythonhosted.org/packages/11/fb/aa241630b4e987c0b343d08c71e20a89098f9c863a12842afa19930f82a0/granian-2.7.9-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1a9b34e5d46a48fa6f017d6afa546f4d35aa6a2a10737e9c49c5c9108e6a4280", size = 6254314, upload-time = "2026-07-03T12:41:34.139Z" }, + { url = "https://files.pythonhosted.org/packages/76/1c/787f63df68e28b9e5793efd80c7ba9e1a0e77663c921525552ac3b0d9305/granian-2.7.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea97a1928b414a35903667440875e0f0f4d2f7a341e8d198ab57f0b2cb70af28", size = 6078452, upload-time = "2026-07-03T12:41:36.228Z" }, + { url = "https://files.pythonhosted.org/packages/b5/60/fcc41cd8f394c12785fc2f9d9843ad4329e90a2468d0aaf3474be3255e90/granian-2.7.9-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:57edbef65585ac69d6ada7b9d7cb6fcd6ebeb2e663833c246f72d3634851f5ac", size = 6298703, upload-time = "2026-07-03T12:41:37.906Z" }, + { url = "https://files.pythonhosted.org/packages/05/0f/555c2f436cf386614e8197ffc3a27c6e3a812149fd84367d2f988361b4fe/granian-2.7.9-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3e39cdfcd5a0ed8e9e8a5e21cd65085ac3a4c73ac5f258f804aedc729cdfa2bb", size = 7241919, upload-time = "2026-07-03T12:41:39.679Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/13bd13b0dcc2697b521e75efbeede7328c0809f3e93e964362fd334c0dd4/granian-2.7.9-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c254a1b2027612f0df6e47e8059fd304cda287be25421e1dced4d6b56fb054c", size = 6673295, upload-time = "2026-07-03T12:41:41.483Z" }, + { url = "https://files.pythonhosted.org/packages/02/0a/c0b977247bf3dd4efa92903dd316b8089f7e67cb21922e83cdcdd98201f7/granian-2.7.9-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f9669323b5cd90be7565711bc75363eccf75b6aecdbb0b286f1a860199ef800c", size = 6830753, upload-time = "2026-07-03T12:41:42.979Z" }, + { url = "https://files.pythonhosted.org/packages/08/b2/8f50b2d83452ee3100fcd1b26a5de5206b8befeb9d36b79e6c20d5f6dacd/granian-2.7.9-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:c6475981a0cbf766575a0146bdbe52439185a5040cef2a3969214ca1ef6a5e18", size = 6976795, upload-time = "2026-07-03T12:41:44.6Z" }, + { url = "https://files.pythonhosted.org/packages/92/3a/bec3533aaaff69a9a984f3fd578a2ca29548c9d8503770b1ba5c2260aecd/granian-2.7.9-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3022cde5c662716db8fe16838ba351e0897768435b9c732afc9a4a7322ed05d5", size = 7420697, upload-time = "2026-07-03T12:41:46.332Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d9/4526cc50a1fe3addcc29fe3c7c676d64046f724a4d8e4e29d76ba7dcbe04/granian-2.7.9-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c7e3193da47554561142be7a58520b36a01f6a2da57a7bcbe61f970fb53cc0c", size = 6950255, upload-time = "2026-07-03T12:41:48.023Z" }, + { url = "https://files.pythonhosted.org/packages/0a/62/aa89482ffbc3e56d533cf8c87a91cfb58c486e6a77c522fa34c9f8874113/granian-2.7.9-cp314-cp314t-win_amd64.whl", hash = "sha256:d2cef5a15afee90683944a3b23694e97d75adeafd59ef85ff3896bc9462695d2", size = 3992366, upload-time = "2026-07-03T12:41:49.523Z" }, ] [package.optional-dependencies] @@ -2136,16 +2136,16 @@ wheels = [ [[package]] name = "mokkari" -version = "3.27.0" +version = "3.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "pyrate-limiter" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/04/6134ee63b1c0079bdd96633fcd3cb353ebf46c27691d18042f3f64a6ba73/mokkari-3.27.0.tar.gz", hash = "sha256:d45c6ee18e3de3f5c8e7cffbaae44a44969e1f82fa6c82db49b06aae845d13ce", size = 74713, upload-time = "2026-05-28T18:21:23.227Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/39/4c5543a72c16116772680e4b0cac22b770b1e89daab6d91ad607c46a0d47/mokkari-3.28.0.tar.gz", hash = "sha256:d47d32c511d3e08d8300d4aef9bd94e62fab98b7913d381e5352f8d85d2efc8b", size = 74728, upload-time = "2026-07-02T19:46:26.02Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/44/3a9847ee5c6d14f4580e1fa94cb7f72d2d344193bf769a10218dc820dc60/mokkari-3.27.0-py3-none-any.whl", hash = "sha256:96c9dcc8297a3376345750eaa9d2d58b99ae366033be792eb147b520b18deaa4", size = 56453, upload-time = "2026-05-28T18:21:22.141Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/d44a2af5192c77345ea64da74307cd899584c8cea6112ffd2f5e6fba4cb6/mokkari-3.28.0-py3-none-any.whl", hash = "sha256:db9aeb6bf1e9d81f2fadf8808050116511707ed0707f4bd0a4487d1b35f27bde", size = 56456, upload-time = "2026-07-02T19:46:25.008Z" }, ] [[package]] @@ -2403,20 +2403,20 @@ wheels = [ [[package]] name = "oxipng-pybind" -version = "10.1.1.post2" +version = "10.1.1.post3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/a1/7b3ba274fafeba75a9c6109d34bcecb3504087f5287c0345f3a9fdf58ac2/oxipng_pybind-10.1.1.post2.tar.gz", hash = "sha256:cf71ed8f447ee1db1d1dad19144f8999917e13df0ac4de3776826c12387bf5b8", size = 179553, upload-time = "2026-05-29T11:37:46.13Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/eb6b06f24b765f0dcb7d85c2834541fae9ce6f0be85e2c8eab7a636f1ec0/oxipng_pybind-10.1.1.post3.tar.gz", hash = "sha256:ca9d9cfa015a1bc0fcb341eb5e7c08ef8cb6577917d6d72eee70a10087e076b2", size = 187238, upload-time = "2026-07-03T22:32:24.849Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/80/5fca2f205121719ac231ff7055961f8ee8c25266d00eb6ae7d0d1bf0e6ef/oxipng_pybind-10.1.1.post2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d8fc5404cd16ea1e48030dbd2d1e3006ded13cd250d6470e37ae2600df67d83e", size = 523107, upload-time = "2026-05-29T11:37:32.235Z" }, - { url = "https://files.pythonhosted.org/packages/42/cc/46d12fd03651a4b480b0eda9f1e0acde07910be445cbc45b2bdc7652192f/oxipng_pybind-10.1.1.post2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:b4a7a16b8abbf39c7153aac0c0252aff7ad94a10e3fd4521d7ff00924f5254d0", size = 489513, upload-time = "2026-05-29T11:37:33.719Z" }, - { url = "https://files.pythonhosted.org/packages/c7/50/758b1fbe5c7e45307b1c8c04b94c3491f9f0d64ef40f564e498af1d6cf86/oxipng_pybind-10.1.1.post2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1bec6f12dba48907e60f3338cda898c85f833fb0cc168fcfd263e489654136f6", size = 529315, upload-time = "2026-05-29T11:37:35.21Z" }, - { url = "https://files.pythonhosted.org/packages/db/5d/cdfd3f0c71d04650885c62426fa4d4cf69f3a5705378445e6dfc154e45eb/oxipng_pybind-10.1.1.post2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f3d3272b67ff49e8bc3cffa04d9c3ae765edf4901632a7cd02c809d47a4263b3", size = 559277, upload-time = "2026-05-29T11:37:36.466Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bf/b81474e0c18c893de88663d6de1029ace3f4cd6280af8ade6f45d2266e0b/oxipng_pybind-10.1.1.post2-cp310-abi3-win_amd64.whl", hash = "sha256:198a9b88b3b7833ee63396b5ddbc6317e8f102bbf66b043c8474b8a40149bdee", size = 456413, upload-time = "2026-05-29T11:37:37.769Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ed/60409eb255bc0fa4aee3ad0bf5b65e965da4be52da4851b60f67a8cc6208/oxipng_pybind-10.1.1.post2-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d8405c3b6024b22fc5a94e1848d20ea69544ea7d52d2afa3d504d9c777918db4", size = 522784, upload-time = "2026-05-29T11:37:39.01Z" }, - { url = "https://files.pythonhosted.org/packages/22/f2/18031795b4d716d2941e498b4fcbeb23f7f7a106e474207805465e3dd003/oxipng_pybind-10.1.1.post2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:cbbde8dc9385af4e656679e04e45690fd406eb5d6a1621d9b14a5cef148fc692", size = 489085, upload-time = "2026-05-29T11:37:40.639Z" }, - { url = "https://files.pythonhosted.org/packages/31/df/9384a6dd3e61d567c67a5b051e816cd8eda7f1a731e19a6a75eb725c57a3/oxipng_pybind-10.1.1.post2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a21a243dbb0d57e89818739a6ac05172fe25011918088bf1e77d64c2cb696bdc", size = 528754, upload-time = "2026-05-29T11:37:42.074Z" }, - { url = "https://files.pythonhosted.org/packages/20/57/8384e6a2bbc48cd928236e1111b5faa2c16f46c21cfecee5253d9afc7eb9/oxipng_pybind-10.1.1.post2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:e9847e0a210b7d971867fea5d11ee81ff86195166e92ce2790e427b38cf4404a", size = 558139, upload-time = "2026-05-29T11:37:43.56Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d4/d8ddd3d4cfc6f0884ee82fb09f04db88c5e19ed3b4af03b931d3800be6cd/oxipng_pybind-10.1.1.post2-cp311-abi3-win_amd64.whl", hash = "sha256:abff496f93da56739979bb84090bdf55bac9b84fbce3b42f1ce0f7ef0b46b7fb", size = 455806, upload-time = "2026-05-29T11:37:44.87Z" }, + { url = "https://files.pythonhosted.org/packages/23/0d/b0c330b7df466a34bd1450d8a948c38b2b1c5a16ca180060d0eb71b2204a/oxipng_pybind-10.1.1.post3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:04849d2d0f2014836c9e5031b4806684e5c8671e1f32aa47fb1995e1212ca3b4", size = 516855, upload-time = "2026-07-03T22:32:08.104Z" }, + { url = "https://files.pythonhosted.org/packages/c2/72/45ff201144278aa0a41a4acb38de46b0cad7fda2bdb048987e6f08db7670/oxipng_pybind-10.1.1.post3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:6fe7a719572d0135c3b512c877a2104c0ff287d3eda9e114492649ad6506eef5", size = 487366, upload-time = "2026-07-03T22:32:09.807Z" }, + { url = "https://files.pythonhosted.org/packages/8b/8e/1a829ef08a7c89e788988f7120aeebe859d6a43c99bb860a02858aa8d0a0/oxipng_pybind-10.1.1.post3-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2dfc6b40abebd58ee643a09d084c7977f9951ea3e56515f8cb9c96a4c09e3975", size = 531147, upload-time = "2026-07-03T22:32:11.679Z" }, + { url = "https://files.pythonhosted.org/packages/79/04/33c4272d73aedc99bef561091623a43d62f3a6de942e47e71943cd652f0e/oxipng_pybind-10.1.1.post3-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ed13db1dcc530ebeeb0d36fb422f945017edf4dee50304182c23665ec9d466be", size = 559186, upload-time = "2026-07-03T22:32:13.366Z" }, + { url = "https://files.pythonhosted.org/packages/16/bc/819547edcd3cab84517672d214dbeb3caa094bcbf4183c1888bbc9c68b1a/oxipng_pybind-10.1.1.post3-cp310-abi3-win_amd64.whl", hash = "sha256:dc7313b75855047499318897a58d761e5883c6a98fa119010ab2a100fc1b785e", size = 461254, upload-time = "2026-07-03T22:32:15.102Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c0/c15f18e7a1b5528e84d453c12c54ee95754a12452b3328c0f5195789eea8/oxipng_pybind-10.1.1.post3-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0de38c64cef6551c94481f9953e2edddd8d9c7ae5387103ff9e0e3c86bfd29c4", size = 515594, upload-time = "2026-07-03T22:32:16.737Z" }, + { url = "https://files.pythonhosted.org/packages/37/91/acf8bf94a762677fe0101e35c45472ee173717ab7395654b947e9a52b4a5/oxipng_pybind-10.1.1.post3-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:f20745f1c9c7e88ab2056442a2184d3ab619c9108e3771169aeb559b85046b9a", size = 486006, upload-time = "2026-07-03T22:32:18.328Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e1/338fccf1a0991889bd72b2e346f46935e95abf19e7002a7fc7132c4b5a39/oxipng_pybind-10.1.1.post3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a2d473e6333f90f02d1b400c890edac745d1f95cb27aaa6521a77a9a14a2525a", size = 528312, upload-time = "2026-07-03T22:32:20.167Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/5bd5b6510567c64563967d38d36b9d7aec62df48f3a17299f6fe63e338db/oxipng_pybind-10.1.1.post3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b5b441203f1675b42d0049aa000cc1c5fd672c1ae8604f86c89a880ba60a9751", size = 557812, upload-time = "2026-07-03T22:32:21.937Z" }, + { url = "https://files.pythonhosted.org/packages/11/91/5b6c5661dc6abb2df1b7ed3932c483c9c842aed5fd6148644a8fb48bfe8b/oxipng_pybind-10.1.1.post3-cp311-abi3-win_amd64.whl", hash = "sha256:8de00b373808def0f9090670d429ae3c2e05fefe1b0307a6e60290355f44394c", size = 460466, upload-time = "2026-07-03T22:32:23.347Z" }, ] [[package]] @@ -2518,71 +2518,73 @@ wheels = [ [[package]] name = "pillow" -version = "12.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, ] [[package]] @@ -3964,27 +3966,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.55" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/08/48/f687c8d268e3581f2f104d1f2ac5944d5b5e841b3695c613b3f263e5bbf7/ty-0.0.55.tar.gz", hash = "sha256:88ca87073825a79a8327c550efcc86cec94344890244c5946f84c9e44a969f31", size = 6040230, upload-time = "2026-06-27T00:27:29.385Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/a3/1a90ba7e5a61c6d09adb92346ddba97668095fc257b577af433e5ac4f404/ty-0.0.55-py3-none-linux_armv6l.whl", hash = "sha256:31e83eef512d066542fe990fe1a3b814423abd1616376c54e48af7045b3e1749", size = 11677249, upload-time = "2026-06-27T00:26:52.18Z" }, - { url = "https://files.pythonhosted.org/packages/82/3a/669f9aa478c38243e213a2684db1502086026cfadc15bb1b29b7cbde030d/ty-0.0.55-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab4bca857950608fea73e269e2da369d43e6467131de85160d68e2fa466fa248", size = 11444180, upload-time = "2026-06-27T00:26:54.576Z" }, - { url = "https://files.pythonhosted.org/packages/15/a4/6a4b2507a53ce6530c66c5b4fe0d58551eb1748ffa9e0696c32fdd55bbd4/ty-0.0.55-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55032bfd31bf2c5355ee81bdc6407b144a1cc7ee41e5681dd1368e4cef2ba327", size = 10963134, upload-time = "2026-06-27T00:26:57.348Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ae/a3b1a0f1cc83b7d258662cb98aa80a720c2e671d0e8fa0d17a4d5d057a7a/ty-0.0.55-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1e049f69ce65b3c269af67624607f435e1c32319786c1e453ef9611502f295", size = 11493517, upload-time = "2026-06-27T00:26:59.26Z" }, - { url = "https://files.pythonhosted.org/packages/0d/9f/311ce39065a979ef40a9b847f685c8e02464e53adf1671e081eea90640ca/ty-0.0.55-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:631409975c681d5a280fc5a99b7b32e9e801f33be7567c6b42ec331362f59d7d", size = 11460590, upload-time = "2026-06-27T00:27:01.425Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8f/3bf29aa77bd78aae48275153135a2052fa7d3ccdf1ecabeb99c8773abd66/ty-0.0.55-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e08cb0436e68b9351555ae8f2697138c9009b4d5b4ae4272232988b2a431a98f", size = 12098430, upload-time = "2026-06-27T00:27:03.596Z" }, - { url = "https://files.pythonhosted.org/packages/bc/6e/e88411a88240b94640bba06fb6d0d92b247fbeef47ee2bc71f39e58c2558/ty-0.0.55-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16c215ad9f823829409b94ee188cfaa4563f6e1384f6ce3fecb1db75f6c7cf7c", size = 12673086, upload-time = "2026-06-27T00:27:05.589Z" }, - { url = "https://files.pythonhosted.org/packages/6c/7e/8f1762fb7f9245a68ba5ae338d73c59403ce57554e5d311b8bb55027b0ec/ty-0.0.55-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b510eb8f4032baf11b7aee2f1d53babc3b4ca03939b9cdcf6a9d15761d575188", size = 12242559, upload-time = "2026-06-27T00:27:07.714Z" }, - { url = "https://files.pythonhosted.org/packages/72/1f/143657daf2670d977dac83435f1fe03d4843efb798d8e1e75950e541aadd/ty-0.0.55-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc05e7959709c3b9b83aa627128a80446865e3c1a4882638dcff6d776dc34a", size = 12021409, upload-time = "2026-06-27T00:27:09.881Z" }, - { url = "https://files.pythonhosted.org/packages/6d/30/69487c439dd1fad3a4a3d96f0a472193de297eaba6fc4b8ea687ce434ac2/ty-0.0.55-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:636e8e5078787b8c6916c94e1406719f10189a4ca6b37b813a5922ce5857a8c7", size = 12303807, upload-time = "2026-06-27T00:27:11.986Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ca/cd88b6493dafc7db077f5e17c0438eb3af6e2d6d08f616dbb52a8ddfd567/ty-0.0.55-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ef7d6deaacb73fec603666b5471f1dc5a5699aa84e11a6d4d644dd07ca72121e", size = 11441263, upload-time = "2026-06-27T00:27:14.087Z" }, - { url = "https://files.pythonhosted.org/packages/aa/fe/66b6915671653ab739f71e4f1b0528e69da64429b7ebf3840c625b6e43f2/ty-0.0.55-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9aeea0fe5875d3cf37faf0e44d0fdf9669335467749741b8fc0103916fb5cd32", size = 11484584, upload-time = "2026-06-27T00:27:16.311Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4f/7a9c0bbac8b899e9f6c0ec110c6612f52e4db35f6bb17ddc0ef60384fa3e/ty-0.0.55-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0b699c01310dbd2705a07c97c5f4aaeedef61bd9adeea2e7c46aed32401d3576", size = 11759309, upload-time = "2026-06-27T00:27:18.471Z" }, - { url = "https://files.pythonhosted.org/packages/ca/de/b6f8b1b69aa631b5716ef3f985c3b56de0e46c2499cc00d30c402b41f714/ty-0.0.55-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32cbeba543e46de2a983ec6d525d8b56514f7422bd1e1b57c44ccf7bfa72c38a", size = 12128755, upload-time = "2026-06-27T00:27:20.55Z" }, - { url = "https://files.pythonhosted.org/packages/7d/90/a912531e51ee7e076b42972479290fa687c0f5e747b7e773f3033164acaa/ty-0.0.55-py3-none-win32.whl", hash = "sha256:52b968e24eb4f7a5c3bd251db1f99f60dd385890356d38fc619d84f1b423446a", size = 11117501, upload-time = "2026-06-27T00:27:22.714Z" }, - { url = "https://files.pythonhosted.org/packages/4c/7a/99d59843bf8908a7f9f4d13fda107dbad07b7faa28ecd7860eacf363fb1c/ty-0.0.55-py3-none-win_amd64.whl", hash = "sha256:bf39cbfdc0add44d94bd3fff1f53c351418d134b6a66b87efdb7876d7b7a2224", size = 12150106, upload-time = "2026-06-27T00:27:24.881Z" }, - { url = "https://files.pythonhosted.org/packages/b3/44/20987505cedf2a865b08482f0eabc181fd9599b062964057ec8a128a4296/ty-0.0.55-py3-none-win_arm64.whl", hash = "sha256:f7f3700a9a060e8f1af11e4fb63fafcaf272b041781f4ccdfda2b3b5c6c1e439", size = 11560157, upload-time = "2026-06-27T00:27:27.332Z" }, +version = "0.0.56" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/07/fb29aea5235b0aa8ecfc4d1cc6ddf9fba8b863d67d96c6d345694d644c43/ty-0.0.56.tar.gz", hash = "sha256:84d114dc3796361c0fc72945016eabd74d46b9ee64f198cb0e485719704681e5", size = 6050123, upload-time = "2026-07-01T16:44:56.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/48/bce79e7ca5c1cc529d3e0d37ddd1121aea4b68a4f749974ad1cc77161871/ty-0.0.56-py3-none-linux_armv6l.whl", hash = "sha256:186d4a53e15747c947e1ec3d7eec8e345d8e40a1ca10e634c585db52497e87dd", size = 11643066, upload-time = "2026-07-01T16:44:18.374Z" }, + { url = "https://files.pythonhosted.org/packages/80/d1/22555d8a1d719661f10050f3865d877bbf497da908961c75fe22217dd18a/ty-0.0.56-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aae1a980fd9535da0469b7ba2b2e1b54a907743a5e0f442dd57eee9f5bfd034c", size = 11407487, upload-time = "2026-07-01T16:44:20.956Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2d/b3b7a74ce8bc59ef48843ad80179bb0d9598bbd6cfc0d11d519bdf6b1352/ty-0.0.56-py3-none-macosx_11_0_arm64.whl", hash = "sha256:afd3058c0a6c5f241e814734f133008c93ee805f61c9cf4ce7412b8822b5d9ad", size = 10962270, upload-time = "2026-07-01T16:44:22.959Z" }, + { url = "https://files.pythonhosted.org/packages/64/ac/6c2fd7de0304a8a7218a756af74f7e62a5e8540fdb175e0a869e51042345/ty-0.0.56-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:058b52f7a823ac13aae3cae30809dd6b5145794b64d8478f9ef38c75d79b4483", size = 11471406, upload-time = "2026-07-01T16:44:25.327Z" }, + { url = "https://files.pythonhosted.org/packages/50/b6/11d861156861c03c7726b74558f9a0e0092661aff83a4fda1279df28c425/ty-0.0.56-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c66e00c1522add1f2bbdd2e45828c953b35c306b7bef03ec9169c75a63699a0", size = 11445612, upload-time = "2026-07-01T16:44:27.531Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ba/09df108582090f3c0770ec4bc8675affed60248f6793a78d909be16211d9/ty-0.0.56-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40903d71c669a30691b5a5d5728056c7877a1bd6be4f233a38883a8b28cf34d7", size = 12093889, upload-time = "2026-07-01T16:44:29.548Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/dbb4b4ccb69cd64c209ae55b1ab788ace8222c2bc1f6845be9e7cbedbf25/ty-0.0.56-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63fe3947fe0c46c69a7d950e6832ee70a9ec17321fefbff3d2e3c20baf9e5bd0", size = 12666337, upload-time = "2026-07-01T16:44:31.586Z" }, + { url = "https://files.pythonhosted.org/packages/86/e9/73f903fe4a3d9ea02f26f57c1eb07e3b1029ec92b0e8c2364718893440e3/ty-0.0.56-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71a0c1a72f9854532e710e119b6871ffe4542c8a65146f1f65dcd78fecd885b4", size = 12280247, upload-time = "2026-07-01T16:44:33.637Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/cebd222495832f1a00dcd321ba25f3cab804221a4991b992c2178bec68ee/ty-0.0.56-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70d1665596494e24d8ebd198438872b5a56ec3cae5f2bcf6c673be797acc4e3c", size = 11991107, upload-time = "2026-07-01T16:44:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/b7/07/8f7337a07250f42d975cdb6decf47fc5b421e6c7da5e3e7be1e85f63a7e5/ty-0.0.56-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:778f99e51558afc1dbbe48ee38ab6aae7b31390ed8c1a1ef1499b295e9f1e82f", size = 12298970, upload-time = "2026-07-01T16:44:38.243Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b9/a52cd59034a48f5f18c6b155cc2cc36861d874b6d0af204b12c898024c3d/ty-0.0.56-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:867bc5708e0066bb4ff6c7db524bd5deea2676c62bfe71d3303138b3be850af0", size = 11425683, upload-time = "2026-07-01T16:44:40.473Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2e/48e42d33357d52eefb695c0c3fcfc96879b73668a7447d1d1e0ad774fedc/ty-0.0.56-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6012f4189c928edb330a37deb9930f982380bd4aa7c4b8e0428eec9651c7551", size = 11469258, upload-time = "2026-07-01T16:44:42.513Z" }, + { url = "https://files.pythonhosted.org/packages/d5/01/ad1b4138be1e3fa97863af3925aa2134f17a593240c35dc38c3429fb5ad1/ty-0.0.56-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee83de1a7ff4cc32837ec06134ce391d441bc5b35ecd8d3cfe053f120f3e4c1", size = 11758736, upload-time = "2026-07-01T16:44:44.567Z" }, + { url = "https://files.pythonhosted.org/packages/09/34/9d81967ff240eaa57e9249728ef7b7790747cf6d3c9a98ec86b2cfdcc8ee/ty-0.0.56-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:62619b3b0e2c6248ef30d3f0e2f2217ae9893040585be07f32324242f197cd6f", size = 12100242, upload-time = "2026-07-01T16:44:46.584Z" }, + { url = "https://files.pythonhosted.org/packages/c3/36/f51d4666d2de6cf33c1f3a1fcc4bb6b70b197dd6ceaa491eef71d78fe8e8/ty-0.0.56-py3-none-win32.whl", hash = "sha256:b30687bb5cd9729d34c889a289edf32770388d9bb05243e534e723fb45e0381b", size = 11093759, upload-time = "2026-07-01T16:44:49.171Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b4/8fb5d4acfa4afb152245b20fa263069a7547bd1f8e4bfca4eda280c897d7/ty-0.0.56-py3-none-win_amd64.whl", hash = "sha256:ad4c8c47b6f4e3f9ed3fc0b1a5d650088d229e17dd8f63c1826d6bbe94cc3235", size = 12100327, upload-time = "2026-07-01T16:44:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fc/6a183e71edde90d0c35c2303f23f7a45b6891d1a2c45daf7b8f869831e19/ty-0.0.56-py3-none-win_arm64.whl", hash = "sha256:57538f273d444a5f1293fa7860e967178afe3917611fc5eff16b64e1204fe0d6", size = 11538780, upload-time = "2026-07-01T16:44:53.8Z" }, ] [[package]] @@ -4035,11 +4037,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] From 27783ee991b02ec16dcd491f21f366944e4e245f Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Fri, 3 Jul 2026 19:19:55 -0700 Subject: [PATCH 03/66] test(frontend): work around vitest/valid-expect false positive on expect.any The @vitest/eslint-plugin valid-expect rule misclassifies expect.any() as chai's `.any` flag chain and reports "unknown modifier". Disable the rule on the one nested assertion with a documented reason rather than weakening it. Co-Authored-By: Claude Opus 4.8 --- frontend/tests/unit/rename-toggle.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/tests/unit/rename-toggle.test.js b/frontend/tests/unit/rename-toggle.test.js index 17899d611..6a2182567 100644 --- a/frontend/tests/unit/rename-toggle.test.js +++ b/frontend/tests/unit/rename-toggle.test.js @@ -114,6 +114,7 @@ describe("EditPanel — rename toggle", () => { await wrapper.vm.fetchRenamePreview(); expect(HTTP.post).toHaveBeenCalledWith( "/admin/tag-write/preflight", + // eslint-disable-next-line vitest/valid-expect -- false positive: the plugin misreads expect.any() as chai's `.any` flag chain expect.objectContaining({ patch: expect.any(String) }), ); expect(wrapper.vm.renamePreviews).toEqual([ From 4963ad9b931486fa4bad07d385bf0fb947396ac4 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Fri, 3 Jul 2026 19:20:11 -0700 Subject: [PATCH 04/66] update deps --- bun.lock | 4 ++-- package.json | 2 +- pyproject.toml | 2 +- uv.lock | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bun.lock b/bun.lock index 8c110c96e..841e1577f 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "@fsouza/prettierd": "^0.28.0", "@prettier/plugin-xml": "^3.4.2", "@stylistic/eslint-plugin": "^5.10.0", - "@vitest/eslint-plugin": "^1.6.20", + "@vitest/eslint-plugin": "^1.6.21", "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-array-func": "^5.1.1", @@ -225,7 +225,7 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.12.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA=="], - "@vitest/eslint-plugin": ["@vitest/eslint-plugin@1.6.20", "", { "dependencies": { "@typescript-eslint/scope-manager": "^8.58.0", "@typescript-eslint/utils": "^8.58.0" }, "peerDependencies": { "@typescript-eslint/eslint-plugin": "*", "eslint": ">=8.57.0", "typescript": ">=5.0.0", "vitest": "*" }, "optionalPeers": ["@typescript-eslint/eslint-plugin", "typescript", "vitest"] }, "sha512-xRwWHFG0Utp6hXtbGiWk4VdKXCGdExD8kbWrrmFEiG5dk8anOJ+vbWbeOa8EbkocKQRTsx7JAWETccZiBgFp/Q=="], + "@vitest/eslint-plugin": ["@vitest/eslint-plugin@1.6.21", "", { "dependencies": { "@typescript-eslint/scope-manager": "^8.58.0", "@typescript-eslint/utils": "^8.58.0" }, "peerDependencies": { "@typescript-eslint/eslint-plugin": "*", "eslint": ">=8.57.0", "typescript": ">=5.0.0", "vitest": "*" }, "optionalPeers": ["@typescript-eslint/eslint-plugin", "typescript", "vitest"] }, "sha512-5nu7QuW5Dg9v4I9t9ZiU1Hh91BoG0x3lOzOWYTsr1bqpIqcHYzFSQ052LvZwGtEm7Trh+Mr2heYEmZ4LkDjfaA=="], "@xml-tools/parser": ["@xml-tools/parser@1.0.11", "", { "dependencies": { "chevrotain": "7.1.1" } }, "sha512-aKqQ077XnR+oQtHJlrAflaZaL7qZsulWc/i/ZEooar5JiWj1eLt0+Wg28cpa+XLney107wXqneC+oG1IZvxkTA=="], diff --git a/package.json b/package.json index 235ab73f8..5fd1e49ec 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@fsouza/prettierd": "^0.28.0", "@prettier/plugin-xml": "^3.4.2", "@stylistic/eslint-plugin": "^5.10.0", - "@vitest/eslint-plugin": "^1.6.20", + "@vitest/eslint-plugin": "^1.6.21", "eslint": "^10.6.0", "eslint_d": "^15.0.2", "eslint-config-prettier": "^10.1.8", diff --git a/pyproject.toml b/pyproject.toml index 68ead7117..28dc0fbb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "adrf~=0.1.12", "bidict~=0.23", "channels~=4.2", - "comicbox[pdf]~=4.0.4", + "comicbox[pdf]~=4.0.5", "cryptography>=48.0.0", "dateparser~=1.2", "django-cachalot~=2.8", diff --git a/uv.lock b/uv.lock index 240d9cca7..7361ed8ca 100644 --- a/uv.lock +++ b/uv.lock @@ -636,7 +636,7 @@ requires-dist = [ { name = "adrf", specifier = "~=0.1.12" }, { name = "bidict", specifier = "~=0.23" }, { name = "channels", specifier = "~=4.2" }, - { name = "comicbox", extras = ["pdf"], specifier = "~=4.0.4" }, + { name = "comicbox", extras = ["pdf"], specifier = "~=4.0.5" }, { name = "cryptography", specifier = ">=48.0.0" }, { name = "dateparser", specifier = "~=1.2" }, { name = "django", specifier = "~=6.0" }, @@ -721,7 +721,7 @@ wheels = [ [[package]] name = "comicbox" -version = "4.0.4" +version = "4.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bidict" }, @@ -758,9 +758,9 @@ dependencies = [ { name = "xmltodict" }, { name = "zipremove" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/ea/2067d26988699d7fcba228d4848356ad55c2a424f052e3c666a1121054e4/comicbox-4.0.4.tar.gz", hash = "sha256:acf185767938c7ae15d401716961fe95de3e6756e9b8f30d85b9f88b94b1f270", size = 978642, upload-time = "2026-06-30T16:48:26.325Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/b7/06d22b40edba18a7d66988d376205184b1b580ed0f7ab69d7340051f36b6/comicbox-4.0.5.tar.gz", hash = "sha256:337b8ae057a684d814d78b2460efdbe6d2df403d23060977b2f853bc1f0dfd0b", size = 978125, upload-time = "2026-07-04T02:11:17.052Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/a5/4e4dbcb9e7635df0039aabdda3796a1ad2d79c20782ede1e8e633d8d4d83/comicbox-4.0.4-py3-none-any.whl", hash = "sha256:31ac3b960531930c498fd3f6645ef65311868178f583ee55374187a720756cea", size = 328967, upload-time = "2026-06-30T16:48:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/8c/86/ee66f8fd03982dc931ebf17cb68c05a7834d90078ba5ec9c302d8b879dcf/comicbox-4.0.5-py3-none-any.whl", hash = "sha256:6c71ed43fd0c7295cdbc7380fe5bbf04ef5560f00f3356d6f97559b4ca401185", size = 328601, upload-time = "2026-07-04T02:11:15.394Z" }, ] [package.optional-dependencies] From 2462a41adc6eb28bcaaf88551afd17923f4e07d3 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Fri, 3 Jul 2026 19:36:00 -0700 Subject: [PATCH 05/66] fix(onlinetag): drop dead effort option; model Metron's fewer requests comicbox 4.0.5 no longer applies the effort knob to Metron tagging, and Metron's search is now a flat two-step (series_list + issues_list) that match mode does not change. - Remove the vestigial `effort` option (serializer, task, resume params, and test). It was collected by the API but never passed to comicbox's OnlineSession. - Count estimate calls-per-comic per source: Metron a flat 2, Comic Vine keeps its per-mode 2/3/5. First-match-wins bills the costliest single source; merge sums per-source calls. Mirrored in the launcher dialog. - Resume view drops unknown persisted params so a pre-upgrade `effort` key in the file-based cache can't crash the task rebuild. Co-Authored-By: Claude Opus 4.8 --- NEWS.md | 5 ++ codex/librarian/onlinetag/estimate.py | 47 +++++++++++----- codex/librarian/onlinetag/session_manager.py | 1 - codex/librarian/onlinetag/tasks.py | 1 - codex/serializers/admin/tagging.py | 1 - codex/views/admin/onlinetag.py | 7 +++ .../components/online-tag/launcher-dialog.vue | 39 +++++++++----- frontend/tests/unit/launcher-dialog.test.js | 10 ++-- tests/test_onlinetag_estimate.py | 54 +++++++++++-------- tests/test_onlinetag_resume_endpoints.py | 1 - 10 files changed, 112 insertions(+), 54 deletions(-) diff --git a/NEWS.md b/NEWS.md index 5866c0940..637e08ea5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -10,6 +10,11 @@ border-radius: 128px; - Fixes - Fix unable to change password. + - Online tagging time and request estimates now reflect Metron's fewer + requests. + +- Performance + - Metron online tagging makes far fewer API requests (comicbox 4.0.5). ## v2.1.0 - Renaming diff --git a/codex/librarian/onlinetag/estimate.py b/codex/librarian/onlinetag/estimate.py index b681294b6..f7f2cbf50 100644 --- a/codex/librarian/onlinetag/estimate.py +++ b/codex/librarian/onlinetag/estimate.py @@ -10,12 +10,18 @@ The model is deliberately simple: a run's wall-clock is bound by the slowest enabled source's per-minute throughput (first-match-wins means a comic isn't done until the binding source answers), times the number of API -calls each comic needs (a function of match mode), over the comics left. +calls each comic needs, over the comics left. -When merging all sources (comicbox first_wins=False) every enabled source is -queried for every comic instead of stopping at the first match, so the call -count is multiplied by the number of sources — a conservative over-estimate -consistent with the slowest-source bottleneck above. +API calls per comic are counted per source. Metron (comicbox 4.0.5+) is a +fixed two-step search — one series_list discovery call plus one issues_list +lookup — that no longer scales with match mode, so it is a flat count. Comic +Vine's fuzzy volume→issue path does more verification the tighter the match +mode, so it keeps a per-mode count. + +Under first-match-wins a comic stops at the first source that answers, so we +conservatively bill the costliest single selected source. When merging all +sources (comicbox first_wins=False) every enabled source is queried for +every comic, so the per-comic calls are summed instead. """ from __future__ import annotations @@ -29,16 +35,27 @@ # truth shared with the estimate math below. SOURCE_RATE_PER_MINUTE: Final = MappingProxyType({"metron": 20, "comicvine": 3}) -# API calls per comic by match mode. Matches MATCH_MODE_CALLS_PER_COMIC. -_MATCH_MODE_CALLS: Final = MappingProxyType({"eager": 2, "auto": 3, "careful": 5}) +# API calls per comic, per source. Matches the launcher-dialog's +# METRON_CALLS_PER_COMIC / COMICVINE_CALLS_BY_MODE. Metron's two-step search +# (series_list + issues_list) is a flat count that match mode does not change; +# Comic Vine's count scales with match mode. +_METRON_CALLS_PER_COMIC: Final = 2 +_COMICVINE_CALLS_BY_MODE: Final = MappingProxyType( + {"eager": 2, "auto": 3, "careful": 5} +) -# Fallbacks for unknown mode / no recognized source, mirroring the dialog. +# Fallbacks for an unknown mode / unrecognized source, mirroring the dialog. _DEFAULT_CALLS_PER_COMIC: Final = 3 _DEFAULT_RATE_PER_MINUTE: Final = 10 -def _calls_per_comic(mode: str) -> int: - return _MATCH_MODE_CALLS.get(mode, _DEFAULT_CALLS_PER_COMIC) +def _calls_per_comic(source: str, mode: str) -> int: + """Return the API calls one comic costs against a single source under ``mode``.""" + if source == "metron": + return _METRON_CALLS_PER_COMIC + if source == "comicvine": + return _COMICVINE_CALLS_BY_MODE.get(mode, _DEFAULT_CALLS_PER_COMIC) + return _DEFAULT_CALLS_PER_COMIC def _slowest_rate_per_minute(sources: tuple[str, ...]) -> int: @@ -56,8 +73,12 @@ def estimate_seconds( """Estimated seconds to look up ``remaining_comics`` more comics.""" if remaining_comics <= 0 or not sources: return 0.0 - # Merge mode queries every source per comic instead of the first match. - source_multiplier = len(sources) if merge_all_sources else 1 - total_calls = remaining_comics * _calls_per_comic(mode) * source_multiplier + per_source_calls = [_calls_per_comic(source, mode) for source in sources] + # Merge queries every source per comic (sum); first-match-wins stops at + # the first source that answers, so bill the costliest single source. + calls_per_comic = ( + sum(per_source_calls) if merge_all_sources else max(per_source_calls) + ) + total_calls = remaining_comics * calls_per_comic minutes = total_calls / _slowest_rate_per_minute(sources) return minutes * 60.0 diff --git a/codex/librarian/onlinetag/session_manager.py b/codex/librarian/onlinetag/session_manager.py index 773e5398f..a45df5a3d 100644 --- a/codex/librarian/onlinetag/session_manager.py +++ b/codex/librarian/onlinetag/session_manager.py @@ -606,7 +606,6 @@ def run_session(self, task: BulkOnlineTagTask) -> None: "sources": list(task.sources), "mode": task.mode, "prompts_mode": task.prompts_mode, - "effort": task.effort, "auto_threshold": task.auto_threshold, "delete_original": task.delete_original, "merge_all_sources": task.merge_all_sources, diff --git a/codex/librarian/onlinetag/tasks.py b/codex/librarian/onlinetag/tasks.py index 44adb2f59..f34d3fbb7 100644 --- a/codex/librarian/onlinetag/tasks.py +++ b/codex/librarian/onlinetag/tasks.py @@ -19,7 +19,6 @@ class BulkOnlineTagTask(OnlineTagTask): sources: tuple[str, ...] = ("metron", "comicvine") mode: str = "auto" prompts_mode: str = "ask" - effort: str = "balanced" auto_threshold: float = 0.85 delete_original: bool = False dry_run: bool = False diff --git a/codex/serializers/admin/tagging.py b/codex/serializers/admin/tagging.py index c01c3a344..61d1e2a18 100644 --- a/codex/serializers/admin/tagging.py +++ b/codex/serializers/admin/tagging.py @@ -58,7 +58,6 @@ class OnlineTagStartSerializer(Serializer): ) mode = CharField(required=False, default="auto") prompts_mode = CharField(required=False, default="ask") - effort = CharField(required=False, default="balanced") auto_threshold = CharField(required=False, default="0.85") dry_run = CharField(required=False, default="false") delete_original = BooleanField(required=False, default=None) diff --git a/codex/views/admin/onlinetag.py b/codex/views/admin/onlinetag.py index 7b1f3722c..df1a914bd 100644 --- a/codex/views/admin/onlinetag.py +++ b/codex/views/admin/onlinetag.py @@ -2,6 +2,7 @@ import contextlib import uuid +from dataclasses import fields from rest_framework.response import Response from rest_framework.status import HTTP_202_ACCEPTED, HTTP_409_CONFLICT @@ -147,6 +148,12 @@ def post(self, _request): params = dict(resume.get("params") or {}) # sources round-trips through JSON as a list; the task wants a tuple. params["sources"] = tuple(params.get("sources") or ()) + # A resume descriptor persists to a file-based cache, so one written by + # an older Codex may carry keys a task field no longer accepts (e.g. the + # removed ``effort`` knob). Drop unknown keys so resuming across an + # upgrade rebuilds the task instead of raising TypeError. + valid_fields = {f.name for f in fields(BulkOnlineTagTask)} + params = {k: v for k, v in params.items() if k in valid_fields} session_id = str(uuid.uuid4()) task = BulkOnlineTagTask( comic_pks=frozenset(remaining), diff --git a/frontend/src/components/online-tag/launcher-dialog.vue b/frontend/src/components/online-tag/launcher-dialog.vue index f472d847d..ef135d3cf 100644 --- a/frontend/src/components/online-tag/launcher-dialog.vue +++ b/frontend/src/components/online-tag/launcher-dialog.vue @@ -204,11 +204,25 @@ const SOURCE_RATES = { comicvine: { perMinute: 3, perHour: 200, label: "Comic Vine" }, }; -const MATCH_MODE_CALLS_PER_COMIC = { +// API calls per comic, per source. Keep in sync with +// codex/librarian/onlinetag/estimate.py. Metron (comicbox 4.0.5+) is a flat +// two-step search (series_list + issues_list) that match mode doesn't change; +// Comic Vine's count scales with match mode. +const METRON_CALLS_PER_COMIC = 2; +const COMICVINE_CALLS_BY_MODE = { eager: 2, auto: 3, careful: 5, }; +const DEFAULT_CALLS_PER_COMIC = 3; + +function callsForSource(source, mode) { + if (source === "metron") return METRON_CALLS_PER_COMIC; + if (source === "comicvine") { + return COMICVINE_CALLS_BY_MODE[mode] || DEFAULT_CALLS_PER_COMIC; + } + return DEFAULT_CALLS_PER_COMIC; +} // Only these sources support online id tagging (mirrors the backend // KNOWN_SOURCES); other identifiers a comic carries aren't offered. @@ -414,18 +428,19 @@ export default { // Nothing to merge with fewer than two sources selected. return this.activeSources.length >= 2; }, - callsPerComic() { - return MATCH_MODE_CALLS_PER_COMIC[this.matchMode] || 3; - }, totalCalls() { - // Merge mode queries every active source per comic instead of stopping - // at the first match, so calls scale with the number of sources. Keep - // this in sync with estimate_seconds in codex/librarian/onlinetag. - const sourceMultiplier = - this.mergeAllSources && this.activeSources.length - ? this.activeSources.length - : 1; - return this.comicCount * this.callsPerComic * sourceMultiplier; + // Per-comic API calls are counted per source. Merge mode queries every + // active source per comic (sum); first-match-wins stops at the first + // source that answers, so bill the costliest single source. Keep this in + // sync with estimate_seconds in codex/librarian/onlinetag/estimate.py. + const perSource = this.activeSources.map((s) => + callsForSource(s, this.matchMode), + ); + if (perSource.length === 0) return 0; + const callsPerComic = this.mergeAllSources + ? perSource.reduce((sum, n) => sum + n, 0) + : Math.max(...perSource); + return this.comicCount * callsPerComic; }, slowestRate() { let minRate = Infinity; diff --git a/frontend/tests/unit/launcher-dialog.test.js b/frontend/tests/unit/launcher-dialog.test.js index 4a4f36487..b00377e11 100644 --- a/frontend/tests/unit/launcher-dialog.test.js +++ b/frontend/tests/unit/launcher-dialog.test.js @@ -374,7 +374,7 @@ describe("OnlineTagLauncherDialog", () => { expect(wrapper.vm.canMerge).toBe(false); }); - test("doubles the estimated calls when merging two sources", async () => { + test("sums per-source calls when merging two sources", async () => { const { wrapper } = mountDialog({ book: { collection: "series", pk: 3, ids: [3], childCount: 10 }, }); @@ -382,12 +382,14 @@ describe("OnlineTagLauncherDialog", () => { wrapper.vm.activeTab = "search"; wrapper.vm.sources = ["metron", "comicvine"]; await wrapper.vm.$nextTick(); - const firstWinsCalls = wrapper.vm.totalCalls; + // First-match-wins bills the costliest source: max(metron 2, comicvine + // auto 3) = 3 calls/comic x 10 comics. + expect(wrapper.vm.totalCalls).toBe(30); wrapper.vm.mergeAllSources = true; await wrapper.vm.$nextTick(); - - expect(wrapper.vm.totalCalls).toBe(firstWinsCalls * 2); + // Merge sums per-source calls: (metron 2 + comicvine 3) x 10 comics. + expect(wrapper.vm.totalCalls).toBe(50); }); }); }); diff --git a/tests/test_onlinetag_estimate.py b/tests/test_onlinetag_estimate.py index 595ca394f..279b97667 100644 --- a/tests/test_onlinetag_estimate.py +++ b/tests/test_onlinetag_estimate.py @@ -1,22 +1,25 @@ """ Unit tests for the online tagging time estimate. -Mirrors the launcher dialog's algorithm: total calls (comics x calls-per-mode) -over the slowest enabled source's per-minute rate. +Mirrors the launcher dialog's algorithm: per-comic API calls (counted per +source — first-match-wins bills the costliest source, merge sums them) over +the slowest enabled source's per-minute rate. """ from __future__ import annotations from typing import Final +import pytest + from codex.librarian.onlinetag.estimate import estimate_seconds -_AUTO_METRON_SECONDS: Final = 90.0 +_AUTO_METRON_SECONDS: Final = 60.0 _SLOWEST_SOURCE_SECONDS: Final = 600.0 -_CAREFUL_SECONDS: Final = 150.0 -_EAGER_SECONDS: Final = 60.0 +_COMICVINE_CAREFUL_SECONDS: Final = 1000.0 +_COMICVINE_EAGER_SECONDS: Final = 400.0 _UNKNOWN_DEFAULTS_SECONDS: Final = 180.0 -_MERGE_TWO_SOURCES_SECONDS: Final = 1200.0 +_MERGE_TWO_SOURCES_SECONDS: Final = 1000.0 def test_zero_remaining_is_zero() -> None: @@ -30,14 +33,23 @@ def test_no_sources_is_zero() -> None: def test_auto_mode_metron() -> None: - """10 comics x 3 calls / 20 per-minute = 1.5 min = 90s.""" + """Metron's flat two-step search: 10 comics x 2 calls / 20 per-minute = 60s.""" assert estimate_seconds(10, "auto", ("metron",)) == _AUTO_METRON_SECONDS +def test_metron_calls_are_mode_independent() -> None: + """Match mode does not change Metron's fixed two-step request count.""" + auto = estimate_seconds(10, "auto", ("metron",)) + careful = estimate_seconds(10, "careful", ("metron",)) + eager = estimate_seconds(10, "eager", ("metron",)) + assert auto == careful == eager == _AUTO_METRON_SECONDS + + def test_slowest_source_binds() -> None: """ Comicvine (3/min) is slower than metron (20/min) and binds the rate. + First-match-wins bills the costliest source: max(metron 2, comicvine 3) = 3. 10 x 3 / 3 = 10 min = 600s. """ assert ( @@ -45,12 +57,12 @@ def test_slowest_source_binds() -> None: ) -def test_match_mode_scales_calls() -> None: - """Careful (5 calls/comic) and eager (2 calls/comic) scale total calls.""" - careful = estimate_seconds(10, "careful", ("metron",)) - eager = estimate_seconds(10, "eager", ("metron",)) - assert careful == _CAREFUL_SECONDS - assert eager == _EAGER_SECONDS +def test_comicvine_match_mode_scales_calls() -> None: + """Comic Vine's per-comic calls scale with match mode (careful 5, eager 2).""" + careful = estimate_seconds(10, "careful", ("comicvine",)) + eager = estimate_seconds(10, "eager", ("comicvine",)) + assert careful == pytest.approx(_COMICVINE_CAREFUL_SECONDS) + assert eager == pytest.approx(_COMICVINE_EAGER_SECONDS) def test_unknown_mode_and_source_use_defaults() -> None: @@ -62,17 +74,17 @@ def test_unknown_mode_and_source_use_defaults() -> None: assert estimate_seconds(10, "bogus", ("unknown",)) == _UNKNOWN_DEFAULTS_SECONDS -def test_merge_all_sources_multiplies_calls() -> None: +def test_merge_all_sources_sums_per_source_calls() -> None: """ - Merge mode queries every source per comic, so calls scale with source count. + Merge mode queries every source per comic, so per-comic calls are summed. - 10 x 3 x 2 sources / 3 per-minute (slowest) = 20 min = 1200s, double the - first-match-wins estimate. + metron 2 + comicvine 3 = 5 calls/comic over 3 per-minute (slowest). + 10 x 5 / 3 = 1000s — more than the 600s first-match-wins estimate but not + double it, since the two sources cost different amounts. """ - assert ( - estimate_seconds(10, "auto", ("metron", "comicvine"), merge_all_sources=True) - == _MERGE_TWO_SOURCES_SECONDS - ) + assert estimate_seconds( + 10, "auto", ("metron", "comicvine"), merge_all_sources=True + ) == pytest.approx(_MERGE_TWO_SOURCES_SECONDS) def test_merge_all_sources_single_source_is_noop() -> None: diff --git a/tests/test_onlinetag_resume_endpoints.py b/tests/test_onlinetag_resume_endpoints.py index 174e2ce66..265c5acd4 100644 --- a/tests/test_onlinetag_resume_endpoints.py +++ b/tests/test_onlinetag_resume_endpoints.py @@ -26,7 +26,6 @@ "sources": ["metron", "comicvine"], "mode": "auto", "prompts_mode": "ask", - "effort": "balanced", "auto_threshold": 0.85, "delete_original": False, "merge_all_sources": False, From 8245efa8c40560550653561c84ac4fb90df402bf Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Fri, 3 Jul 2026 19:40:04 -0700 Subject: [PATCH 06/66] trim news --- NEWS.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index 637e08ea5..4677afc65 100644 --- a/NEWS.md +++ b/NEWS.md @@ -10,8 +10,6 @@ border-radius: 128px; - Fixes - Fix unable to change password. - - Online tagging time and request estimates now reflect Metron's fewer - requests. - Performance - Metron online tagging makes far fewer API requests (comicbox 4.0.5). From a1845afdcb2c0cf3658c2fa0b0789651f2e2430b Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Fri, 3 Jul 2026 20:25:05 -0700 Subject: [PATCH 07/66] refactor(onlinetag): derive source list + issue-id parser from comicbox Two facilities codex hand-synced from comicbox now consume it directly: - Source names: KNOWN_SOURCES and the task/serializer/frontend default lists derive from comicbox's canonical SOURCE_NAMES tuple instead of repeating {"metron","comicvine"} literals in four places. The frontend gets it through the tagging choices JSON (build-choices), so a new comicbox source propagates without hand-editing every site. - Issue-id parsing: the two byte-identical trailing-int regex copies (stored_id_prepass, explicit_id) collapse into one issue_id.parse_issue_id built on comicbox's canonical PARSE_COMICVINE_RE. It honors the real Comic Vine 4-digit long-key rule instead of grabbing any trailing int; an unrecognized key returns None, which safely falls back to search / rejects the id rather than guessing wrong. No user-visible behavior change. Co-Authored-By: Claude Opus 4.8 --- codex/choices/admin.py | 4 +++ .../onlinetag/credential_validator.py | 6 ++++- codex/librarian/onlinetag/explicit_id.py | 14 +++------- codex/librarian/onlinetag/issue_id.py | 24 +++++++++++++++++ .../librarian/onlinetag/stored_id_prepass.py | 14 ++-------- codex/librarian/onlinetag/tasks.py | 4 ++- codex/serializers/admin/tagging.py | 5 ++-- .../components/online-tag/launcher-dialog.vue | 9 ++++--- tests/test_onlinetag_issue_id.py | 27 +++++++++++++++++++ 9 files changed, 76 insertions(+), 31 deletions(-) create mode 100644 codex/librarian/onlinetag/issue_id.py create mode 100644 tests/test_onlinetag_issue_id.py diff --git a/codex/choices/admin.py b/codex/choices/admin.py index fcfa35653..3d1ddc3af 100644 --- a/codex/choices/admin.py +++ b/codex/choices/admin.py @@ -2,6 +2,7 @@ from types import MappingProxyType +from comicbox.formats.base.online import SOURCE_NAMES from django.db.models.enums import TextChoices @@ -63,6 +64,9 @@ class AdminFlagChoices(TextChoices): "never": "Never", } ), + # Online-tag source names in priority order, derived from comicbox so + # the frontend's source list tracks comicbox instead of hardcoding it. + "sources": SOURCE_NAMES, } ) diff --git a/codex/librarian/onlinetag/credential_validator.py b/codex/librarian/onlinetag/credential_validator.py index 2be2626e6..eec933dc1 100644 --- a/codex/librarian/onlinetag/credential_validator.py +++ b/codex/librarian/onlinetag/credential_validator.py @@ -12,12 +12,16 @@ from dataclasses import dataclass from typing import TYPE_CHECKING +from comicbox.formats.base.online import SOURCE_NAMES + if TYPE_CHECKING: from collections.abc import Collection from comicbox.online_session import OnlineCredentials -KNOWN_SOURCES: frozenset[str] = frozenset({"metron", "comicvine"}) +# comicbox owns the canonical online-tag source names (metron, comicvine); derive +# from it so codex tracks a new source instead of hand-syncing a literal. +KNOWN_SOURCES: frozenset[str] = frozenset(SOURCE_NAMES) _COMICVINE_TIMEOUT_SECS: float = 10.0 diff --git a/codex/librarian/onlinetag/explicit_id.py b/codex/librarian/onlinetag/explicit_id.py index ca6ce6af6..9fd00676b 100644 --- a/codex/librarian/onlinetag/explicit_id.py +++ b/codex/librarian/onlinetag/explicit_id.py @@ -12,7 +12,6 @@ from __future__ import annotations -import re from dataclasses import replace from typing import TYPE_CHECKING, Any @@ -28,16 +27,14 @@ from comicbox.formats.comicbox.schema import IDENTIFIERS_KEY from comicbox.identifiers import ID_KEY_KEY +from codex.librarian.onlinetag.issue_id import parse_issue_id + if TYPE_CHECKING: from pathlib import Path from comicbox.config.settings import ComicboxSettings from comicbox.online_session import OnlineCredentials -# Comic Vine keys may be stored long-form (``4000-67890``); the issue id is the -# trailing integer. Metron keys are already bare integers. -_TRAILING_INT_RE = re.compile(r"(\d+)$") - def _build_auth_source( source: str, credentials: OnlineCredentials @@ -105,11 +102,8 @@ def _result_has_requested_id(tags: dict[str, Any], source: str, issue_id: int) - a wrong id even on an already-tagged comic, whose stale id won't match. """ id_obj = (tags.get(IDENTIFIERS_KEY) or {}).get(source) or {} - key = id_obj.get(ID_KEY_KEY) - if not key: - return False - match = _TRAILING_INT_RE.search(str(key)) - return bool(match) and int(match.group(1)) == issue_id + parsed = parse_issue_id(id_obj.get(ID_KEY_KEY)) + return parsed is not None and parsed == issue_id def fetch_tags_by_explicit_id( diff --git a/codex/librarian/onlinetag/issue_id.py b/codex/librarian/onlinetag/issue_id.py new file mode 100644 index 000000000..f277e201c --- /dev/null +++ b/codex/librarian/onlinetag/issue_id.py @@ -0,0 +1,24 @@ +""" +Extract the numeric issue id from a stored online identifier key. + +Comic Vine issue keys are stored long-form (``4000-12345``); comicbox owns the +canonical rule for that ``-`` shape (``PARSE_COMICVINE_RE``), so we +consume it here instead of hand-rolling the format. Metron keys are already +bare integers. Anything else yields ``None`` — the callers (stored-id prepass, +explicit-id confirmation) treat that as "not resolvable", which safely falls +back to search or rejects the id rather than guessing a wrong one. +""" + +from __future__ import annotations + +from comicbox.identifiers import PARSE_COMICVINE_RE + + +def parse_issue_id(key: str | None) -> int | None: + """Return the numeric issue id from a stored identifier key, or ``None``.""" + if not key: + return None + key = str(key) + if match := PARSE_COMICVINE_RE.search(key): + return int(match.group("id_key")) + return int(key) if key.isdigit() else None diff --git a/codex/librarian/onlinetag/stored_id_prepass.py b/codex/librarian/onlinetag/stored_id_prepass.py index ca03e830e..bd41f1cba 100644 --- a/codex/librarian/onlinetag/stored_id_prepass.py +++ b/codex/librarian/onlinetag/stored_id_prepass.py @@ -13,24 +13,14 @@ from __future__ import annotations -import re from typing import TYPE_CHECKING +from codex.librarian.onlinetag.issue_id import parse_issue_id from codex.models.identifier import Identifier, IdentifierType if TYPE_CHECKING: from collections.abc import Collection -# Comic Vine keys may be stored long-form (``4000-12345``); the issue id is the -# trailing integer. Metron keys are already bare integers. -_TRAILING_INT_RE = re.compile(r"(\d+)$") - - -def _parse_issue_id(key: str | None) -> int | None: - """Pull the trailing integer issue id out of a stored identifier key.""" - match = _TRAILING_INT_RE.search(key or "") - return int(match.group(1)) if match else None - def build_stored_id_map( comic_pks: Collection[int], sources: tuple[str, ...] @@ -53,7 +43,7 @@ def build_stored_id_map( priority = {source: i for i, source in enumerate(sources)} id_map: dict[int, dict[str, int]] = {} for pk, source_name, key in rows: - issue_id = _parse_issue_id(key) + issue_id = parse_issue_id(key) if issue_id is None: continue id_map.setdefault(pk, {})[source_name] = issue_id diff --git a/codex/librarian/onlinetag/tasks.py b/codex/librarian/onlinetag/tasks.py index f34d3fbb7..4e916fcb8 100644 --- a/codex/librarian/onlinetag/tasks.py +++ b/codex/librarian/onlinetag/tasks.py @@ -3,6 +3,8 @@ from dataclasses import dataclass from typing import Any +from comicbox.formats.base.online import SOURCE_NAMES + from codex.librarian.tasks import LibrarianTask @@ -16,7 +18,7 @@ class BulkOnlineTagTask(OnlineTagTask): comic_pks: frozenset[int] session_id: str - sources: tuple[str, ...] = ("metron", "comicvine") + sources: tuple[str, ...] = SOURCE_NAMES mode: str = "auto" prompts_mode: str = "ask" auto_threshold: float = 0.85 diff --git a/codex/serializers/admin/tagging.py b/codex/serializers/admin/tagging.py index 61d1e2a18..dcb995369 100644 --- a/codex/serializers/admin/tagging.py +++ b/codex/serializers/admin/tagging.py @@ -1,5 +1,6 @@ """Comicbox tagging serializers.""" +from comicbox.formats.base.online import SOURCE_NAMES from rest_framework.fields import SerializerMethodField from rest_framework.serializers import ( BooleanField, @@ -53,9 +54,7 @@ class OnlineTagStartSerializer(Serializer): collection = CharField() pks = ListField(child=CharField()) - sources = ListField( - child=CharField(), required=False, default=["metron", "comicvine"] - ) + sources = ListField(child=CharField(), required=False, default=list(SOURCE_NAMES)) mode = CharField(required=False, default="auto") prompts_mode = CharField(required=False, default="ask") auto_threshold = CharField(required=False, default="0.85") diff --git a/frontend/src/components/online-tag/launcher-dialog.vue b/frontend/src/components/online-tag/launcher-dialog.vue index ef135d3cf..f4fd08dc0 100644 --- a/frontend/src/components/online-tag/launcher-dialog.vue +++ b/frontend/src/components/online-tag/launcher-dialog.vue @@ -224,9 +224,10 @@ function callsForSource(source, mode) { return DEFAULT_CALLS_PER_COMIC; } -// Only these sources support online id tagging (mirrors the backend -// KNOWN_SOURCES); other identifiers a comic carries aren't offered. -const TAGGABLE_SOURCES = Object.freeze(new Set(["metron", "comicvine"])); +// Only these sources support online id tagging; derived from comicbox's +// SOURCE_NAMES via the tagging choices so it tracks the backend. Other +// identifiers a comic carries aren't offered. +const TAGGABLE_SOURCES = Object.freeze(new Set(TAGGING_CHOICES.sources)); // A comic's own issue identifier is stored with id_type "comic" — codex's // IdentifierType.ISSUE value is the table name, not "issue". Accept that (and // a bare "issue" defensively); skip non-issue types like publisher / series. @@ -269,7 +270,7 @@ export default { // Search matchModeChoices: TAGGING_CHOICES.matchMode, promptsModeChoices: TAGGING_CHOICES.promptsMode, - sources: ["metron", "comicvine"], + sources: [...TAGGING_CHOICES.sources], matchMode: "auto", promptsMode: "ask", mergeAllSources: false, diff --git a/tests/test_onlinetag_issue_id.py b/tests/test_onlinetag_issue_id.py new file mode 100644 index 000000000..ede91318e --- /dev/null +++ b/tests/test_onlinetag_issue_id.py @@ -0,0 +1,27 @@ +"""Unit tests for parsing the numeric issue id out of a stored identifier key.""" + +from __future__ import annotations + +import pytest + +from codex.librarian.onlinetag.issue_id import parse_issue_id + + +@pytest.mark.parametrize( + ("key", "expected"), + [ + ("12345", 12345), # bare Metron key + ("4000-67890", 67890), # Comic Vine long-form -> id_key + (67890, 67890), # non-str key coerces + ("https://comicvine.gamespot.com/x/4000-67890/", 67890), # CV rule anywhere + ("", None), + (None, None), + ("abc", None), # no digits at all + ("metron", None), + ("4000-", None), # long-form type without a key + ("12-99", None), # not the 4-digit CV rule -> not a lossy trailing int + ], +) +def test_parse_issue_id(key, expected) -> None: + """The CV long-key rule wins; bare digits pass; everything else is None.""" + assert parse_issue_id(key) == expected From cd84ed99e1260ff65a7236800304986700bf5721 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Fri, 3 Jul 2026 20:48:23 -0700 Subject: [PATCH 08/66] fix(onlinetag): scope the match-mode request-count hint to Comic Vine The "~N requests/comic" tail in the match-mode hints only describes Comic Vine, whose calls scale with match mode; Metron is a flat two-step search regardless of mode. Drop the tail from the base hints and append a "~N Comic Vine requests/comic" suffix only when Comic Vine is an active source, so a Metron-only run no longer shows a count that doesn't apply. The number derives from the existing COMICVINE_CALLS_BY_MODE constant. Co-Authored-By: Claude Opus 4.8 --- .../components/online-tag/launcher-dialog.vue | 20 ++++++++++++--- frontend/tests/unit/launcher-dialog.test.js | 25 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/online-tag/launcher-dialog.vue b/frontend/src/components/online-tag/launcher-dialog.vue index f4fd08dc0..bba5f5d43 100644 --- a/frontend/src/components/online-tag/launcher-dialog.vue +++ b/frontend/src/components/online-tag/launcher-dialog.vue @@ -186,12 +186,15 @@ import { useAdminStore } from "@/stores/admin"; import { useCommonStore } from "@/stores/common"; import { useOnlineTagStore } from "@/stores/online-tag"; +// Base match-mode semantics, shown for any source. The per-comic request +// count is Comic Vine-specific (Metron is a flat two-step regardless of mode), +// so it's appended by matchModeHint only when Comic Vine is an active source. const MATCH_MODE_HINTS = { careful: - "Only accepts high-confidence matches. Defers ambiguous results for manual review. ~5 requests/comic.", - auto: "Balances accuracy and speed. Accepts good matches automatically, defers uncertain ones. ~3 requests/comic.", + "Only accepts high-confidence, unambiguous matches. Defers anything uncertain for manual review.", + auto: "Balances accuracy and speed. Accepts confident matches automatically and defers uncertain ones.", eager: - "Accepts the best available match with minimal verification. Fastest but least precise. ~2 requests/comic.", + "Accepts the best available match with minimal verification. Fastest, but least precise.", }; const PROMPTS_MODE_HINTS = { ask: "Pauses on ambiguous matches and asks you to choose the correct result.", @@ -411,7 +414,16 @@ export default { }, // --- search hints / estimates --------------------------------------- matchModeHint() { - return MATCH_MODE_HINTS[this.matchMode] || ""; + const base = MATCH_MODE_HINTS[this.matchMode] || ""; + // The request-count tail only applies to Comic Vine, whose calls scale + // with match mode; Metron's flat two-step doesn't, so skip it otherwise. + if (!this.activeSources.includes("comicvine")) { + return base; + } + const requests = COMICVINE_CALLS_BY_MODE[this.matchMode]; + return requests + ? `${base} ~${requests} Comic Vine requests/comic.` + : base; }, promptsModeHint() { return PROMPTS_MODE_HINTS[this.promptsMode] || ""; diff --git a/frontend/tests/unit/launcher-dialog.test.js b/frontend/tests/unit/launcher-dialog.test.js index b00377e11..08c3e1a4e 100644 --- a/frontend/tests/unit/launcher-dialog.test.js +++ b/frontend/tests/unit/launcher-dialog.test.js @@ -392,6 +392,31 @@ describe("OnlineTagLauncherDialog", () => { expect(wrapper.vm.totalCalls).toBe(50); }); }); + + describe("match mode hint", () => { + test("appends the Comic Vine request count when Comic Vine is active", async () => { + const { wrapper } = mountDialog(); + + wrapper.vm.sources = ["metron", "comicvine"]; + wrapper.vm.matchMode = "auto"; + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.matchModeHint).toContain( + "~3 Comic Vine requests/comic.", + ); + }); + + test("omits the request-count tail on a Metron-only run", async () => { + const { wrapper } = mountDialog(); + + wrapper.vm.sources = ["metron"]; + wrapper.vm.matchMode = "careful"; + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.matchModeHint).toContain("high-confidence"); + expect(wrapper.vm.matchModeHint).not.toContain("requests/comic"); + }); + }); }); export default {}; From 9980c04493585ef4be022a9855a0f4f13a8cad83 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sat, 4 Jul 2026 15:45:25 -0700 Subject: [PATCH 09/66] fix(fs): don't let one unreadable folder crash the library scan The poller's DiskSnapshot._walk() called os.scandir() with no guard around the directory open, so a single permission-denied folder (e.g. a Synology /comics/#recycle bin) raised PermissionError that propagated up and killed the LibraryPollerThread, aborting the scan of every other folder (issue #795). - Wrap os.scandir so an unreadable/vanished directory is logged and skipped instead of aborting the whole poll, and widen the per-entry guard to cover entry.is_dir(), which can also raise PermissionError. This matches the os.walk default-onerror behavior the watcher relies on. - Register the OS/NAS metadata basenames the filters module already documented but never populated (@eaDir, #recycle, __MACOSX, Thumbs.db, desktop.ini), so the walker skips the recycle bin entirely and NAS/OS junk never enters the library. Co-Authored-By: Claude Opus 4.8 --- codex/librarian/fs/filters.py | 23 ++++++++--- codex/librarian/fs/poller/snapshot.py | 53 +++++++++++++++--------- tests/test_fs_filters_ignore.py | 59 +++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 25 deletions(-) diff --git a/codex/librarian/fs/filters.py b/codex/librarian/fs/filters.py index f68bfb99a..4229cb984 100644 --- a/codex/librarian/fs/filters.py +++ b/codex/librarian/fs/filters.py @@ -11,11 +11,14 @@ # the watchfiles filter. Either constant can be extended to add new # patterns without touching the walker/filter call sites: # -# _IGNORED_BASENAMES — exact-match basenames (case-sensitive). Use -# this for OS / archive metadata trees that don't follow a prefix -# convention. Examples for future extension: ``"@eaDir"`` -# (Synology), ``"__MACOSX"`` (Mac archive metadata), ``"Thumbs.db"`` -# / ``"desktop.ini"`` (Windows), ``"#recycle"`` (recycle bins). +# _IGNORED_BASENAMES — exact-match basenames (case-sensitive) for +# OS / archive metadata trees that don't follow a prefix +# convention: ``@eaDir`` (Synology thumbnails), ``#recycle`` +# (Synology recycle bin), ``__MACOSX`` (Mac archive metadata), +# ``Thumbs.db`` / ``desktop.ini`` (Windows). Add more here as +# needed. Skipping these keeps NAS/OS junk out of the library and +# stops the walker from descending into permission-restricted +# trees like the recycle bin (issue #795). # # _IGNORED_BASENAME_PREFIXES — prefix matches. Currently catches all # hidden files / directories ("." prefix) so VCS metadata (.git), @@ -25,7 +28,15 @@ # A path is ignored when *any* component (relative to its library # root) matches either rule. The check is applied to every entry the # walker sees and every event the watcher receives. -_IGNORED_BASENAMES: frozenset[str] = frozenset() +_IGNORED_BASENAMES: frozenset[str] = frozenset( + { + "@eaDir", # Synology thumbnail / extended-attribute store + "#recycle", # Synology recycle bin + "__MACOSX", # macOS archive metadata + "Thumbs.db", # Windows thumbnail cache + "desktop.ini", # Windows folder settings + } +) _IGNORED_BASENAME_PREFIXES: tuple[str, ...] = (".",) diff --git a/codex/librarian/fs/poller/snapshot.py b/codex/librarian/fs/poller/snapshot.py index cecd88c93..0070ec0ed 100644 --- a/codex/librarian/fs/poller/snapshot.py +++ b/codex/librarian/fs/poller/snapshot.py @@ -138,25 +138,40 @@ def _should_include(self, path: Path, *, is_dir: bool) -> bool: def _walk(self, root: str) -> None: """Walk the directory tree and populate lookups.""" - for entry in os.scandir(root): - # Walking only the surviving children means the recursion - # never enters an ignored dir (``.git`` / ``.Trashes`` / - # ``@eaDir`` if registered / …), so a per-component check - # at each scandir level suffices — no need to re-check - # ancestors. See ``filters._IGNORED_BASENAMES`` / - # ``_IGNORED_BASENAME_PREFIXES`` for the registered rules. - if is_ignored_basename(entry.name): - continue - is_dir = entry.is_dir(follow_symlinks=self._follow_symlinks) - if not self._should_include(Path(entry.path), is_dir=is_dir): - continue - try: - st = entry.stat(follow_symlinks=self._follow_symlinks) - except OSError: - continue - self._set_lookups(entry.path, st) - if is_dir: - self._walk(entry.path) + try: + scandir = os.scandir(root) + except OSError as exc: + # A single unreadable directory (permission denied, or one + # that vanished mid-scan) must not abort the whole library + # poll — otherwise one locked folder crashes the poller + # thread and no other folder gets scanned (issue #795). + # Skip it and keep walking the rest of the tree, matching + # the ``os.walk`` default-onerror behavior the watcher relies + # on. + self.log.warning(f"Skipping unreadable directory {root}: {exc}") + return + with scandir: + for entry in scandir: + # Walking only the surviving children means the recursion + # never enters an ignored dir (``.git`` / ``.Trashes`` / + # ``@eaDir`` / ``#recycle`` / …), so a per-component check + # at each scandir level suffices — no need to re-check + # ancestors. See ``filters._IGNORED_BASENAMES`` / + # ``_IGNORED_BASENAME_PREFIXES`` for the registered rules. + if is_ignored_basename(entry.name): + continue + try: + is_dir = entry.is_dir(follow_symlinks=self._follow_symlinks) + if not self._should_include(Path(entry.path), is_dir=is_dir): + continue + st = entry.stat(follow_symlinks=self._follow_symlinks) + except OSError: + # An entry that can't be stat'd (broken symlink, + # permission denied, race) is skipped, not fatal. + continue + self._set_lookups(entry.path, st) + if is_dir: + self._walk(entry.path) class DatabaseSnapshot(Snapshot): diff --git a/tests/test_fs_filters_ignore.py b/tests/test_fs_filters_ignore.py index 4811fab0e..ddd5e7597 100644 --- a/tests/test_fs_filters_ignore.py +++ b/tests/test_fs_filters_ignore.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import shutil from logging import getLogger from pathlib import Path @@ -13,6 +14,7 @@ from codex.librarian.fs import filters as filters_mod from codex.librarian.fs.filters import is_ignored_basename, is_ignored_path +from codex.librarian.fs.poller import snapshot as snapshot_mod from codex.librarian.fs.poller.snapshot import DiskSnapshot from codex.librarian.fs.watcher.data import ChangeBatch from codex.librarian.fs.watcher.dirs import expand_dir_added @@ -83,6 +85,12 @@ def test_prefix_registry_picked_up(self) -> None: assert is_ignored_basename("#recycle") is True assert is_ignored_basename(".DS_Store") is False + def test_os_metadata_basenames_registered_by_default(self) -> None: + # NAS / OS junk trees are ignored out of the box so they are + # neither imported nor descended into (issue #795). + for name in ("@eaDir", "#recycle", "__MACOSX", "Thumbs.db", "desktop.ini"): + assert is_ignored_basename(name) is True + class TestDiskSnapshotSkipsDotfiles: """The poller's walker must not surface dotfile paths.""" @@ -122,6 +130,57 @@ def test_folder_cover_dotfile_skipped(self) -> None: names = {Path(p).name for p in snap.paths} assert ".codex-cover.jpg" not in names + def test_recycle_bin_directory_pruned(self) -> None: + # Synology recycle bins are registered noise: the walker must + # never descend into them, so their permission-restricted + # contents are never scanned (issue #795). + (_TEST_LIB_ROOT / "comic.cbz").touch() + recycle = _TEST_LIB_ROOT / "#recycle" + recycle.mkdir() + (recycle / "deleted.cbz").touch() + snap = DiskSnapshot(str(_TEST_LIB_ROOT), getLogger("test")) + assert not any("#recycle" in Path(p).parts for p in snap.paths) + + +class TestDiskSnapshotSurvivesUnreadableDir: + """A single permission-denied directory must not abort the whole walk.""" + + @pytest.fixture(autouse=True) + def _tmp_library(self): + shutil.rmtree(_TEST_LIB_ROOT, ignore_errors=True) + _TEST_LIB_ROOT.mkdir(parents=True) + yield + shutil.rmtree(_TEST_LIB_ROOT, ignore_errors=True) + + def test_unreadable_subdir_skipped_siblings_survive(self) -> None: + # Reproduces issue #795: a subdirectory that raises + # PermissionError on scandir (e.g. a NAS recycle bin) used to + # crash the poller thread and abort the entire scan. It must now + # be skipped while every other folder still gets walked. Mock + # scandir so the test holds regardless of the runner's uid + # (CI runs as root, where a 0o000 dir stays readable). + (_TEST_LIB_ROOT / "comic.cbz").touch() + locked = _TEST_LIB_ROOT / "locked" + locked.mkdir() + (locked / "hidden.cbz").touch() + + real_scandir = os.scandir + + def fake_scandir(path): + if Path(path) == locked: + raise PermissionError(13, "Permission denied") + return real_scandir(path) + + with patch.object(snapshot_mod.os, "scandir", side_effect=fake_scandir): + snap = DiskSnapshot(str(_TEST_LIB_ROOT), getLogger("test")) + + names = {Path(p).name for p in snap.paths} + # The sibling comic is still surfaced despite the locked dir. + assert "comic.cbz" in names + # The locked directory stat'd fine so its own entry is recorded, + # but its unreadable contents are not. + assert "hidden.cbz" not in names + class TestCodexWatchFilter: """The watchfiles filter rejects hidden-tree events before classification.""" From 42b7fe939856caae57afd4a85e1d945bf7be07c0 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sat, 4 Jul 2026 15:46:56 -0700 Subject: [PATCH 10/66] update deps --- uv.lock | 174 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 87 insertions(+), 87 deletions(-) diff --git a/uv.lock b/uv.lock index 7361ed8ca..5e823d9ce 100644 --- a/uv.lock +++ b/uv.lock @@ -1188,7 +1188,7 @@ wheels = [ [[package]] name = "djlint" -version = "1.40.2" +version = "1.40.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1199,45 +1199,45 @@ dependencies = [ { name = "pyyaml" }, { name = "regex" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/ff/63a4ce9608c7051ab20954a150f6c0f2bfe7332b14cb4f11ee77036b3c6d/djlint-1.40.2.tar.gz", hash = "sha256:b39ff27a9806e4c4ce319b76a058c94811ef73dbba4a21dbec4d8b5009b48d5f", size = 58974, upload-time = "2026-07-03T00:50:27.469Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/60/8d7c37b6dce0d04eebf07c8c6b7de2941a8db06babbba69a04feea115d6a/djlint-1.40.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:775df87f239927afead55eba79e041a75cdffb5000eb09db5f959122b79062f5", size = 572293, upload-time = "2026-07-03T00:49:35.313Z" }, - { url = "https://files.pythonhosted.org/packages/c1/26/58fb1338c832e183c797d553949557810ba5e65aabd232c1445d2bfc3bb1/djlint-1.40.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f0d6c97c99b1e538ad36d604b2fdbad31142ce3a116389f3b9c3a56db0ae8ce6", size = 539842, upload-time = "2026-07-03T00:49:36.666Z" }, - { url = "https://files.pythonhosted.org/packages/b2/aa/4524a5409344d8221aad7f0bdb1002555c4c8f0577aead459eeb335b9b43/djlint-1.40.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25f7dfbd4a8073a171a5ffcc01269a9e5b8f7a7225f37fa43168c5172951a5dd", size = 571205, upload-time = "2026-07-03T00:49:38.022Z" }, - { url = "https://files.pythonhosted.org/packages/da/a2/58772dfb57f5e49d10aea99dcc4d6180497d9f3cc9759aa96a54fea244ea/djlint-1.40.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20a50da7799653163ddafcc3af43c2179e0a8657d402b772e10e3b628b24deca", size = 598632, upload-time = "2026-07-03T00:49:39.29Z" }, - { url = "https://files.pythonhosted.org/packages/48/a2/e1e4f31340580bc2a5308f9e75c55277e7273919e4c7b3e4e3b443f03fb7/djlint-1.40.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afc96e355b8d8b60215fab201465201c2595040b2f36095954416b0652119d65", size = 579727, upload-time = "2026-07-03T00:49:40.938Z" }, - { url = "https://files.pythonhosted.org/packages/0c/7a/ea6729fcb39b053d1f5df0a7b1f7f268c2e792a5a36bf31c029f350ad1a5/djlint-1.40.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2cb90f921636a5cfab27f672cca38cee6823bad66f657eeb5c79d542f42c29d", size = 608067, upload-time = "2026-07-03T00:49:42.344Z" }, - { url = "https://files.pythonhosted.org/packages/91/af/9dcd98a260e5929b0690352300f2d37c56a0acf53ce25a7f881b59a022b4/djlint-1.40.2-cp312-cp312-win32.whl", hash = "sha256:9bd4cb9e47e7be21aee243f8be6013dea7fd5c8ef8556876ff6eb8ee94cc216d", size = 434780, upload-time = "2026-07-03T00:49:43.565Z" }, - { url = "https://files.pythonhosted.org/packages/b8/26/fa11eb09ea47005d33da21370b509f3fb065d65499478b4b92e6661436ed/djlint-1.40.2-cp312-cp312-win_amd64.whl", hash = "sha256:b139459a6843d07123182b84d27c1dd4da0e9b9ba52dab00c3a6d3a0b6cf0541", size = 484621, upload-time = "2026-07-03T00:49:44.857Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d6/0d42dbba8e13bdc1defdb3d213596c69b4da8751426399f5d8ba36a31b9c/djlint-1.40.2-cp312-cp312-win_arm64.whl", hash = "sha256:914ebed15d84276021bb720e7e9d66eb8c6587e9724af23a0875dae101e141a5", size = 428267, upload-time = "2026-07-03T00:49:46.122Z" }, - { url = "https://files.pythonhosted.org/packages/86/24/3cbb5bf5681af9d68d5dcc23c0cf2421500a086f7da389287387441ce410/djlint-1.40.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0496e44b61c7581c6e02df9c3b621d04172405dc7186ced9376e0a6a356a5861", size = 569009, upload-time = "2026-07-03T00:49:47.615Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b7/af7eb51a428081116e2aa464b1df190bb7955049e7f39bd1bb358d9c1f14/djlint-1.40.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a4a98dcfc7af894603f64bba29ef9572e3e447d0c0466198eb4a7be5fefd3e4", size = 538693, upload-time = "2026-07-03T00:49:49.042Z" }, - { url = "https://files.pythonhosted.org/packages/d9/60/f1c7c3b8223cc01fdd3bf9bb06afa07a7935e4fbf97d72b69d4e5c7b8300/djlint-1.40.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffa475bab3e8be757d8b75d3e0ae6327d44937209951925c64042f7d89cb17", size = 569448, upload-time = "2026-07-03T00:49:50.407Z" }, - { url = "https://files.pythonhosted.org/packages/d7/23/90554b25ef09c6048659fa7fc44c1267a965962854dc7aa7632913303ebf/djlint-1.40.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49886bdf08e1d896f91eb008b1f7c754031242feb39036cc09a13a7daea41e7b", size = 597012, upload-time = "2026-07-03T00:49:51.675Z" }, - { url = "https://files.pythonhosted.org/packages/2d/dc/317374b75b8085d33fdb103c8490c575050798d6991e7876cc4446e3852a/djlint-1.40.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0476479e5ad676a89d9a8ead2c74d26f608750bbf146dd4530dc571a601940c9", size = 577408, upload-time = "2026-07-03T00:49:53.101Z" }, - { url = "https://files.pythonhosted.org/packages/9e/85/ea93e7f48d3e8a36410c596796c6f132baed90e241edcb235693caba00a9/djlint-1.40.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:109f89b1ceb3e1c37bf9e02cb2d73c4a65a020a3fafc4fda91e8128ea87c0388", size = 606770, upload-time = "2026-07-03T00:49:54.457Z" }, - { url = "https://files.pythonhosted.org/packages/5f/b8/dc7c3c1fb6903652187fcaa562998560d821097a715978bf8c54ec8dea08/djlint-1.40.2-cp313-cp313-win32.whl", hash = "sha256:9c548033768d51701b3f561c3dffdf5f09a718f9332d58a7039a437778e0cfc3", size = 434326, upload-time = "2026-07-03T00:49:55.675Z" }, - { url = "https://files.pythonhosted.org/packages/97/9a/4cb35d83c165661d494603b1a16c8eaad28f2dc2825ca218f93c1a5a1ff3/djlint-1.40.2-cp313-cp313-win_amd64.whl", hash = "sha256:e9d97bfcc3e35c991ed7b76cbf0d2a50b68dd68ce96fb1e4a1845fb1d1182642", size = 484132, upload-time = "2026-07-03T00:49:57.096Z" }, - { url = "https://files.pythonhosted.org/packages/09/dd/22a2699bf7e0907a78c258138a74d9f3c0709448c8397f3ca29d6f4d5a66/djlint-1.40.2-cp313-cp313-win_arm64.whl", hash = "sha256:e46012fa48495717db33e1e9f9d4472e2413c3f165016691b42b20377b1f4cf6", size = 427278, upload-time = "2026-07-03T00:49:58.354Z" }, - { url = "https://files.pythonhosted.org/packages/61/55/1769a60325e5a4a93305a69e424e747cb2088fe99fa9a0961010fbff1a2e/djlint-1.40.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a2732aa3d8513d7b9c8fbaad70a3eb572267594e94c555aa099ca5c194e42782", size = 567625, upload-time = "2026-07-03T00:49:59.773Z" }, - { url = "https://files.pythonhosted.org/packages/47/38/8512f315ad0b74453bfec16b85964f8ef586f2a07a7b7b85624b89e51e05/djlint-1.40.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e074858fa9fe8ff005b567a2325717b9bcd0e7f067d86ee513d92459c1d22209", size = 538175, upload-time = "2026-07-03T00:50:01.188Z" }, - { url = "https://files.pythonhosted.org/packages/fe/28/a0625a3f251b4299a40477ac1cc5fa70ddcd2988e4f8622011e583ab70c4/djlint-1.40.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af9c71981467ddd2eadd415889638b0f73c3a320747e899268045ab36f9610c0", size = 572323, upload-time = "2026-07-03T00:50:02.466Z" }, - { url = "https://files.pythonhosted.org/packages/d6/0a/d2968b2c62248e1052da7b6af037e6b89cea68a016e65af82c9ca6377db8/djlint-1.40.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0a6156f2de3705c43132100995af254296983831ec4fbb7c6ae25983163e23f", size = 595952, upload-time = "2026-07-03T00:50:03.874Z" }, - { url = "https://files.pythonhosted.org/packages/57/5f/1b2153a08b058bdf2a633543ca1592ec9d84ccac3e725e7917175d686b91/djlint-1.40.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:71435a4a6adffeef0c849bb523d22a27f4d9c433895d1ea2e0db112355c3b469", size = 580515, upload-time = "2026-07-03T00:50:05.185Z" }, - { url = "https://files.pythonhosted.org/packages/15/bb/d1383799594d5f2556caa2ae97cebf9aaeba22cc131c54d773ab03b5a433/djlint-1.40.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6e7ddbe49919093ef386f013e79b55bd5b923ef224cfc19bfb090da6f8555cda", size = 605908, upload-time = "2026-07-03T00:50:06.483Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e5/5e66780ad129ec8b80d4eb410eb1e381c406356ed1f2de4aea1d4b667ba4/djlint-1.40.2-cp314-cp314-win32.whl", hash = "sha256:b6b10d2e1d7400453695257eb9b343a52a6814e2203778d5f9b9b5bbf7228033", size = 440078, upload-time = "2026-07-03T00:50:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/c9/3c/dc2d5dd03b47b39f37e125d9d8f2e8faaa033a9121d7b9423bd888538d6e/djlint-1.40.2-cp314-cp314-win_amd64.whl", hash = "sha256:ee825c58347ab0ba7a8383402840c3294a9c03461cf957753bf7254eef8a87a6", size = 492181, upload-time = "2026-07-03T00:50:09.468Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9b/c5e9b2dad272acfa13f50bfaf6eab33165591d9970a75d8fe003a8d5c373/djlint-1.40.2-cp314-cp314-win_arm64.whl", hash = "sha256:69fc1c7c5704964977e2a12eb870840c5819fa0e47fc49adb2d36e4db1707ade", size = 436170, upload-time = "2026-07-03T00:50:10.729Z" }, - { url = "https://files.pythonhosted.org/packages/2b/a3/a639d6b9126e8d62bbeed3122a9f13f0f4f427f5bb6603b0e7a38b2a8323/djlint-1.40.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:94dc6e7be625c86ca7395c2bcf750e65587d10eecf65752ff3b11fdb6790fee1", size = 617749, upload-time = "2026-07-03T00:50:12.296Z" }, - { url = "https://files.pythonhosted.org/packages/d3/fb/49fea811fc720a6e588259ecb95d158edd5a80ed9d6244119ae6f2e6c9f5/djlint-1.40.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4f3d5835adbc05eddb9b05feff1465630015b0639a5a3360e7f194ae80ddecf4", size = 589156, upload-time = "2026-07-03T00:50:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/d8/5c/d750f26136d433f76398469e09ccd778aa6383915c91fa511a9b0ff2f5e3/djlint-1.40.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36c0c55caee9843895acb6032d72e67f8af1e60c2153c20eded6b07cd873f4bb", size = 611560, upload-time = "2026-07-03T00:50:15.142Z" }, - { url = "https://files.pythonhosted.org/packages/8c/4f/2cb74536eb817b6ccabeb19b508f40319cab7ce805c3cb92dc3d873671e2/djlint-1.40.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:828e443e9b1edd8b73047e1f4e1f81dc93e7f6ece36c8fc0500b457eab040088", size = 633415, upload-time = "2026-07-03T00:50:16.556Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9c/6e5ae6a41b6406389ee0b1ec05491cf35957858694dc1b0f39122984b35d/djlint-1.40.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d116c1d51a5034fb1d2fa8403ae5c544b3f4c3fcffff4f62fbf82e4211b7a2fb", size = 618263, upload-time = "2026-07-03T00:50:18.579Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7e/19d7c473c6e023c419cc196688bdd1958902a1aafbc9d4524c3e9b5ddc93/djlint-1.40.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d8e8853f35c24b86bec1259b85c5f3ef6d6dbf6b261cfbe2c5bc8652919aeb24", size = 642095, upload-time = "2026-07-03T00:50:20.031Z" }, - { url = "https://files.pythonhosted.org/packages/a8/d9/882a4f5027f12b2a3ce09bd68f4d922a27e8af132e2ff7af46a597d9556a/djlint-1.40.2-cp314-cp314t-win32.whl", hash = "sha256:e4ca9de0e528695f9486566066f664ec994928f3d68741ebf63cc6ed3531655e", size = 462707, upload-time = "2026-07-03T00:50:21.558Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7a/393f3b78735a4d6e41e2ee2bb0f4b9207c93244667d77ab286e46ae91ed3/djlint-1.40.2-cp314-cp314t-win_amd64.whl", hash = "sha256:17d3e40e9a84d55708214ec57db1f6f5ddeb01feedd56696fad85b872a148d67", size = 511638, upload-time = "2026-07-03T00:50:23.204Z" }, - { url = "https://files.pythonhosted.org/packages/34/50/3a44bbb2a918c23e93846895e19ccbce7baab8e2e5be1cca757b3bcd6215/djlint-1.40.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5712c2ac633317b039ef7123ebac197f177d49dc0ddc8a3891edb688ccb5dde9", size = 454371, upload-time = "2026-07-03T00:50:25.063Z" }, - { url = "https://files.pythonhosted.org/packages/0d/8f/463b58ad48f7ebcf1d72d0ce34b9a4c86cf056885240f66e286ba83f4c64/djlint-1.40.2-py3-none-any.whl", hash = "sha256:861ae83cf2aa0754d401e0eea074a8d2c9b5a13b1c930e46a494979f7d112c7e", size = 65070, upload-time = "2026-07-03T00:50:26.193Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/75/98/fdee72fa3bd20af0ca42c136d076c8a2069cd7303d66cb77c60fc901914f/djlint-1.40.3.tar.gz", hash = "sha256:4fe34e932c55f5b87c8df0daf60c07afbe8f620e1a24da1a2bde0ea2651c430e", size = 59215, upload-time = "2026-07-04T11:48:31.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ef/c9b7728d1345ef773698983119e15cda95aedacc9a978e0776184a73c898/djlint-1.40.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e2dbb2219d9133ac5d3dfb9b6bb6a7db582ebcfe005679c16d6cbf284a8fe5", size = 577267, upload-time = "2026-07-04T11:47:46.954Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/d41a15487a18e544bb9b7c2bbc07a38cf56e13b62cae85fd78a6ffb7df50/djlint-1.40.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47b29ce89517a2ebbfdfc5de74c49f39b0f1c19a01d04aeaed4f8ddf844be367", size = 542842, upload-time = "2026-07-04T11:47:48.057Z" }, + { url = "https://files.pythonhosted.org/packages/93/5e/2dbc6d1ef05716b53db877b52fb2b6de1b0cff3ce7309cec041236563b3f/djlint-1.40.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b54bf1812e2abcff9a97d9208c6fcd0943ed311d345a0c3364fb2e8adf907eda", size = 575133, upload-time = "2026-07-04T11:47:49.585Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c7/25eac5e98b878f572bae8a4e6697b8495666f21aa1db59d0f336d26b1760/djlint-1.40.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec29ff8987808121fcc962e14c66cd929d656d63d3b5b2e04c34c435adf162fe", size = 602415, upload-time = "2026-07-04T11:47:50.674Z" }, + { url = "https://files.pythonhosted.org/packages/ac/20/78d983573e742f8df01a7942c5b0ec57e0b9b0f7fe6b533a7f003916e6ea/djlint-1.40.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62dfa80599ab3f0a1d3c43d85dffa4b42a11933626a6c5fcf03823c066109c19", size = 583668, upload-time = "2026-07-04T11:47:51.717Z" }, + { url = "https://files.pythonhosted.org/packages/80/9b/6dfa0574e980741c9bcc75168c3c790f714647dd526af88a42acfdc6b45f/djlint-1.40.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6715fb4204436013a4eb7e1e7f7edcce44eb03ec94e428ca886c5f1787540e63", size = 611619, upload-time = "2026-07-04T11:47:52.825Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1b/a9f764dcf2dfa058472b95e7e148432b109aa9569e1714ca5c22787ce6d0/djlint-1.40.3-cp312-cp312-win32.whl", hash = "sha256:54e34f363278b292b274c28326542bc0f42c06a6bd99867c630864fd772b50d7", size = 437224, upload-time = "2026-07-04T11:47:53.973Z" }, + { url = "https://files.pythonhosted.org/packages/22/8e/c9d4d8f36bda28784d42fae7c067e8951e7b3ac7e3d1e3fd5f043bc42cad/djlint-1.40.3-cp312-cp312-win_amd64.whl", hash = "sha256:00e053bf26d5c9ddcad67455c597a6ef47ad8debfc59911c00d17b72c17625ab", size = 487134, upload-time = "2026-07-04T11:47:54.997Z" }, + { url = "https://files.pythonhosted.org/packages/72/38/0f665b8a24db2818b15abb459590e56a44909016cd8056906768c0790507/djlint-1.40.3-cp312-cp312-win_arm64.whl", hash = "sha256:33ca64c999010426643977eb4538e8fa790150f601cfb7af778e2cd1652d9ad8", size = 430019, upload-time = "2026-07-04T11:47:56.217Z" }, + { url = "https://files.pythonhosted.org/packages/d9/32/3b9519f888b3983ef3bfbaf220b3a493ccbf68d7a1752e05189fc9309b56/djlint-1.40.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e02a4a3cd5f5489b5dd6dc8f0807105cdb1faba1c7ff011cdc539e215b3f945d", size = 574381, upload-time = "2026-07-04T11:47:57.323Z" }, + { url = "https://files.pythonhosted.org/packages/4c/32/61d2c87fadc03044a8ec131d10087ce54196192dde864ec300a3b0494282/djlint-1.40.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ea2e27d4f23da2d397f7dd1af7a9b38b6866dc5c3697229469f31449cb8e756", size = 541643, upload-time = "2026-07-04T11:47:58.609Z" }, + { url = "https://files.pythonhosted.org/packages/66/d6/69518734dd0b266778d3800c8f6871c645dbdce64241514d4fc531f4202e/djlint-1.40.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191c439e57be56327be260e2b6d9d888f81b1016f388af5f55dc063146e6e290", size = 572995, upload-time = "2026-07-04T11:47:59.775Z" }, + { url = "https://files.pythonhosted.org/packages/01/ad/cf94cfae8f07d98b5c2f8b41f7755d20982edcad571bd1d17331af8b162f/djlint-1.40.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7704817e41e795e2d62b2e1646c8ef512678b484bae9b15d10a4968ff6cc77c", size = 600618, upload-time = "2026-07-04T11:48:00.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/1a/0fae63824b1cd6f17788e474ca149bccbc79896960e1f75e8c10fb35b240/djlint-1.40.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:94a5d64dd7e0396ecdc953f7cbc3f935427330f63b5a3ee80551c3ca56a11bd2", size = 581418, upload-time = "2026-07-04T11:48:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/a5/25/789fcf430e3eb5e07abfe637407cbb29ad620d1f1a186c6dd113ef5c4b7a/djlint-1.40.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bd6d14cc6ac7732a81d704276bf0865749c640c149dc7218da940c6b9a89e4c4", size = 610274, upload-time = "2026-07-04T11:48:03.196Z" }, + { url = "https://files.pythonhosted.org/packages/30/e2/8690bc27594f50b2682873affe46eab1e1b7cb6492b1c64b140981851c1c/djlint-1.40.3-cp313-cp313-win32.whl", hash = "sha256:2e858b98c5961bbdfe20a02dd3e49115de804182601a2f1c1e27b2f6a1a68f56", size = 437031, upload-time = "2026-07-04T11:48:04.354Z" }, + { url = "https://files.pythonhosted.org/packages/47/52/04c3022eb839c063a1791560e02404b2b82f1ebb486bfe9dee93f2d945ff/djlint-1.40.3-cp313-cp313-win_amd64.whl", hash = "sha256:e7db951983c33c27bf2a32153f11a05d0ec91e2f56c6d95c92c0f9a324df7676", size = 486723, upload-time = "2026-07-04T11:48:05.616Z" }, + { url = "https://files.pythonhosted.org/packages/50/ad/8460d545a0875d777d85f7ae36300d779b09b9810579a98a05ab15a83961/djlint-1.40.3-cp313-cp313-win_arm64.whl", hash = "sha256:b12581b55e2b4acf3e9d8f57ef18188618fc31a82500ea9974276428af0a32be", size = 429482, upload-time = "2026-07-04T11:48:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/80/66/36c7f35408d47b88d4547a7a707c2ce4db9128b19fb549b271a53076204c/djlint-1.40.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:669a2b157d2a878eb82e7c85a65e0c57fdfc5193b2c19508d88d64ac82e83e00", size = 572309, upload-time = "2026-07-04T11:48:07.896Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8f/a83bdfe5f5ea4af351755ac5923304e21fe3d12c15659608e56ecd728a0e/djlint-1.40.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:85fe6a62e4759647c98f1800923ad3a186d5a34ecb027da29bf8c825c62236c8", size = 541432, upload-time = "2026-07-04T11:48:09.015Z" }, + { url = "https://files.pythonhosted.org/packages/03/75/ab7225f497acf5bbae18e381cd25d11de2beff9d7d60d058128b90adc172/djlint-1.40.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:498f66913d456da7621e3ba7a712719204d893c6f50b32d860595c1e4e565de7", size = 577447, upload-time = "2026-07-04T11:48:10.175Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7f/52cfe4727bdb2a574acf5e7c17588f851127a75c665b5380edc46a4e5eb3/djlint-1.40.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4539ac7689f4b37b813b36f88679e3c444b8372d0c76fa6d13788069ddf295c", size = 599804, upload-time = "2026-07-04T11:48:11.61Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/8305bf213c9065e3f955ad9fdbf1c88c97eee69a5f4783978b5f4ad8eac4/djlint-1.40.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:976b023f2cd2c564eb71ebc618b308d8c38eb67c5d24574bd0d29d445f2106e5", size = 584955, upload-time = "2026-07-04T11:48:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/0a/85/8b6afa4018fe3c70adecbb6ae19f6372d883d16662a5d39caa3e68da83fc/djlint-1.40.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e61c5d4ad684eeaa4134c5e51e652eb96f3d342d05f5b617eaa629accf591096", size = 609521, upload-time = "2026-07-04T11:48:14.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/42/3e087f577bae675e51c47d123e38c83b249fa5ac29140740e78512b9c36c/djlint-1.40.3-cp314-cp314-win32.whl", hash = "sha256:341dbb91b1ebb96f86804a338c7c40b3a3cb860ace949c40a79da6d45272ff11", size = 442482, upload-time = "2026-07-04T11:48:15.176Z" }, + { url = "https://files.pythonhosted.org/packages/44/15/d71bd699b69f0a1d004f4451779543f778a12bac26c3e9f6ad25a910f205/djlint-1.40.3-cp314-cp314-win_amd64.whl", hash = "sha256:7917f50c96e28c5ecb48312f1696043eba8dd09a497af99f500e020ad0613d36", size = 494803, upload-time = "2026-07-04T11:48:16.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/84/2e9a86cd16d9a17c52945cec926dd4fb3db1a1e91443a5e8002b8e2e45df/djlint-1.40.3-cp314-cp314-win_arm64.whl", hash = "sha256:b09952d9cd50874287a56aaddda8caf98b33b6a94991195d717cc3b68629438a", size = 438407, upload-time = "2026-07-04T11:48:17.482Z" }, + { url = "https://files.pythonhosted.org/packages/45/2e/ae7714a5db1747c4ed14b71583baf84c53646aa6a2aa37916b55ebc46d5d/djlint-1.40.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:97c83b7497686749f7649973882c2fdd239675662f445135957f19bcc815536c", size = 623561, upload-time = "2026-07-04T11:48:18.604Z" }, + { url = "https://files.pythonhosted.org/packages/af/83/cec6eefd521993531e66e6912db85cccd5f871df65e263715a38f6b21e12/djlint-1.40.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:19969e5e5391cb97c755d1e04158456422e51a1808aacb27af624e2c47a45814", size = 593177, upload-time = "2026-07-04T11:48:19.916Z" }, + { url = "https://files.pythonhosted.org/packages/80/39/954d6465f8a072f8ef8d7468d1e33ca7c36912428bb42bb4d4d55145817a/djlint-1.40.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c02df10cf101b3908ff31128d326e23efa66e68cfaef4430e890e32c61efc129", size = 616979, upload-time = "2026-07-04T11:48:21.086Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/f1c1c0fdd7163256cc48192ce0d9bed54cc6e9a537ec66257ea721a9de5d/djlint-1.40.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e3900f481b4dcad5b4d149611b0e2c72c4955cdb1ceb5c1074ecb457cb2cb36", size = 638069, upload-time = "2026-07-04T11:48:22.463Z" }, + { url = "https://files.pythonhosted.org/packages/00/b2/834c1aba299454855555792e71610d4b128456748b1335ede89095158792/djlint-1.40.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:152b2854f92285b0ddb9b730426dfe9e7cc64603a92bde9934d51e80518a0e40", size = 623183, upload-time = "2026-07-04T11:48:23.617Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e9/1858be94f1205c85505d8646ac6b85d1312791e8b8d19f94548cf54ccb5c/djlint-1.40.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:617be8d11f337cc1e4034b8a035ef1c21dcdf4578571c4029d6108439e55765c", size = 646847, upload-time = "2026-07-04T11:48:25.11Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d8/45405e2389f7e7ff74add6abf563e66cb79934809c4298e4412a1e07f216/djlint-1.40.3-cp314-cp314t-win32.whl", hash = "sha256:a44c8248569e783e9ca5eb9588374b1b265b800b93633527d8b084ff93828287", size = 465322, upload-time = "2026-07-04T11:48:26.421Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/cac6d8e0b348c1eddb37f64bf4362e0f43dde0e122de670061386d458747/djlint-1.40.3-cp314-cp314t-win_amd64.whl", hash = "sha256:af048452b63eaf329bb9c011085b554a81ef2417724f3a0f55dd26825383d85f", size = 514250, upload-time = "2026-07-04T11:48:27.886Z" }, + { url = "https://files.pythonhosted.org/packages/13/86/69541eb6079d8eda51085fefae0e7f8603390679894231dac763d90c7ca8/djlint-1.40.3-cp314-cp314t-win_arm64.whl", hash = "sha256:5e125ec3742d710b838c49e74fb5074e0efc80c3261c2bcc36a9817510da438e", size = 456540, upload-time = "2026-07-04T11:48:29.18Z" }, + { url = "https://files.pythonhosted.org/packages/17/40/f29fc555c8fb3a0cf2d12346ee05b8e8c6183ba98b82e5c8ee5453018891/djlint-1.40.3-py3-none-any.whl", hash = "sha256:d2b396a2cb288409fa83abc3f2b17c04aa98b0238bceaeb9d02c1db66e4f0e8f", size = 65272, upload-time = "2026-07-04T11:48:30.239Z" }, ] [[package]] @@ -2343,53 +2343,53 @@ wheels = [ [[package]] name = "numpy" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987, upload-time = "2026-06-21T20:56:16.657Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322, upload-time = "2026-06-21T20:56:19.813Z" }, - { url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605, upload-time = "2026-06-21T20:56:22.173Z" }, - { url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628, upload-time = "2026-06-21T20:56:23.914Z" }, - { url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691, upload-time = "2026-06-21T20:56:25.631Z" }, - { url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066, upload-time = "2026-06-21T20:56:28.009Z" }, - { url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638, upload-time = "2026-06-21T20:56:30.26Z" }, - { url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647, upload-time = "2026-06-21T20:56:32.41Z" }, - { url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688, upload-time = "2026-06-21T20:56:34.657Z" }, - { url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237, upload-time = "2026-06-21T20:56:36.24Z" }, - { url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912, upload-time = "2026-06-21T20:56:38.733Z" }, - { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" }, - { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" }, - { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504, upload-time = "2026-06-21T20:56:47.567Z" }, - { url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086, upload-time = "2026-06-21T20:56:49.352Z" }, - { url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250, upload-time = "2026-06-21T20:56:51.542Z" }, - { url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864, upload-time = "2026-06-21T20:56:55.401Z" }, - { url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407, upload-time = "2026-06-21T20:56:57.75Z" }, - { url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431, upload-time = "2026-06-21T20:57:00.016Z" }, - { url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420, upload-time = "2026-06-21T20:57:01.815Z" }, - { url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533, upload-time = "2026-06-21T20:57:03.961Z" }, - { url = "https://files.pythonhosted.org/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9", size = 16783807, upload-time = "2026-06-21T20:57:06.253Z" }, - { url = "https://files.pythonhosted.org/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446", size = 11765215, upload-time = "2026-06-21T20:57:08.547Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c", size = 5324493, upload-time = "2026-06-21T20:57:11.012Z" }, - { url = "https://files.pythonhosted.org/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303", size = 6645211, upload-time = "2026-06-21T20:57:12.919Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22", size = 15166004, upload-time = "2026-06-21T20:57:14.717Z" }, - { url = "https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03", size = 16650797, upload-time = "2026-06-21T20:57:16.906Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a", size = 16524647, upload-time = "2026-06-21T20:57:19.165Z" }, - { url = "https://files.pythonhosted.org/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21", size = 18411841, upload-time = "2026-06-21T20:57:21.638Z" }, - { url = "https://files.pythonhosted.org/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731", size = 6106361, upload-time = "2026-06-21T20:57:23.844Z" }, - { url = "https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73", size = 12551749, upload-time = "2026-06-21T20:57:25.945Z" }, - { url = "https://files.pythonhosted.org/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e", size = 10617072, upload-time = "2026-06-21T20:57:28.175Z" }, - { url = "https://files.pythonhosted.org/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b", size = 11881067, upload-time = "2026-06-21T20:57:30.104Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927", size = 5440290, upload-time = "2026-06-21T20:57:32.193Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826", size = 6748371, upload-time = "2026-06-21T20:57:33.933Z" }, - { url = "https://files.pythonhosted.org/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd", size = 15214643, upload-time = "2026-06-21T20:57:35.677Z" }, - { url = "https://files.pythonhosted.org/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6", size = 16690128, upload-time = "2026-06-21T20:57:38.159Z" }, - { url = "https://files.pythonhosted.org/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a", size = 16577902, upload-time = "2026-06-21T20:57:40.447Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9", size = 18452814, upload-time = "2026-06-21T20:57:42.875Z" }, - { url = "https://files.pythonhosted.org/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7", size = 6253168, upload-time = "2026-06-21T20:57:45.101Z" }, - { url = "https://files.pythonhosted.org/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa", size = 12736286, upload-time = "2026-06-21T20:57:46.935Z" }, - { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" }, +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, ] [[package]] From dfbf3906506581e002a9d283d7febc9ee4ddd1ba Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sat, 4 Jul 2026 15:47:25 -0700 Subject: [PATCH 11/66] update version to 2.1.2 --- frontend/package.json | 2 +- pyproject.toml | 2 +- tests/test_fs_filters_ignore.py | 1 - uv.lock | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index d18e9aa18..d11db2abf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "codex", - "version": "2.1.1", + "version": "2.1.2", "private": true, "description": "ui for codex api", "type": "module", diff --git a/pyproject.toml b/pyproject.toml index 28dc0fbb3..eeda95f8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ readme = "README.md" requires-python = ">=3.12" license = "GPL-3.0-only" name = "codex" -version = "2.1.1" +version = "2.1.2" [[project.authors]] name = "AJ Slater" email = "aj@slater.net" diff --git a/tests/test_fs_filters_ignore.py b/tests/test_fs_filters_ignore.py index ddd5e7597..fc81d6ce5 100644 --- a/tests/test_fs_filters_ignore.py +++ b/tests/test_fs_filters_ignore.py @@ -14,7 +14,6 @@ from codex.librarian.fs import filters as filters_mod from codex.librarian.fs.filters import is_ignored_basename, is_ignored_path -from codex.librarian.fs.poller import snapshot as snapshot_mod from codex.librarian.fs.poller.snapshot import DiskSnapshot from codex.librarian.fs.watcher.data import ChangeBatch from codex.librarian.fs.watcher.dirs import expand_dir_added diff --git a/uv.lock b/uv.lock index 5e823d9ce..d6e19c432 100644 --- a/uv.lock +++ b/uv.lock @@ -551,7 +551,7 @@ wheels = [ [[package]] name = "codex" -version = "2.1.1" +version = "2.1.2" source = { editable = "." } dependencies = [ { name = "adrf" }, From 1d066076e52bd816c9df4235402e422625e6b8b4 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sat, 4 Jul 2026 15:48:50 -0700 Subject: [PATCH 12/66] fix typechecking --- tests/test_fs_filters_ignore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_fs_filters_ignore.py b/tests/test_fs_filters_ignore.py index fc81d6ce5..8b807a9cf 100644 --- a/tests/test_fs_filters_ignore.py +++ b/tests/test_fs_filters_ignore.py @@ -170,7 +170,7 @@ def fake_scandir(path): raise PermissionError(13, "Permission denied") return real_scandir(path) - with patch.object(snapshot_mod.os, "scandir", side_effect=fake_scandir): + with patch.object(os, "scandir", side_effect=fake_scandir): snap = DiskSnapshot(str(_TEST_LIB_ROOT), getLogger("test")) names = {Path(p).name for p in snap.paths} From 07ffa2aa5e203561ced021ae1c3075be518b30dc Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sat, 4 Jul 2026 15:50:50 -0700 Subject: [PATCH 13/66] news for v2.1.2 Co-Authored-By: Claude Opus 4.8 --- NEWS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/NEWS.md b/NEWS.md index 4677afc65..159bc9335 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,14 @@ width: 128px; border-radius: 128px; " /> +## v2.1.2 + +- Fixes + - A folder with denied permissions no longer crashes the whole library + scan. + - Ignore NAS and OS junk folders, including Synology recycle bins and macOS + and Windows metadata. + ## v2.1.1 - Fixes From ed38cb5554efd73b166b826e8ed297229b9ed853 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sat, 4 Jul 2026 15:57:29 -0700 Subject: [PATCH 14/66] format --- NEWS.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index 159bc9335..ff7a4fcdb 100644 --- a/NEWS.md +++ b/NEWS.md @@ -9,8 +9,7 @@ border-radius: 128px; ## v2.1.2 - Fixes - - A folder with denied permissions no longer crashes the whole library - scan. + - A folder with denied permissions no longer crashes the whole library scan. - Ignore NAS and OS junk folders, including Synology recycle bins and macOS and Windows metadata. From a41eede6af41854006d7b25c70386016a3cb02ae Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sat, 4 Jul 2026 16:02:12 -0700 Subject: [PATCH 15/66] Squashed commit of the following: commit 9db6fe273622635700defb5c9015bf540630e40a Merge: c8984b9ed ed38cb555 Author: AJ Slater Date: Sat Jul 4 15:57:53 2026 -0700 Merge branch 'develop' into online-estimate-consume-comicbox commit c8984b9ede16f82f398501adb49c58d5428c168d Merge: 2b2e63a35 cd84ed99e Author: AJ Slater Date: Sat Jul 4 13:22:52 2026 -0700 Merge branch 'develop' into online-estimate-consume-comicbox commit 2b2e63a35013de0cd5593c8c5cadb360ffbd23ab Author: AJ Slater Date: Fri Jul 3 20:36:09 2026 -0700 feat(onlinetag): consume comicbox 4.1.0 estimate; drop the codex copy Pin comicbox ~=4.1.0 and move the online-tag run estimate onto its comicbox.online_estimate.estimate_run() home: - estimate.py becomes a thin seam over comicbox: estimate_seconds() forwards to estimate_run().seconds and re-exports SOURCE_RATE_PER_MINUTE. The request/rate constants and math are deleted -- comicbox owns and tests them now. - The launcher dialog's per-source rates and per-comic request model derive from comicbox via a new tagging-estimate.json (choices/onlinetag.py, build-choices); only display labels stay in the component, so the JS estimate can no longer drift from the backend. - The codex estimate test slims to an adapter / re-export guard. Prep branch: the ~=4.1.0 pin does not resolve until comicbox 4.1.0 is published, so uv.lock is untouched and CI targets that shell out to `uv` will fail until then. Post-publish, run `uv lock`; the change was validated locally with the 4.1.0 modules installed into the venv. Co-Authored-By: Claude Opus 4.8 --- codex/choices/choices_to_json.py | 2 + codex/choices/onlinetag.py | 50 ++++++++++ codex/librarian/onlinetag/estimate.py | 85 ++++------------- .../components/online-tag/launcher-dialog.vue | 36 ++++--- pyproject.toml | 2 +- tests/test_onlinetag_estimate.py | 95 ++++--------------- 6 files changed, 107 insertions(+), 163 deletions(-) create mode 100644 codex/choices/onlinetag.py diff --git a/codex/choices/choices_to_json.py b/codex/choices/choices_to_json.py index 2aac6ce9e..c5e5e04ce 100755 --- a/codex/choices/choices_to_json.py +++ b/codex/choices/choices_to_json.py @@ -20,6 +20,7 @@ ) from codex.choices.jobs import ADMIN_JOBS from codex.choices.notifications import WebsocketMessages +from codex.choices.onlinetag import TAGGING_ESTIMATE from codex.choices.reader import READER_CHOICES, READER_DEFAULTS from codex.choices.search import SEARCH_FIELDS from codex.choices.statii import ADMIN_STATUS_TITLES @@ -74,6 +75,7 @@ "reader-defaults.json": READER_DEFAULTS, "reader-map.json": READER_CHOICES, "search-map.json": SEARCH_FIELDS, + "tagging-estimate.json": TAGGING_ESTIMATE, } ) diff --git a/codex/choices/onlinetag.py b/codex/choices/onlinetag.py new file mode 100644 index 000000000..c65d739da --- /dev/null +++ b/codex/choices/onlinetag.py @@ -0,0 +1,50 @@ +""" +Online-tag run estimate model, derived from comicbox. + +The launcher dialog (``frontend/src/components/online-tag/launcher-dialog.vue``) +estimates a run's time and request count client-side. The per-source rates and +the per-comic request model come from comicbox (``comicbox.online_estimate``, +4.1.0) so the JS estimate tracks the backend instead of hand-syncing constants. +Emitted to ``frontend/src/choices/tagging-estimate.json`` by +``choices_to_json.py``. Display labels stay in the component — they're UI copy. +""" + +from types import MappingProxyType + +from comicbox.formats.base.online.rate_limits import COMICVINE_DEFAULT_PER_HOUR +from comicbox.online_estimate import ( + COMICVINE_REQUESTS_BY_MODE, + DEFAULT_REQUESTS_PER_COMIC, + METRON_REQUESTS_PER_COMIC, + SOURCE_RATE_PER_MINUTE, +) + +# Effective per-source rate limits for the launcher's rate-limit warning. +# ``per_minute`` is comicbox's estimate rate; ``per_hour`` is the effective +# hourly ceiling — Metron's per-minute cap projected over the hour, Comic Vine's +# documented 200/hour cap. +_SOURCE_RATES = MappingProxyType( + { + "metron": MappingProxyType( + { + "per_minute": SOURCE_RATE_PER_MINUTE["metron"], + "per_hour": SOURCE_RATE_PER_MINUTE["metron"] * 60, + } + ), + "comicvine": MappingProxyType( + { + "per_minute": SOURCE_RATE_PER_MINUTE["comicvine"], + "per_hour": COMICVINE_DEFAULT_PER_HOUR, + } + ), + } +) + +TAGGING_ESTIMATE = MappingProxyType( + { + "source_rates": _SOURCE_RATES, + "metron_requests_per_comic": METRON_REQUESTS_PER_COMIC, + "comicvine_requests_by_mode": COMICVINE_REQUESTS_BY_MODE, + "default_requests_per_comic": DEFAULT_REQUESTS_PER_COMIC, + } +) diff --git a/codex/librarian/onlinetag/estimate.py b/codex/librarian/onlinetag/estimate.py index f7f2cbf50..d21d57626 100644 --- a/codex/librarian/onlinetag/estimate.py +++ b/codex/librarian/onlinetag/estimate.py @@ -1,84 +1,35 @@ """ -Estimate how long an online tagging run will take. - -This mirrors the "tag online" launcher dialog's estimate -(frontend/src/components/online-tag/launcher-dialog.vue) so the number the -admin saw before starting carries forward into the live "Look Up Online -Tags" status countdown. Keep the constants and the math in sync with that -component — the whole point is that the two agree. - -The model is deliberately simple: a run's wall-clock is bound by the -slowest enabled source's per-minute throughput (first-match-wins means a -comic isn't done until the binding source answers), times the number of API -calls each comic needs, over the comics left. - -API calls per comic are counted per source. Metron (comicbox 4.0.5+) is a -fixed two-step search — one series_list discovery call plus one issues_list -lookup — that no longer scales with match mode, so it is a flat count. Comic -Vine's fuzzy volume→issue path does more verification the tighter the match -mode, so it keeps a per-mode count. - -Under first-match-wins a comic stops at the first source that answers, so we -conservatively bill the costliest single selected source. When merging all -sources (comicbox first_wins=False) every enabled source is queried for -every comic, so the per-comic calls are summed instead. +Adapt comicbox's online-tag run estimator to codex's callers. + +The request/rate model that used to live here now lives in +``comicbox.online_estimate`` (comicbox 4.1.0), next to the search flow it +describes and the rate limits it divides by. This module is the thin +codex-facing seam over it: callers keep importing ``estimate_seconds`` / +``SOURCE_RATE_PER_MINUTE`` from here, and the live status countdown and the +launcher dialog stay in agreement with comicbox because they read the same +source of truth instead of a hand-synced copy. """ from __future__ import annotations -from types import MappingProxyType -from typing import Final - -# Per-minute request budgets per source. Matches launcher-dialog SOURCE_RATES. -# Public because the live session snapshot reports each source's budget; the -# leading underscore is gone but the values are still the single source of -# truth shared with the estimate math below. -SOURCE_RATE_PER_MINUTE: Final = MappingProxyType({"metron": 20, "comicvine": 3}) - -# API calls per comic, per source. Matches the launcher-dialog's -# METRON_CALLS_PER_COMIC / COMICVINE_CALLS_BY_MODE. Metron's two-step search -# (series_list + issues_list) is a flat count that match mode does not change; -# Comic Vine's count scales with match mode. -_METRON_CALLS_PER_COMIC: Final = 2 -_COMICVINE_CALLS_BY_MODE: Final = MappingProxyType( - {"eager": 2, "auto": 3, "careful": 5} -) - -# Fallbacks for an unknown mode / unrecognized source, mirroring the dialog. -_DEFAULT_CALLS_PER_COMIC: Final = 3 -_DEFAULT_RATE_PER_MINUTE: Final = 10 - +from typing import TYPE_CHECKING -def _calls_per_comic(source: str, mode: str) -> int: - """Return the API calls one comic costs against a single source under ``mode``.""" - if source == "metron": - return _METRON_CALLS_PER_COMIC - if source == "comicvine": - return _COMICVINE_CALLS_BY_MODE.get(mode, _DEFAULT_CALLS_PER_COMIC) - return _DEFAULT_CALLS_PER_COMIC +from comicbox.online_session import SOURCE_RATE_PER_MINUTE, estimate_run +if TYPE_CHECKING: + from collections.abc import Sequence -def _slowest_rate_per_minute(sources: tuple[str, ...]) -> int: - rates = [SOURCE_RATE_PER_MINUTE[s] for s in sources if s in SOURCE_RATE_PER_MINUTE] - return min(rates) if rates else _DEFAULT_RATE_PER_MINUTE +__all__ = ("SOURCE_RATE_PER_MINUTE", "estimate_seconds") def estimate_seconds( remaining_comics: int, mode: str, - sources: tuple[str, ...], + sources: Sequence[str], *, merge_all_sources: bool = False, ) -> float: """Estimated seconds to look up ``remaining_comics`` more comics.""" - if remaining_comics <= 0 or not sources: - return 0.0 - per_source_calls = [_calls_per_comic(source, mode) for source in sources] - # Merge queries every source per comic (sum); first-match-wins stops at - # the first source that answers, so bill the costliest single source. - calls_per_comic = ( - sum(per_source_calls) if merge_all_sources else max(per_source_calls) - ) - total_calls = remaining_comics * calls_per_comic - minutes = total_calls / _slowest_rate_per_minute(sources) - return minutes * 60.0 + return estimate_run( + remaining_comics, mode, sources, merge_all_sources=merge_all_sources + ).seconds diff --git a/frontend/src/components/online-tag/launcher-dialog.vue b/frontend/src/components/online-tag/launcher-dialog.vue index bba5f5d43..cae3bf656 100644 --- a/frontend/src/components/online-tag/launcher-dialog.vue +++ b/frontend/src/components/online-tag/launcher-dialog.vue @@ -182,6 +182,7 @@ import { mapActions, mapState } from "pinia"; import { HTTP } from "@/api/v4/base"; import TAGGING_CHOICES from "@/choices/tagging-choices.json"; +import TAGGING_ESTIMATE from "@/choices/tagging-estimate.json"; import { useAdminStore } from "@/stores/admin"; import { useCommonStore } from "@/stores/common"; import { useOnlineTagStore } from "@/stores/online-tag"; @@ -202,29 +203,26 @@ const PROMPTS_MODE_HINTS = { "Skips all ambiguous matches without prompting. Unmatched comics are left unchanged.", }; -const SOURCE_RATES = { - metron: { perMinute: 20, perHour: 1200, label: "Metron Cloud" }, - comicvine: { perMinute: 3, perHour: 200, label: "Comic Vine" }, -}; - -// API calls per comic, per source. Keep in sync with -// codex/librarian/onlinetag/estimate.py. Metron (comicbox 4.0.5+) is a flat -// two-step search (series_list + issues_list) that match mode doesn't change; -// Comic Vine's count scales with match mode. -const METRON_CALLS_PER_COMIC = 2; -const COMICVINE_CALLS_BY_MODE = { - eager: 2, - auto: 3, - careful: 5, -}; -const DEFAULT_CALLS_PER_COMIC = 3; +// Display names are UI copy; the per-source rates and per-comic request model +// derive from comicbox via tagging-estimate.json (build-choices), so the client +// estimate tracks the backend instead of hand-syncing constants. +const SOURCE_LABELS = { metron: "Metron Cloud", comicvine: "Comic Vine" }; +const SOURCE_RATES = Object.fromEntries( + Object.entries(TAGGING_ESTIMATE.sourceRates).map(([source, rate]) => [ + source, + { ...rate, label: SOURCE_LABELS[source] || source }, + ]), +); function callsForSource(source, mode) { - if (source === "metron") return METRON_CALLS_PER_COMIC; + if (source === "metron") return TAGGING_ESTIMATE.metronRequestsPerComic; if (source === "comicvine") { - return COMICVINE_CALLS_BY_MODE[mode] || DEFAULT_CALLS_PER_COMIC; + return ( + TAGGING_ESTIMATE.comicvineRequestsByMode[mode] || + TAGGING_ESTIMATE.defaultRequestsPerComic + ); } - return DEFAULT_CALLS_PER_COMIC; + return TAGGING_ESTIMATE.defaultRequestsPerComic; } // Only these sources support online id tagging; derived from comicbox's diff --git a/pyproject.toml b/pyproject.toml index eeda95f8a..1d4a6dd25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "adrf~=0.1.12", "bidict~=0.23", "channels~=4.2", - "comicbox[pdf]~=4.0.5", + "comicbox[pdf]~=4.1.0", "cryptography>=48.0.0", "dateparser~=1.2", "django-cachalot~=2.8", diff --git a/tests/test_onlinetag_estimate.py b/tests/test_onlinetag_estimate.py index 279b97667..c5fed9ece 100644 --- a/tests/test_onlinetag_estimate.py +++ b/tests/test_onlinetag_estimate.py @@ -1,9 +1,9 @@ """ -Unit tests for the online tagging time estimate. +The codex estimate seam delegates to comicbox.online_estimate. -Mirrors the launcher dialog's algorithm: per-comic API calls (counted per -source — first-match-wins bills the costliest source, merge sums them) over -the slowest enabled source's per-minute rate. +The request/rate model and its full test matrix live in comicbox (4.1.0); these +just guard that codex's thin adapter forwards correctly and re-exports the rate +map the session snapshot reads. """ from __future__ import annotations @@ -12,84 +12,27 @@ import pytest -from codex.librarian.onlinetag.estimate import estimate_seconds +from codex.librarian.onlinetag.estimate import SOURCE_RATE_PER_MINUTE, estimate_seconds -_AUTO_METRON_SECONDS: Final = 60.0 -_SLOWEST_SOURCE_SECONDS: Final = 600.0 -_COMICVINE_CAREFUL_SECONDS: Final = 1000.0 -_COMICVINE_EAGER_SECONDS: Final = 400.0 -_UNKNOWN_DEFAULTS_SECONDS: Final = 180.0 -_MERGE_TWO_SOURCES_SECONDS: Final = 1000.0 +_METRON_AUTO_SECONDS: Final = 60.0 +_MERGE_SECONDS: Final = 1000.0 +_METRON_RATE: Final = 20 +_COMICVINE_RATE: Final = 3 -def test_zero_remaining_is_zero() -> None: - """No remaining comics means no time.""" - assert estimate_seconds(0, "auto", ("metron",)) == 0.0 +def test_estimate_seconds_delegates_to_comicbox() -> None: + """Metron's flat two-step: 10 comics x 2 requests / 20 per-minute = 60s.""" + assert estimate_seconds(10, "auto", ("metron",)) == _METRON_AUTO_SECONDS -def test_no_sources_is_zero() -> None: - """No enabled sources means no time.""" - assert estimate_seconds(10, "auto", ()) == 0.0 - - -def test_auto_mode_metron() -> None: - """Metron's flat two-step search: 10 comics x 2 calls / 20 per-minute = 60s.""" - assert estimate_seconds(10, "auto", ("metron",)) == _AUTO_METRON_SECONDS - - -def test_metron_calls_are_mode_independent() -> None: - """Match mode does not change Metron's fixed two-step request count.""" - auto = estimate_seconds(10, "auto", ("metron",)) - careful = estimate_seconds(10, "careful", ("metron",)) - eager = estimate_seconds(10, "eager", ("metron",)) - assert auto == careful == eager == _AUTO_METRON_SECONDS - - -def test_slowest_source_binds() -> None: - """ - Comicvine (3/min) is slower than metron (20/min) and binds the rate. - - First-match-wins bills the costliest source: max(metron 2, comicvine 3) = 3. - 10 x 3 / 3 = 10 min = 600s. - """ - assert ( - estimate_seconds(10, "auto", ("metron", "comicvine")) == _SLOWEST_SOURCE_SECONDS - ) - - -def test_comicvine_match_mode_scales_calls() -> None: - """Comic Vine's per-comic calls scale with match mode (careful 5, eager 2).""" - careful = estimate_seconds(10, "careful", ("comicvine",)) - eager = estimate_seconds(10, "eager", ("comicvine",)) - assert careful == pytest.approx(_COMICVINE_CAREFUL_SECONDS) - assert eager == pytest.approx(_COMICVINE_EAGER_SECONDS) - - -def test_unknown_mode_and_source_use_defaults() -> None: - """ - Unknown mode -> 3 calls/comic; unknown source -> 10/min default. - - 10 x 3 / 10 = 3 min = 180s. - """ - assert estimate_seconds(10, "bogus", ("unknown",)) == _UNKNOWN_DEFAULTS_SECONDS - - -def test_merge_all_sources_sums_per_source_calls() -> None: - """ - Merge mode queries every source per comic, so per-comic calls are summed. - - metron 2 + comicvine 3 = 5 calls/comic over 3 per-minute (slowest). - 10 x 5 / 3 = 1000s — more than the 600s first-match-wins estimate but not - double it, since the two sources cost different amounts. - """ +def test_estimate_seconds_passes_merge_flag() -> None: + """merge_all_sources reaches comicbox: (metron 2 + comicvine 3) x 10 / 3.""" assert estimate_seconds( 10, "auto", ("metron", "comicvine"), merge_all_sources=True - ) == pytest.approx(_MERGE_TWO_SOURCES_SECONDS) + ) == pytest.approx(_MERGE_SECONDS) -def test_merge_all_sources_single_source_is_noop() -> None: - """With one source there's nothing to merge, so the estimate is unchanged.""" - assert ( - estimate_seconds(10, "auto", ("metron",), merge_all_sources=True) - == _AUTO_METRON_SECONDS - ) +def test_source_rate_per_minute_reexported() -> None: + """The snapshot reads the per-source rate map through this seam.""" + assert SOURCE_RATE_PER_MINUTE["metron"] == _METRON_RATE + assert SOURCE_RATE_PER_MINUTE["comicvine"] == _COMICVINE_RATE From 8ebc051f907f594aae7189d66f6e4354ab36d8b2 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sat, 4 Jul 2026 16:05:43 -0700 Subject: [PATCH 16/66] update deps --- uv.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index d6e19c432..d62cd7156 100644 --- a/uv.lock +++ b/uv.lock @@ -636,7 +636,7 @@ requires-dist = [ { name = "adrf", specifier = "~=0.1.12" }, { name = "bidict", specifier = "~=0.23" }, { name = "channels", specifier = "~=4.2" }, - { name = "comicbox", extras = ["pdf"], specifier = "~=4.0.5" }, + { name = "comicbox", extras = ["pdf"], specifier = "~=4.1.0" }, { name = "cryptography", specifier = ">=48.0.0" }, { name = "dateparser", specifier = "~=1.2" }, { name = "django", specifier = "~=6.0" }, @@ -721,7 +721,7 @@ wheels = [ [[package]] name = "comicbox" -version = "4.0.5" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bidict" }, @@ -758,9 +758,9 @@ dependencies = [ { name = "xmltodict" }, { name = "zipremove" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/b7/06d22b40edba18a7d66988d376205184b1b580ed0f7ab69d7340051f36b6/comicbox-4.0.5.tar.gz", hash = "sha256:337b8ae057a684d814d78b2460efdbe6d2df403d23060977b2f853bc1f0dfd0b", size = 978125, upload-time = "2026-07-04T02:11:17.052Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/b3/988bfc40cc288e1036ed9b8d9a37cacbc4b63948a4decde6d855714b1c23/comicbox-4.1.0.tar.gz", hash = "sha256:8c14e015ad0829651f17d10f8bae34d2d378ff9385c3800e2fb85fe126f8cf56", size = 981621, upload-time = "2026-07-04T23:01:22.83Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/86/ee66f8fd03982dc931ebf17cb68c05a7834d90078ba5ec9c302d8b879dcf/comicbox-4.0.5-py3-none-any.whl", hash = "sha256:6c71ed43fd0c7295cdbc7380fe5bbf04ef5560f00f3356d6f97559b4ca401185", size = 328601, upload-time = "2026-07-04T02:11:15.394Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/717c5ea5de203da3e1c0e2722da51c88adfcecb9c193fe32e28d142ab6d5/comicbox-4.1.0-py3-none-any.whl", hash = "sha256:4287bc0664260e93605617640fc47762ae742eb52739083aac9a32ef9d781a5a", size = 330884, upload-time = "2026-07-04T23:01:21.444Z" }, ] [package.optional-dependencies] From 08eadba9aa645c58db71af49eccb21dafe03ef86 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sat, 4 Jul 2026 16:07:28 -0700 Subject: [PATCH 17/66] refactor(onlinetag): extract resume-param sanitizer to cut complexity AdminOnlineTagResumeView.post crossed radon's C threshold once the resume descriptor sanitization landed. Move that logic (sources tuple coercion + dropping keys no task field accepts) into a module-level helper; the view falls to rank B and reads more directly. No behavior change. Co-Authored-By: Claude Opus 4.8 --- codex/views/admin/onlinetag.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/codex/views/admin/onlinetag.py b/codex/views/admin/onlinetag.py index df1a914bd..c0b0bce32 100644 --- a/codex/views/admin/onlinetag.py +++ b/codex/views/admin/onlinetag.py @@ -130,6 +130,22 @@ def delete(self, _request, session_id): return Response({"detail": "Pause signal sent."}, status=HTTP_202_ACCEPTED) +def _resume_task_params(resume: dict) -> dict: + """ + Sanitize a stored resume descriptor into ``BulkOnlineTagTask`` kwargs. + + ``sources`` round-trips through JSON as a list; the task wants a tuple. A + resume descriptor persists to a file-based cache, so one written by an older + Codex may carry keys a task field no longer accepts (e.g. the removed + ``effort`` knob) — drop unknown keys so resuming across an upgrade rebuilds + the task instead of raising ``TypeError``. + """ + params = dict(resume.get("params") or {}) + params["sources"] = tuple(params.get("sources") or ()) + valid_fields = {f.name for f in fields(BulkOnlineTagTask)} + return {k: v for k, v in params.items() if k in valid_fields} + + class AdminOnlineTagResumeView(AdminAPIView): """Resume a paused/interrupted scan over the comics it never reached.""" @@ -145,20 +161,11 @@ def post(self, _request): if not resume or not remaining: return Response({"detail": "Nothing to resume."}, status=400) - params = dict(resume.get("params") or {}) - # sources round-trips through JSON as a list; the task wants a tuple. - params["sources"] = tuple(params.get("sources") or ()) - # A resume descriptor persists to a file-based cache, so one written by - # an older Codex may carry keys a task field no longer accepts (e.g. the - # removed ``effort`` knob). Drop unknown keys so resuming across an - # upgrade rebuilds the task instead of raising TypeError. - valid_fields = {f.name for f in fields(BulkOnlineTagTask)} - params = {k: v for k, v in params.items() if k in valid_fields} session_id = str(uuid.uuid4()) task = BulkOnlineTagTask( comic_pks=frozenset(remaining), session_id=session_id, - **params, + **_resume_task_params(resume), ) LIBRARIAN_QUEUE.put(task) return Response( From 227cea4f3381ffe361d7be0ac5099a5319838b41 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sat, 4 Jul 2026 16:27:04 -0700 Subject: [PATCH 18/66] fix(onlinetag): reference defined constant in match-mode hint matchModeHint read an undefined COMICVINE_CALLS_BY_MODE, throwing a ReferenceError on every launcher-dialog render (and failing tests/unit/launcher-dialog.test.js). Point at the real TAGGING_ESTIMATE.comicvineRequestsByMode map that callsForSource already uses. Co-Authored-By: Claude Opus 4.8 --- frontend/src/components/online-tag/launcher-dialog.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/online-tag/launcher-dialog.vue b/frontend/src/components/online-tag/launcher-dialog.vue index cae3bf656..bf27ef9ce 100644 --- a/frontend/src/components/online-tag/launcher-dialog.vue +++ b/frontend/src/components/online-tag/launcher-dialog.vue @@ -418,7 +418,7 @@ export default { if (!this.activeSources.includes("comicvine")) { return base; } - const requests = COMICVINE_CALLS_BY_MODE[this.matchMode]; + const requests = TAGGING_ESTIMATE.comicvineRequestsByMode[this.matchMode]; return requests ? `${base} ~${requests} Comic Vine requests/comic.` : base; From 97c62aab58cd4d4c4ce9cc48209a37199c3efbe0 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Mon, 6 Jul 2026 23:54:56 -0700 Subject: [PATCH 19/66] update deps --- bun.lock | 72 +++++++++-- frontend/bun.lock | 26 ++-- frontend/package.json | 4 +- package.json | 6 +- uv.lock | 270 ++++++++++++++++++++++-------------------- 5 files changed, 224 insertions(+), 154 deletions(-) diff --git a/bun.lock b/bun.lock index 841e1577f..d7db82ab8 100644 --- a/bun.lock +++ b/bun.lock @@ -25,17 +25,17 @@ "eslint-plugin-no-unsanitized": "^4.1.5", "eslint-plugin-no-use-extend-native": "^0.7.3", "eslint-plugin-package-json": "^1.5.0", - "eslint-plugin-perfectionist": "^5.9.1", + "eslint-plugin-perfectionist": "^5.10.0", "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-promise": "^7.3.0", "eslint-plugin-regexp": "^3.1.1", "eslint-plugin-security": "^4.0.1", "eslint-plugin-sonarjs": "^4.1.0", "eslint-plugin-toml": "^1.4.0", - "eslint-plugin-unicorn": "^70.0.0", + "eslint-plugin-unicorn": "^71.1.0", "eslint-plugin-vue": "^10.9.2", "eslint-plugin-vue-scoped-css": "^3.1.1", - "eslint-plugin-yml": "^3.5.0", + "eslint-plugin-yml": "^3.6.0", "eslint_d": "^15.0.2", "postcss-scss": "^4.0.9", "prettier": "^3.9.4", @@ -303,6 +303,8 @@ "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], + "convert-hrtime": ["convert-hrtime@5.0.0", "", {}, "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg=="], + "core-js-compat": ["core-js-compat@3.49.0", "", { "dependencies": { "browserslist": "^4.28.1" } }, "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA=="], "core_d": ["core_d@6.1.1", "", { "dependencies": { "supports-color": "^8.1.0" } }, "sha512-96Pba8AFV6AjBwOz0PxHXkKm4CzmrNA2Im8NlPCsi70OefzgXxw3VDuKIMC18SXV1GF2oyNfIdh3w+JaNkSCCA=="], @@ -399,7 +401,7 @@ "eslint-plugin-package-json": ["eslint-plugin-package-json@1.5.0", "", { "dependencies": { "@altano/repository-tools": "^2.0.1", "change-case": "^5.4.4", "detect-indent": "^7.0.2", "detect-newline": "^4.0.1", "eslint-fix-utils": "~0.4.1", "eslint-json-compat-utils": "^0.2.3", "jsonc-eslint-parser": "^3.1.0", "package-json-validator": "^1.5.0", "semver": "^7.7.3", "sort-object-keys": "^2.0.0", "sort-package-json": "^4.0.0" }, "peerDependencies": { "@eslint/json": ">=1.0.0", "eslint": ">=9.0.0" }, "optionalPeers": ["@eslint/json"] }, "sha512-KQkAdn+7I4vn1W4Va+V8M94RhcgZBvgrxMglDqgEhJgQDRUJ9RdtrEiHoe/84oVTIlp0jiPttLYwkExdvHYhZQ=="], - "eslint-plugin-perfectionist": ["eslint-plugin-perfectionist@5.9.1", "", { "dependencies": { "@typescript-eslint/utils": "^8.61.0", "natural-orderby": "^5.0.0" }, "peerDependencies": { "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" } }, "sha512-30mHLNfEhzwaq5cquyWgnzrNXvT8AzwIwyeH5aj4U5ajhHSF2uiO6i09xpMDLv7koaZVTjLsvYF4m3gK/15tyA=="], + "eslint-plugin-perfectionist": ["eslint-plugin-perfectionist@5.10.0", "", { "dependencies": { "@typescript-eslint/utils": "^8.62.1", "natural-orderby": "^5.0.0" }, "peerDependencies": { "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" } }, "sha512-HiqpDrUDbGrMC6iHQbemgDyHJ0366Vyz/qRWmxQcSAkmG25cXr8BdRgx8yAhOKhEfBXn8Rnf/mTCsV4EqUJSxg=="], "eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.6", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.13" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ=="], @@ -413,13 +415,13 @@ "eslint-plugin-toml": ["eslint-plugin-toml@1.4.0", "", { "dependencies": { "@eslint/core": "^1.0.1", "@eslint/plugin-kit": "^0.7.0", "@ota-meshi/ast-token-store": "^0.3.0", "debug": "^4.1.1", "toml-eslint-parser": "^1.0.1" }, "peerDependencies": { "eslint": ">=9.38.0" } }, "sha512-3ErTnfUjXq/23f72XeyRcE0Y4Sd/ME1lsZeezczqpn2R4tE7+Sgco/NUKDXm0xAMz15tzcRz/9RfJRm6AqRO+A=="], - "eslint-plugin-unicorn": ["eslint-plugin-unicorn@70.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "@eslint-community/eslint-utils": "^4.9.1", "browserslist": "^4.28.2", "change-case": "^5.4.4", "ci-info": "^4.4.0", "core-js-compat": "^3.49.0", "detect-indent": "^7.0.2", "find-up-simple": "^1.0.1", "globals": "^17.6.0", "indent-string": "^5.0.0", "is-builtin-module": "^5.0.0", "jsesc": "^3.1.0", "pluralize": "^8.0.0", "regjsparser": "^0.13.1", "semver": "^7.8.4", "strip-indent": "^4.1.1" }, "peerDependencies": { "eslint": ">=10.4" } }, "sha512-uAF9xMcVvvhTfvusCgogJ1wh4To3q2KhVMw3i1Apf/ILTbxsCjscvraAZACsEurb7no2fdXblD3whcbVnjw5zg=="], + "eslint-plugin-unicorn": ["eslint-plugin-unicorn@71.1.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "browserslist": "^4.28.4", "change-case": "^5.4.4", "ci-info": "^4.4.0", "core-js-compat": "^3.49.0", "detect-indent": "^7.0.2", "find-up-simple": "^1.0.1", "globals": "^17.7.0", "indent-string": "^5.0.0", "is-builtin-module": "^5.0.0", "is-identifier": "^1.1.0", "pluralize": "^8.0.0", "quote-js-string": "^0.1.0", "regjsparser": "^0.13.2", "reserved-identifiers": "^1.2.0", "semver": "^7.8.5", "strip-indent": "^4.1.1" }, "peerDependencies": { "eslint": ">=10.4" } }, "sha512-dn3YmR3qLLUeYyo/os3ubZ7UHQJ1WbBAgC9cIhnLTyMj9J6kivuc2U1fCmYetLexUlTDVYtBqhjSj/VaebTe6Q=="], "eslint-plugin-vue": ["eslint-plugin-vue@10.9.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", "nth-check": "^2.1.1", "postcss-selector-parser": "^7.1.0", "semver": "^7.6.3", "xml-name-validator": "^4.0.0" }, "peerDependencies": { "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "vue-eslint-parser": "^10.3.0" }, "optionalPeers": ["@stylistic/eslint-plugin", "@typescript-eslint/parser"] }, "sha512-4g7ZP3pYcuqd7Zp0pzUKcos0W+RkjBz4EGdhJ92FcYk6v03Ti/GK5NwjgsjxHK+98eXDbHeK7VtX1az7/8doZA=="], "eslint-plugin-vue-scoped-css": ["eslint-plugin-vue-scoped-css@3.1.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "es-toolkit": "^1.46.1", "postcss": "^8.4.31", "postcss-safe-parser": "^7.0.0", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "eslint": ">=9.38.0", "postcss-scss": "^4.0.3", "postcss-styl": "^0.12.0", "vue-eslint-parser": ">=7.1.0" }, "optionalPeers": ["postcss-scss", "postcss-styl"] }, "sha512-GIskMvLPnDtiu88rWXQHy2b2QZ4j959N5UgghML64jH0sg3Km+HRa9m7nkpcEBGLD4iA4vtMDbBIoLdFcbT8lQ=="], - "eslint-plugin-yml": ["eslint-plugin-yml@3.5.0", "", { "dependencies": { "@eslint/core": "^1.0.1", "@eslint/plugin-kit": "^0.7.0", "@ota-meshi/ast-token-store": "^0.3.0", "diff-sequences": "^29.0.0", "escape-string-regexp": "5.0.0", "natural-compare": "^1.4.0", "yaml-eslint-parser": "^2.0.0" }, "peerDependencies": { "eslint": ">=9.38.0" } }, "sha512-u2UkSIp/+th1wYCt0QWeCI6agf24dxX6PbFfCCN18gGQHmXh3Cn9D/U5OiP5RNYTEwjXCLusj1OJRK+zwdvFqQ=="], + "eslint-plugin-yml": ["eslint-plugin-yml@3.6.0", "", { "dependencies": { "@eslint/core": "^1.0.1", "@eslint/plugin-kit": "^0.7.0", "@ota-meshi/ast-token-store": "^0.3.0", "diff-sequences": "^29.0.0", "escape-string-regexp": "5.0.0", "natural-compare": "^1.4.0", "yaml-eslint-parser": "^2.0.0" }, "peerDependencies": { "eslint": ">=9.38.0" } }, "sha512-8lSqBIiOJO8ms1gktdlNaVGm3zXQJiYeKhQmtIG66XzU58kFDOjQae4sufQEMGHC4bP8VxiaH3tjU5PpDB6euw=="], "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], @@ -471,6 +473,8 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-timeout": ["function-timeout@1.0.2", "", {}, "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA=="], + "functional-red-black-tree": ["functional-red-black-tree@1.0.1", "", {}, "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g=="], "get-set-props": ["get-set-props@0.2.0", "", {}, "sha512-YCmOj+4YAeEB5Dd9jfp6ETdejMet4zSxXjNkgaa4npBEKRI9uDOGB5MmAdAgi2OoFGAKshYhCbmLq2DS03CgVA=="], @@ -491,6 +495,8 @@ "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + "identifier-regex": ["identifier-regex@1.1.0", "", { "dependencies": { "reserved-identifiers": "^1.0.0" } }, "sha512-SLX4H/vtcYlYnL7XqnuJKHU7Z8517TgsW9nmQiGOgMCjQ8V/deLYu6bEmbGoXe7WMMhc9+EUGyFFneHja8KabA=="], + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], @@ -527,6 +533,8 @@ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-identifier": ["is-identifier@1.1.0", "", { "dependencies": { "identifier-regex": "^1.1.0", "super-regex": "^1.1.0" } }, "sha512-NhOds0mDx9lJu+1lBRO0xbwFo5nobA7GCk/0e5xjr6+6XugX985+0OyGX35BNrTkPAsdLcIKg02HUQJOK8D8kw=="], + "is-js-type": ["is-js-type@3.0.0", "", { "dependencies": { "js-types": "^4.0.0" } }, "sha512-IbPf3g3vxm1D902xaBaYp2TUHiXZWwWRu5bM9hgKN9oAQcFaKALV6Gd13PGhXjKE5u2n8s1PhLhdke/E1fchxQ=="], "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], @@ -583,6 +591,8 @@ "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], + "make-asynchronous": ["make-asynchronous@1.1.0", "", { "dependencies": { "p-event": "^6.0.0", "type-fest": "^4.6.0", "web-worker": "^1.5.0" } }, "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg=="], + "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], @@ -735,10 +745,14 @@ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "p-event": ["p-event@6.0.1", "", { "dependencies": { "p-timeout": "^6.1.2" } }, "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "package-json-validator": ["package-json-validator@1.5.2", "", { "dependencies": { "npm-package-arg": "^13.0.2", "semver": "^7.7.2", "validate-npm-package-license": "^3.0.4", "validate-npm-package-name": "^7.0.0" } }, "sha512-eHXskJQU4aCiSfjhRfTVtCJ+22/EzLHgYgZv5Gj3teb3NJrnTMzq5BnKAWKvR+PLpknCO1PmOCImDuO+dX4Vaw=="], @@ -791,6 +805,8 @@ "quotation": ["quotation@2.0.3", "", {}, "sha512-yEc24TEgCFLXx7D4JHJJkK4JFVtatO8fziwUxY4nB/Jbea9o9CVS3gt22mA0W7rPYAGW2fWzYDSOtD94PwOyqA=="], + "quote-js-string": ["quote-js-string@0.1.0", "", {}, "sha512-Y3NoRtprEEZQD8RfxMCfS0ZTqc4e+i18OrXEXAvpM6TfC/3y+0L5rNbZiSnbBBEkDfFzbpd8o+cE8q3/anjMGA=="], + "read-package-json-fast": ["read-package-json-fast@3.0.2", "", { "dependencies": { "json-parse-even-better-errors": "^3.0.0", "npm-normalize-package-bin": "^3.0.0" } }, "sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -929,6 +945,8 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "reserved-identifiers": ["reserved-identifiers@1.2.0", "", {}, "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], @@ -981,6 +999,8 @@ "strip-indent": ["strip-indent@4.1.1", "", {}, "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA=="], + "super-regex": ["super-regex@1.1.0", "", { "dependencies": { "function-timeout": "^1.0.1", "make-asynchronous": "^1.0.1", "time-span": "^5.1.0" } }, "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ=="], + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], @@ -989,6 +1009,8 @@ "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + "time-span": ["time-span@5.1.0", "", { "dependencies": { "convert-hrtime": "^5.0.0" } }, "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -1063,6 +1085,8 @@ "walk-up-path": ["walk-up-path@3.0.1", "", {}, "sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA=="], + "web-worker": ["web-worker@1.5.0", "", {}, "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], @@ -1125,9 +1149,15 @@ "eslint-mdx/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + "eslint-plugin-perfectionist/@typescript-eslint/utils": ["@typescript-eslint/utils@8.63.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA=="], + "eslint-plugin-sonarjs/globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="], - "eslint-plugin-unicorn/globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="], + "eslint-plugin-unicorn/browserslist": ["browserslist@4.28.5", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", "electron-to-chromium": "^1.5.387", "node-releases": "^2.0.50", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ=="], + + "eslint-plugin-unicorn/globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="], + + "eslint-plugin-unicorn/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "eslint-plugin-yml/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -1139,6 +1169,8 @@ "jsonc-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "make-asynchronous/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], @@ -1193,6 +1225,20 @@ "eslint-mdx/espree/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0" } }, "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A=="], + + "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.63.0", "", {}, "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q=="], + + "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.63.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.63.0", "@typescript-eslint/tsconfig-utils": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q=="], + + "eslint-plugin-unicorn/browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="], + + "eslint-plugin-unicorn/browserslist/caniuse-lite": ["caniuse-lite@1.0.30001803", "", {}, "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg=="], + + "eslint-plugin-unicorn/browserslist/electron-to-chromium": ["electron-to-chromium@1.5.388", "", {}, "sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw=="], + + "eslint-plugin-unicorn/browserslist/node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], + "eslint_d/eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "eslint_d/eslint/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], @@ -1219,8 +1265,20 @@ "@npmcli/map-workspaces/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], + + "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.63.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.63.0", "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ=="], + + "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.63.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg=="], + + "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "npm-pick-manifest/npm-package-arg/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], } } diff --git a/frontend/bun.lock b/frontend/bun.lock index e8570a768..a0aa825e8 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -36,9 +36,9 @@ "vite": "^8.1.3", "vite-plugin-checker": "^0.14.4", "vite-plugin-dynamic-base": "^1.4.1", - "vite-plugin-run": "^0.9.0", + "vite-plugin-run": "^0.9.1", "vite-plugin-vuetify": "^2.1.3", - "vitest": "^4.1.9", + "vitest": "^4.1.10", "vue-eslint-parser": "^10.4.1", }, }, @@ -342,19 +342,19 @@ "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.7", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg=="], - "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], - "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], - "@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], - "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], - "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], @@ -880,11 +880,11 @@ "vite-plugin-dynamic-base": ["vite-plugin-dynamic-base@1.4.1", "", { "dependencies": { "@swc/core": "^1.3.61", "node-html-parser": "^5.3.3" }, "peerDependencies": { "vite": ">= 2.9.5" } }, "sha512-TvH5FNo8mp/jBZZN/agKnMIlf/OgFKr3vBPMniBAl8x5uFyIJzPQEMqIqwKQUxZUtWhVRWWBdW+9T/LU2Wg+Qg=="], - "vite-plugin-run": ["vite-plugin-run@0.9.0", "", { "dependencies": { "@antfu/utils": "^9.3.0", "debug": "^4.4.3", "es-toolkit": "^1.45.1", "minimatch": "^10.2.5", "picocolors": "^1.1.1", "tinyexec": "^1.1.1" }, "peerDependencies": { "vite": "^8.0.8" } }, "sha512-Y2jaKIPtnAiQu46LJ798JV5gHRBRzGocOBX/RspfRsDbHZts/oYsvx2vQNxFLqxd0k69j4lQokYZ8dKDAqiTgA=="], + "vite-plugin-run": ["vite-plugin-run@0.9.1", "", { "dependencies": { "@antfu/utils": "^9.3.0", "debug": "^4.4.3", "es-toolkit": "^1.45.1", "minimatch": "^10.2.5", "picocolors": "^1.1.1", "tinyexec": "^1.1.1" }, "peerDependencies": { "vite": "^8.0.8" } }, "sha512-0WtCNNwJM2iBbYcNfmQjHdQGluZuyKRIXM62MY+x3B4TVuCgA6pChMUFjzD1dxrQwgEl5f6duhY4ewweV7vQJw=="], "vite-plugin-vuetify": ["vite-plugin-vuetify@2.1.3", "", { "dependencies": { "@vuetify/loader-shared": "^2.1.2", "debug": "^4.3.3", "upath": "^2.0.1" }, "peerDependencies": { "vite": ">=5", "vue": "^3.0.0", "vuetify": ">=3" } }, "sha512-Q4SC/4TqbNvaZIFb9YsfBqkGlYHbJJJ6uU3CnRBZqLUF3s5eCMVZAaV4GkTbehIH/bhSj42lMXztOwc71u6rVw=="], - "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], @@ -1022,8 +1022,6 @@ "vite/rolldown": ["rolldown@1.1.3", "", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-x64-msvc": "1.1.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g=="], - "vitest/vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], - "vue/@vue/compiler-dom": ["@vue/compiler-dom@3.5.39", "", { "dependencies": { "@vue/compiler-core": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg=="], "vue/@vue/server-renderer": ["@vue/server-renderer@3.5.39", "", { "dependencies": { "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "vue": "3.5.39" } }, "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw=="], @@ -1148,8 +1146,6 @@ "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA=="], - "vitest/vite/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], - "vue-router/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.1.3", "", { "dependencies": { "@vue/devtools-shared": "^8.1.3", "birpc": "^2.6.1", "hookable": "^5.5.3", "perfect-debounce": "^2.0.0" } }, "sha512-cRn7GXiCQkMYU2Z3h3pM4YO/ndbx9FY1yLDAqIqPLcmIq4H6zAOJHein6tvZU3AfPwgrodqLiPBEF+YQaS8AxA=="], "vue/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.39", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw=="], diff --git a/frontend/package.json b/frontend/package.json index d11db2abf..fa4eae834 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,9 +47,9 @@ "vite": "^8.1.3", "vite-plugin-checker": "^0.14.4", "vite-plugin-dynamic-base": "^1.4.1", - "vite-plugin-run": "^0.9.0", + "vite-plugin-run": "^0.9.1", "vite-plugin-vuetify": "^2.1.3", - "vitest": "^4.1.9", + "vitest": "^4.1.10", "vue-eslint-parser": "^10.4.1" } } diff --git a/package.json b/package.json index 5fd1e49ec..6c296d239 100644 --- a/package.json +++ b/package.json @@ -80,17 +80,17 @@ "eslint-plugin-no-unsanitized": "^4.1.5", "eslint-plugin-no-use-extend-native": "^0.7.3", "eslint-plugin-package-json": "^1.5.0", - "eslint-plugin-perfectionist": "^5.9.1", + "eslint-plugin-perfectionist": "^5.10.0", "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-promise": "^7.3.0", "eslint-plugin-regexp": "^3.1.1", "eslint-plugin-security": "^4.0.1", "eslint-plugin-sonarjs": "^4.1.0", "eslint-plugin-toml": "^1.4.0", - "eslint-plugin-unicorn": "^70.0.0", + "eslint-plugin-unicorn": "^71.1.0", "eslint-plugin-vue": "^10.9.2", "eslint-plugin-vue-scoped-css": "^3.1.1", - "eslint-plugin-yml": "^3.5.0", + "eslint-plugin-yml": "^3.6.0", "postcss-scss": "^4.0.9", "prettier": "^3.9.4", "prettier-plugin-nginx": "^1.0.3", diff --git a/uv.lock b/uv.lock index d62cd7156..cd2dfa415 100644 --- a/uv.lock +++ b/uv.lock @@ -387,59 +387,87 @@ wheels = [ [[package]] name = "cffi" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, ] [[package]] @@ -457,75 +485,63 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +version = "3.4.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/56/10a88e00039537d74bd420f0457c52ab8f58a1af56126e3b9f1b1c8c4724/charset_normalizer-3.4.8.tar.gz", hash = "sha256:d9bf144d6faf12c70d58e47f7512992ae2882b820031d6cef68152deb645bf2d", size = 151790, upload-time = "2026-07-06T15:27:58.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/c2/39de60ef5687662f467bed3d1e6944c67a4f0d057141d0404002b8f405ae/charset_normalizer-3.4.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:faac37c4904598daa00cb4c9b32f3b4cc814fb5f145d7a531ceb4a70f2114132", size = 319040, upload-time = "2026-07-06T15:26:15.854Z" }, + { url = "https://files.pythonhosted.org/packages/a7/57/a9474c3aeaa337c8a330c0dc5df266527d56da3b189c029529f6b08af2a4/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f191c19a32dc6cec0fb8079789d786254653a9ce906fcab04ccd2eed07bba233", size = 215541, upload-time = "2026-07-06T15:26:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/13/a9/be1ff7e81f6e086dced2a7a7a28b789be351d9796084ccaf6136a4ffafb3/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05811b76943d477bb90822dedb5c4565cef70148847a59d574e2b35043aeb563", size = 236913, upload-time = "2026-07-06T15:26:18.482Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/d8c5eae93da26d463f9ebe46a4937ca44434dc2937a565b92437befb3d94/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3868a3e4ec1e40b419e060d063f93eac6f046fa21426c4816421223ae7dc8ab8", size = 232815, upload-time = "2026-07-06T15:26:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/98e301ca944bcca5e6bc312406b579c8a6d81546c1b494afb3a9478495d6/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25f93d194eb6264c64416cabff46a91f6d99b97e7525a1b4f35c77a99e75cc68", size = 223995, upload-time = "2026-07-06T15:26:20.931Z" }, + { url = "https://files.pythonhosted.org/packages/fa/df/f5222366b76dcb31453a9bd922610c893540d0e729fd390439b0d3e972ee/charset_normalizer-3.4.8-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:ee6a62492f18d432cca031fabd158f400a8c25bf7b9458f50953393a2a23d97a", size = 208522, upload-time = "2026-07-06T15:26:22.214Z" }, + { url = "https://files.pythonhosted.org/packages/74/52/293220d59d8ddfb8aa56836b33bd6df58e70795d8a102a858c2984480f00/charset_normalizer-3.4.8-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c16cb4fc35e4b064f5ee78d849f15a550ada1729c3372916672e38f1f01d1d4", size = 219660, upload-time = "2026-07-06T15:26:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/c3/2c/81a298e66f3d01e61bfc6f7064bbb553b067a9f1d979e5962bf00733069b/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2fbd0edb0426ab28e70fac9d1d4ef549eb5a64a2521f0428c441d75e4387e6c2", size = 218230, upload-time = "2026-07-06T15:26:24.629Z" }, + { url = "https://files.pythonhosted.org/packages/0d/45/f1dd2328cbc3340705f82072c09bd4c68d6e079191cde05810c1eac77eee/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccb9052771216170015f810b88065fd9e13b1e0b391f92abb9b47e0919a42aad", size = 210006, upload-time = "2026-07-06T15:26:25.837Z" }, + { url = "https://files.pythonhosted.org/packages/38/63/28697000620e117eb413424caaf60b6f98ddb1b09b2c11f7c0038d9936a7/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3809ba5d3cd02aca0894597f2669a825bdfe2229061515c128b0f4e5533b4ab5", size = 225771, upload-time = "2026-07-06T15:26:27.079Z" }, + { url = "https://files.pythonhosted.org/packages/12/a7/d5844e315f5f35e7938c415f07a1df144eed1cf993f1b43cc16c980c5b46/charset_normalizer-3.4.8-cp312-cp312-win32.whl", hash = "sha256:de63c31666a049f653ada24e800192e3c019e96bc7d70fb449a000bccf26a36f", size = 150922, upload-time = "2026-07-06T15:26:28.514Z" }, + { url = "https://files.pythonhosted.org/packages/9b/82/eb8b72f184b1e4986dd9daec15d7f6d9285a6728d2b07b7f04656829f473/charset_normalizer-3.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:14a4bbe066f3fb05c6ba70e9cf9d34614b57a2fd70ea8c27cc30f34155e16a58", size = 162294, upload-time = "2026-07-06T15:26:29.745Z" }, + { url = "https://files.pythonhosted.org/packages/09/5a/ab810134aa41034a08ffe94c058102016e6ad9bce62f3cdba547b4723385/charset_normalizer-3.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:2b5b0c0dca0a02c3f816f89abf18af3d20416dedbc3d3aa5f3981045f88ae7b0", size = 152409, upload-time = "2026-07-06T15:26:31.034Z" }, + { url = "https://files.pythonhosted.org/packages/11/49/fe5a8572a70cd9cba79f80af9388ac8c5c914ed4459b956f940244e499a5/charset_normalizer-3.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:057f8609f7341618c98e5aa9a6109fa116acff2a658497d47ab3325b5e8f2b08", size = 317424, upload-time = "2026-07-06T15:26:32.23Z" }, + { url = "https://files.pythonhosted.org/packages/e5/59/d71c96616b6825425a876f79f38fa440db30b32cc1166179a839f6259150/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c591f9a82adc5b89a039b90df74e43de2b9177fb46771172bed7b80722a70db0", size = 214723, upload-time = "2026-07-06T15:26:33.635Z" }, + { url = "https://files.pythonhosted.org/packages/c6/35/dc9eeb297f19b7b6ada39709ccb74937e6c51f0947958ae601a977cedd5d/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b56f449132d9adefe55b87635d05177a914ed5d070438a74725e1d77a280002", size = 236200, upload-time = "2026-07-06T15:26:34.99Z" }, + { url = "https://files.pythonhosted.org/packages/0a/37/6775fe852b4acad8bf7e0575fbe8aa9f41b546e33251acbded3c04a6b0d9/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1004c3b5831a301dadfb9e916f38e78e2ff3e08db24a1ad7c354db8ee3dea9c3", size = 231740, upload-time = "2026-07-06T15:26:36.498Z" }, + { url = "https://files.pythonhosted.org/packages/e4/28/1bcc3f5f3bac81532384adcfcdd9362c7f46a188a19deacc1ddaf7bdaa00/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60883e22821d17c9e5b4f3ca1ef8074f766e3db28791f851b665929c515635c0", size = 222888, upload-time = "2026-07-06T15:26:38.161Z" }, + { url = "https://files.pythonhosted.org/packages/47/81/9f3993ca62ef090c58059da641e49e3129e74700a6a3beb58436cdb8d4b9/charset_normalizer-3.4.8-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:21e4dbb942c8a6342e2685f232dd2a7bc73465697bd26ead4f118271d28be383", size = 207649, upload-time = "2026-07-06T15:26:39.628Z" }, + { url = "https://files.pythonhosted.org/packages/11/f4/679f636bcbdc2d53d06b1f4039be310450dca95a9f76bbf22f09985556e8/charset_normalizer-3.4.8-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84bcae14c65e645ca66b661339183d32b8c846a17c96e3e81ab3d346e1c498d4", size = 218908, upload-time = "2026-07-06T15:26:40.911Z" }, + { url = "https://files.pythonhosted.org/packages/7e/44/96e8c81867ba8a45ff893c8e7474c2d6b9633f7aa663da7901d040214d3e/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b4e4d44b8287aa13a25e16e29393d494b0643b24894f7c8266c6f6788dd36337", size = 217096, upload-time = "2026-07-06T15:26:42.148Z" }, + { url = "https://files.pythonhosted.org/packages/a5/bf/4d53f04f29bdb22601701f4f9f4d038edfb27976c296fcb7400c02736a6e/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:f68545d1b267dbfafd5d253b6d1cb161562c4e61ab25b5c4cdb7d9e5923e441e", size = 209355, upload-time = "2026-07-06T15:26:43.557Z" }, + { url = "https://files.pythonhosted.org/packages/ad/60/92b3f630798d777fa880ad289a3f9f2fc663e4b4beb24783c53318820254/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1936e48214adea74922a20c8ab41b1393ae27cc9e329eb1f0b937d3416824f36", size = 224732, upload-time = "2026-07-06T15:26:44.892Z" }, + { url = "https://files.pythonhosted.org/packages/bd/85/eafa0a3c7bb6fe9f02f4c7901f02071933cac85ee634197e17280818c6de/charset_normalizer-3.4.8-cp313-cp313-win32.whl", hash = "sha256:1f8e3521860187d597f3867d8466da225b9179ea2833bb26de1bb026144d07c3", size = 150358, upload-time = "2026-07-06T15:26:46.153Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8c/879fafff7b47bb1166d289f2d2472cb31b9922f9f4ca1f392edf85ec16be/charset_normalizer-3.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:8b654b6f52a0a9a6be38e88f3e1dc68f1093ebeb2abbadafc7c82da0786a34be", size = 161685, upload-time = "2026-07-06T15:26:47.423Z" }, + { url = "https://files.pythonhosted.org/packages/c3/46/b57c7e778a7b578f28d35fd38544687d4f8d9c019585eebc5ad936073fad/charset_normalizer-3.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:d2d5a250ee26e29468b7607d97479221b069fa8aaf6f929ac84ec0e962e15154", size = 152333, upload-time = "2026-07-06T15:26:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/0a8540b8cd494951cca1428606373942803f5ffcec40fe798f819c5a8adb/charset_normalizer-3.4.8-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:77e993ecf65f21ab1f82266ff5e84a7de2c879e7d9b8bc006009df83f22a1d5e", size = 316993, upload-time = "2026-07-06T15:26:49.962Z" }, + { url = "https://files.pythonhosted.org/packages/0e/99/a0868f0a1f0a045fd374d1f2cf7042d8ad5d7fb4dd1f4ac7365e319f7e32/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:524939917f17f6de502dfda30b472550965740d7f126659d4c4f8dd1569cce22", size = 215638, upload-time = "2026-07-06T15:26:51.338Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e9/43c4d09a09b5557cc5fe1d87c9d96f86a3942aec0517d2b5408cef87ca75/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a4508989ba8e2ce43ef989453d18188b261546e8188cbdd4ef451fb9e4c3b467", size = 236456, upload-time = "2026-07-06T15:26:52.531Z" }, + { url = "https://files.pythonhosted.org/packages/e2/67/492ca98b3ab785b736b5da10c1bc233e1c8fec6c0cdb29b482c38bfc52a2/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9e44127f7d11eee4548ad2cdf1f4e1b6eaaddd5cb92d15ad65f6ecc9bcf403ab", size = 232253, upload-time = "2026-07-06T15:26:53.838Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/1e6eff58c14f1aace1e26d80defbeaea2d35e075dbe4b611111ee4b47fa8/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb90317359f7e67bb6df615999a95e0980877468e617ddce8b6c2f8e7fe60d95", size = 222886, upload-time = "2026-07-06T15:26:55.009Z" }, + { url = "https://files.pythonhosted.org/packages/40/7a/90056a5326b0c4b9a3f924d337729c344c11542e5bc7191e50410db61587/charset_normalizer-3.4.8-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:35d9e7a9c960520ae89d1f4e305d1c047a74dea2e0f73a0e84f879356c2e8776", size = 206482, upload-time = "2026-07-06T15:26:56.306Z" }, + { url = "https://files.pythonhosted.org/packages/18/ff/94761d31a33878dbb5008ddbd918615061fcf5c0a612aa3075450e60f628/charset_normalizer-3.4.8-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e322b054c7ff886f78feab7360736bb45de2e18cf4a0ee84e8fc5a08d53a19", size = 218929, upload-time = "2026-07-06T15:26:57.422Z" }, + { url = "https://files.pythonhosted.org/packages/2b/dc/00b9675acd7c4b926b9102ee3f0d1a570ce943901be73b87485001393fe1/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c0086d97094363556206dc3bcf43f7edcfc043ea7a568a46f45efea74858bd1", size = 218069, upload-time = "2026-07-06T15:26:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/04/11/94ada5a0482ee4bf688d04be4c7d6fd945d37370d04a95671040dfe2b416/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0752c849b51198267df2aba013c4de3a2955bd014a4fd70828809946c1acbc0c", size = 207146, upload-time = "2026-07-06T15:27:00.058Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f7/246bd36762207ab4752cd436b64e5d81a1668b15ddea7b5b2d0e8545e727/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2a4707e09eca11e81ece4fced600c5a0a801f568b962244f6f517bc274745fc9", size = 224896, upload-time = "2026-07-06T15:27:01.599Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f7/3510622d1fbe13b0ebf827c475e40a27e2be427140d792878b63ab6425cc/charset_normalizer-3.4.8-cp314-cp314-win32.whl", hash = "sha256:8ea67f427c073ae3da0923aa55f3715131fa613a61a7f2f8d762bde75eaf00ae", size = 150851, upload-time = "2026-07-06T15:27:02.964Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/9ce65dd21672b55cf800cca5f4433afa1586fda1d78731067ec9ec544c62/charset_normalizer-3.4.8-cp314-cp314-win_amd64.whl", hash = "sha256:ff71018850863362e5c7533769d0a9f77715c31af1502d523630ce822922f5c9", size = 162549, upload-time = "2026-07-06T15:27:04.249Z" }, + { url = "https://files.pythonhosted.org/packages/2f/34/9a5967eed666a88f31a0866884606d9ec3c2cd6091e2ccd7e0b4c4176c35/charset_normalizer-3.4.8-cp314-cp314-win_arm64.whl", hash = "sha256:44464e66f4da2f21dea7145c7693f9f60717ca4794a954dea5bf8c2c932678bd", size = 153079, upload-time = "2026-07-06T15:27:05.608Z" }, + { url = "https://files.pythonhosted.org/packages/02/4f/aa44cc81d8987f105352c74c0bf919007f8b80e9880d28bcf0393c1a816e/charset_normalizer-3.4.8-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:50a0c2e58ad2c203adb616fef28941b7e13716adbc25e0dfaeec29f5afe6382f", size = 338586, upload-time = "2026-07-06T15:27:06.86Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2b/b0392e2b235c08ff0623d905c2ee8ac820620544043c1ce92ce0b3d64c55/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1e589fdb95c76f08288bbb346230cdd8994db74903db6637b380f7b5fc9336", size = 222764, upload-time = "2026-07-06T15:27:08.23Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a1/7d466879190731f5559662c22232646f2ae2dace2323c3e5aefcf78d458a/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3d7c887444c5a7ef0d68d358d81e758a850bc626f8e639e2ca5667153272b20", size = 241331, upload-time = "2026-07-06T15:27:09.512Z" }, + { url = "https://files.pythonhosted.org/packages/70/17/8b89e797137aa28c8fb0bafbafc243246a7afe21620a13b00e37624ece1d/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:65c389b96c0cfff3a3f0458fa1c7ce554a30e23101a88a49f03997afce6a929f", size = 239323, upload-time = "2026-07-06T15:27:10.86Z" }, + { url = "https://files.pythonhosted.org/packages/d7/98/1c1940730ed22d50983be4e243c722c89d5136d6f073bd840d1128bfddcb/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:593403fc47dcdf55e2987b2e3cc2e064127e2b908929f1f18b2e4a4652cbd780", size = 229964, upload-time = "2026-07-06T15:27:12.113Z" }, + { url = "https://files.pythonhosted.org/packages/53/2d/bb8e81b7ff603d3f77e9a8a5d1ad34fcabbf3c54d300c29d99fba581fa23/charset_normalizer-3.4.8-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:606088e9fa2b7469ab9c42d4da8e05a415622a07714edd2fcd8fed48dda4c853", size = 212405, upload-time = "2026-07-06T15:27:13.447Z" }, + { url = "https://files.pythonhosted.org/packages/ce/1f/e52a3a53b13da591bb8f21d29e63877268eadf20686b7762351d4b89062c/charset_normalizer-3.4.8-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0317406326fed512f42a1632ad91a96228a7616c06547666a6dd79967f1bd6ca", size = 226918, upload-time = "2026-07-06T15:27:14.89Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f9/32996d79c57189af9722fe618f46d8a86b7be035ca98887b8d0c3821f141/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b67d50ee47e5c57a0064a9cb575b963a7125819dfd1fd094d44d378fff94659b", size = 225113, upload-time = "2026-07-06T15:27:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d2/9248c18e695696513774523a794cfb8b677521ce9ad7554d301cb10a9b20/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79e402b869f270140afa5e2b0e2ac100585358d812fe3dd093d424f7a72964e0", size = 214966, upload-time = "2026-07-06T15:27:17.418Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9d/4b19432d406179a40f924691906ee5b15ac664b408971c973295192444ea/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2970b9f7ab69ec3a0423ec6b6ac718e79fbf4a282c0bc103ef88c1ef50dfa15a", size = 231699, upload-time = "2026-07-06T15:27:19.131Z" }, + { url = "https://files.pythonhosted.org/packages/be/41/bdbdf71e8c3ccff10ef3cc2bb9467a7fdb3dc94b9a406d1a3c44afd39632/charset_normalizer-3.4.8-cp314-cp314t-win32.whl", hash = "sha256:458c2972a78043b7261c9726670029f15f722e70669bcbe961153a01968f589f", size = 155333, upload-time = "2026-07-06T15:27:20.681Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f8/e05c69323bd50091ec39f5f885385b884624b0131a6885a0c83a6217ba7a/charset_normalizer-3.4.8-cp314-cp314t-win_amd64.whl", hash = "sha256:0c926329a1df7cd56d7d8349fe354460d20aefd2e394c9e159e479d018b2b359", size = 167378, upload-time = "2026-07-06T15:27:22.042Z" }, + { url = "https://files.pythonhosted.org/packages/c2/04/cbaf1a2f5e2bbf70760e774380cbf052b10849fc35e770905df31af5cf00/charset_normalizer-3.4.8-cp314-cp314t-win_arm64.whl", hash = "sha256:2232baea80a2b01783679fed4e625ccdb19a974f44c9cf0fba21a777a4c8179c", size = 157782, upload-time = "2026-07-06T15:27:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/23/52/d5bee5b6ea81882d549b566d2545b044bbcbc33fe5fbe001008a7e745a21/charset_normalizer-3.4.8-py3-none-any.whl", hash = "sha256:b7c1fb310df524e01fbe84d43b7f95aa4f808f8eaa0dafc185f64ba395e37d54", size = 64279, upload-time = "2026-07-06T15:27:57.043Z" }, ] [[package]] @@ -1242,7 +1258,7 @@ wheels = [ [[package]] name = "drf-spectacular" -version = "0.29.0" +version = "0.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, @@ -1252,9 +1268,9 @@ dependencies = [ { name = "pyyaml" }, { name = "uritemplate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/0e/a4f50d83e76cbe797eda88fc0083c8ca970cfa362b5586359ef06ec6f70a/drf_spectacular-0.29.0.tar.gz", hash = "sha256:0a069339ea390ce7f14a75e8b5af4a0860a46e833fd4af027411a3e94fc1a0cc", size = 241722, upload-time = "2025-11-02T03:40:26.348Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/43/41d25039a6a53545420ebc98eb9f877ec9fe30c7bd03fefabcaf9b953af7/drf_spectacular-0.30.0.tar.gz", hash = "sha256:53e79e7ba00e240441b63c32273754a5368e4c2ab44a19f2595277cc1cd559c9", size = 252311, upload-time = "2026-07-06T11:29:46.264Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/d9/502c56fc3ca960075d00956283f1c44e8cafe433dada03f9ed2821f3073b/drf_spectacular-0.29.0-py3-none-any.whl", hash = "sha256:d1ee7c9535d89848affb4427347f7c4a22c5d22530b8842ef133d7b72e19b41a", size = 105433, upload-time = "2025-11-02T03:40:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/c3/56/74dd7b45bbde6d24494220b98d6961cb1200b63a1800332b430daa2c4551/drf_spectacular-0.30.0-py3-none-any.whl", hash = "sha256:006cf5921ebe20a9bd24f7c846261ebbf78780be5961b0d6e87afaa82afd62ff", size = 111150, upload-time = "2026-07-06T11:29:45.12Z" }, ] [[package]] @@ -2439,7 +2455,7 @@ wheels = [ [[package]] name = "picopt" -version = "6.5.2" +version = "6.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "confuse" }, @@ -2461,9 +2477,9 @@ dependencies = [ { name = "treestamps" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/d7/ea8bc07909aee3b1ad49f5102d036e4635e12c71346820c8523d3cf4fb44/picopt-6.5.2.tar.gz", hash = "sha256:e1a5ef09fee16dff008551a61adb322a3ad478a0dfb4d5c0183c2eab553601fd", size = 10321774, upload-time = "2026-05-28T23:03:41.999Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/df/011852b57fb5a0568daf6ebc8ad80ff56c24dcec0ae26b899931ce954fce/picopt-6.6.1.tar.gz", hash = "sha256:e3733489a5d8eef3a824a1daa5717a50ee9fa4f7a6f94f7f272b07171e098f02", size = 10372187, upload-time = "2026-07-06T17:21:49.44Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/88/816fe6e0b2f9c83999e3debd854d98bca2836d2490c7b1270442216f0652/picopt-6.5.2-py3-none-any.whl", hash = "sha256:6dda424bd9d75ba1f769803fd81c5f6e0a15181a6252844db87b392c90d5c9c3", size = 108108, upload-time = "2026-05-28T23:03:40.678Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/a72ad0c0d950a6a816d7fa0237d9be0c2387b2a835e8126c56df69fbff09/picopt-6.6.1-py3-none-any.whl", hash = "sha256:26ce77bdabd4c83fea75dbf6279a58abfc8ca4df92ee43d1d0798314cc5f2d2c", size = 131744, upload-time = "2026-07-06T17:21:47.823Z" }, ] [[package]] @@ -3953,15 +3969,15 @@ wheels = [ [[package]] name = "treestamps" -version = "4.0.2" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ruamel-yaml" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/a1/02de5cb2900dbaefed89971b3c5bc61a9ee93c13d68725b61022e0d575c8/treestamps-4.0.2.tar.gz", hash = "sha256:7ab0ae03b25ddcf45011cc0e589b9137baeb2e284f0b194f5cf7a299a8baa9bb", size = 181276, upload-time = "2026-04-29T09:52:32.935Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/87/31328b19121029e6db528aa4c70f992f1dd5ccc74435bb2e22874970f64d/treestamps-4.1.0.tar.gz", hash = "sha256:159652cd3a2f6365e3d7c5021defb66104ba89148c0cdd87e7ec14f713870cb3", size = 210180, upload-time = "2026-07-05T19:46:29.661Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/10/d467b3fc53990fb3f86a5cffd0a290d717fa076442557b585af882a2c716/treestamps-4.0.2-py3-none-any.whl", hash = "sha256:53146741ace2a8df626d2f305cfa3aac8c7719e4af26a6dcb9aa19ba270aecb1", size = 18358, upload-time = "2026-04-29T09:52:34.07Z" }, + { url = "https://files.pythonhosted.org/packages/3d/cb/508cbbb17a23ad35cea3fe2b86c8b1811d0c37fd7476c1e3892e79025be5/treestamps-4.1.0-py3-none-any.whl", hash = "sha256:f7323de89fad48030989cee9939b809c5ede15c5a20d5153c157f8d008231494", size = 32155, upload-time = "2026-07-05T19:46:28.62Z" }, ] [[package]] From f7abf7d7ae73cfdf06e94c85d798145352ed4875 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sat, 11 Jul 2026 00:45:49 -0700 Subject: [PATCH 20/66] fix(settings): nest comicbox loglevel/delete_keys under general section An un-nested overlay is silently ignored by confuse. --- codex/settings/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/codex/settings/__init__.py b/codex/settings/__init__.py index f3c82a861..0186eecb2 100644 --- a/codex/settings/__init__.py +++ b/codex/settings/__init__.py @@ -1118,11 +1118,15 @@ def _get_middleware(features: FeatureFlags) -> tuple[str, ...]: # ``delete_keys`` is typed as ``Sequence(str)`` in the comicbox confuse # template, so feed it a sorted tuple rather than the source frozenset. +# Both keys live under the ``general`` section of the comicbox config +# tree; an un-nested overlay is silently ignored by confuse. COMICBOX_CONFIG: ComicboxSettings = get_config( { "comicbox": { - "loglevel": LOGLEVEL, - "delete_keys": tuple(sorted(_COMICBOX_DELETE_KEYS)), + "general": { + "loglevel": LOGLEVEL, + "delete_keys": tuple(sorted(_COMICBOX_DELETE_KEYS)), + } } } ) From da257b06f8f9695ea838f489f2263a39bbe766e1 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sat, 11 Jul 2026 00:53:15 -0700 Subject: [PATCH 21/66] fix(onlinetag): resolve prompts against current DB path, surface apply failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answering a deferred prompt fetched the chosen issue against the path serialized into the prompt at scan time. When an earlier write for the same comic ran with rename enabled (the comic's other source's prompt, or a stored-id prefetch), that path was stale and the apply died with an uncaught FileNotFoundError — after the prompt was already consumed, so the admin's pick vanished with no feedback. - _apply_resolution now re-reads the comic's path from the DB by pk; a missing row reports to the Tagging error panel instead of fetching a dead path. - fetch/replay failures (ComicboxError, OSError) and non-resolving explicit ids now land on the Tagging error panel instead of only the log, since the pick can no longer be re-prompted. - stored-id prefetch and tag_by_id also catch OSError so a vanished file degrades gracefully. - regression test for the COMICBOX_CONFIG general-section overlay (un-nested loglevel/delete_keys were silently ignored, letting comicfn2dict remainders like "(0000)" leak into rename targets). Co-Authored-By: Claude Fable 5 --- codex/librarian/onlinetag/session_manager.py | 76 +++++++++++-- tests/test_onlinetag_session_manager.py | 106 +++++++++++++++++++ tests/test_settings_comicbox_config.py | 28 +++++ 3 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 tests/test_settings_comicbox_config.py diff --git a/codex/librarian/onlinetag/session_manager.py b/codex/librarian/onlinetag/session_manager.py index a45df5a3d..12d600856 100644 --- a/codex/librarian/onlinetag/session_manager.py +++ b/codex/librarian/onlinetag/session_manager.py @@ -163,7 +163,7 @@ def tag_by_id(self, task: OnlineTagByIdTask) -> None: credentials, extra_ids=task.extra_ids, ) - except ComicboxError as exc: + except (ComicboxError, OSError) as exc: msg = f"Fetching {task.source} issue {task.issue_id} failed: {exc}" self.log.warning(f"Online tag by id: {msg} ({path})") add_tag_write_error(str(path), msg) @@ -473,7 +473,7 @@ def _fetch_stored_id( tags = fetch_tags_by_explicit_id( path, primary_source, primary_id, credentials, extra_ids=extra_ids ) - except ComicboxError as exc: + except (ComicboxError, OSError) as exc: self.log.warning(f"Online tag stored-id prefetch failed for {path}: {exc}") return None return (primary_source, tags) if tags else None @@ -756,6 +756,32 @@ def _explicit_resolution( return "manual", f"{source}:{issue_id}" return action, payload + def _report_apply_failure(self, path_str: str, msg: str) -> None: + """Log + surface a failed prompt apply in the admin Tagging error panel.""" + self.log.warning(f"Online tag: {msg} ({path_str})") + add_tag_write_error(path_str, msg) + self.librarian_queue.put(TAG_WRITE_ERRORS_CHANGED_TASK) + + def _refresh_comic_path(self, pk: int, prompt_path: str) -> str: + """ + Return the comic's current path, preferring the DB row over the prompt. + + A prompt's serialized path goes stale between deferral and answer — + most commonly when an earlier write for the same comic ran with + rename enabled and moved the file. The DB row follows the rename + (via the write's move import), so it is the fresher reference. A + missing row means the comic was deleted (or re-imported under a new + pk after a rename the DB never synced); surface that instead of + fetching against a dead path. + """ + comic = Comic.objects.filter(pk=pk).only("path").first() + if comic: + return comic.path + self._report_apply_failure( + prompt_path, "comic no longer in the database; answer not applied" + ) + return "" + def _apply_resolution( self, prompt: dict[str, Any], @@ -765,11 +791,14 @@ def _apply_resolution( ) -> None: """Build a fresh session, apply the chosen match, and enqueue a write.""" pk = prompt.get("pk") - path_str = prompt.get("path") or "" + prompt_path = prompt.get("path") or "" source = prompt.get("source") or "" - if pk is None or not path_str or not source: + if pk is None or not prompt_path or not source: self.log.warning("Online tag: prompt missing comic reference; skipping.") return + path_str = self._refresh_comic_path(pk, prompt_path) + if not path_str: + return credentials = self._build_credentials() if not credentials: self.log.warning("Online tag: no credentials for prompt resolution.") @@ -784,7 +813,21 @@ def _apply_resolution( explicit = self._explicit_issue_id(action, payload, source) if explicit is not None: self._apply_explicit_id(prompt, pk, explicit, path_str, credentials) - return + else: + resolution = (action, payload, chosen_volume_id) + self._apply_replayed_search(prompt, pk, path_str, credentials, resolution) + + def _apply_replayed_search( + self, + prompt: dict[str, Any], + pk: int, + path_str: str, + credentials: OnlineCredentials, + resolution: tuple[str, Any, int | None], + ) -> None: + """Re-search and apply a pick that carries no explicit issue id.""" + action, payload, chosen_volume_id = resolution + source = prompt.get("source") or "" # defer_prompts on: the bridged selector consults the preloaded # resolution; without it (or a handler) an ambiguous re-search would # fall through to comicbox's interactive CLI prompt inside the daemon. @@ -800,7 +843,13 @@ def _apply_resolution( payload=payload, chosen_volume_id=chosen_volume_id, ) - tags = self._first_tags(session, Path(path_str)) + try: + tags = self._first_tags(session, Path(path_str)) + except (ComicboxError, OSError) as exc: + self._report_apply_failure( + path_str, f"re-applying chosen match failed: {exc}" + ) + return if not tags: self._handle_unresolved(prompt, path_str, session) return @@ -832,14 +881,23 @@ def _apply_explicit_id( ) -> None: """Fetch the picked issue by id and enqueue its write (no re-search).""" src, issue_id = explicit - tags = fetch_tags_by_explicit_id(Path(path_str), src, issue_id, credentials) + try: + tags = fetch_tags_by_explicit_id(Path(path_str), src, issue_id, credentials) + except (ComicboxError, OSError) as exc: + # The prompt is already consumed, so a swallowed failure would + # silently discard the admin's pick — put it on the error panel. + self._report_apply_failure( + path_str, f"fetching chosen issue {src}:{issue_id} failed: {exc}" + ) + return if tags: self._enqueue_resolved_write(prompt, pk, tags, path_str) else: # The id itself didn't resolve (wrong/unknown issue). Don't re-queue # a fresh ambiguous prompt — the admin made an explicit choice. - msg = f"chosen issue {src}:{issue_id} did not resolve for {path_str}" - self.log.warning(f"Online tag: {msg}.") + self._report_apply_failure( + path_str, f"chosen issue {src}:{issue_id} did not resolve" + ) def _handle_unresolved( self, prompt: dict[str, Any], path_str: str, session: OnlineSession diff --git a/tests/test_onlinetag_session_manager.py b/tests/test_onlinetag_session_manager.py index 7642242cf..e589a4450 100644 --- a/tests/test_onlinetag_session_manager.py +++ b/tests/test_onlinetag_session_manager.py @@ -37,6 +37,7 @@ BulkOnlineTagTask, OnlineTagPromptResponseTask, ) +from codex.librarian.scribe.tagwrite_errors import get_tag_write_errors from codex.librarian.scribe.tasks import BulkTagWriteTask from codex.models import ( Comic, @@ -466,6 +467,111 @@ def test_resolve_choose_unresolved_id_does_not_write_or_requeue(self) -> None: assert not [i for i in self.queue.items if isinstance(i, BulkTagWriteTask)] # The explicit choice is honored: no worse fresh prompt is re-queued. assert get_pending_prompts() == {} + # The consumed-but-unapplied pick surfaces on the admin error panel. + errors = get_tag_write_errors() + assert len(errors) == 1 + assert "did not resolve" in errors[0]["error"] + + def test_resolve_fetches_against_current_db_path_not_prompt_snapshot(self) -> None: + """ + A pick applies against the comic's current DB path, not the prompt's. + + The serialized prompt path goes stale when an earlier write for the + same comic ran with rename enabled (e.g. the comic's other source's + prompt was answered first) — the DB row follows the rename. + """ + comic = _make_comic() + stale_path = str(Path(comic.path).with_name("stale-pre-rename-name.cbz")) + set_pending_prompts( + { + "fp1": { + "fingerprint": "fp1", + "pk": comic.pk, + "path": stale_path, + "source": "metron", + "candidates": [{"issue_id": 123, "source": "metron"}], + "mode": "auto", + "formats": ["COMIC_INFO"], + "delete_original": False, + } + } + ) + captured: dict = {} + + def _fake_fetch(path, source, issue_id, _credentials, **_kwargs): + captured.update(path=str(path), source=source, issue_id=issue_id) + return {"series": "X"} + + with patch(_FETCH_TARGET, _fake_fetch): + self.manager.resolve_prompt("fp1", "choose", 0, None) + + assert captured["path"] == str(comic.path) + writes = [i for i in self.queue.items if isinstance(i, BulkTagWriteTask)] + assert len(writes) == 1 + assert writes[0].comic_pks == frozenset({comic.pk}) + + def test_resolve_fetch_failure_surfaces_error_without_crashing(self) -> None: + """A fetch crash (stale file, source error) reports; the pick isn't lost silently.""" + comic = _make_comic() + comic_path = str(comic.path) + set_pending_prompts( + { + "fp1": { + "fingerprint": "fp1", + "pk": comic.pk, + "path": comic_path, + "source": "comicvine", + "candidates": [{"issue_id": 764978, "source": "comicvine"}], + "mode": "auto", + "formats": ["COMIC_INFO"], + "delete_original": False, + } + } + ) + + def _boom(*_args, **_kwargs): + reason = f"{comic_path} does not exist." + raise FileNotFoundError(reason) + + with patch(_FETCH_TARGET, _boom): + self.manager.resolve_prompt("fp1", "choose", 0, None) + + assert not [i for i in self.queue.items if isinstance(i, BulkTagWriteTask)] + assert get_pending_prompts() == {} + errors = get_tag_write_errors() + assert len(errors) == 1 + assert "fetching chosen issue comicvine:764978 failed" in errors[0]["error"] + + def test_resolve_missing_comic_row_reports_and_never_fetches(self) -> None: + """A prompt whose comic left the DB reports instead of fetching a dead path.""" + set_pending_prompts( + { + "fp1": { + "fingerprint": "fp1", + "pk": 999_999, + "path": "/gone/c.cbz", + "source": "metron", + "candidates": [{"issue_id": 123, "source": "metron"}], + "mode": "auto", + "formats": ["COMIC_INFO"], + "delete_original": False, + } + } + ) + called: list = [] + + def _fake_fetch(*args, **_kwargs): + called.append(args) + return {"series": "X"} + + with patch(_FETCH_TARGET, _fake_fetch): + self.manager.resolve_prompt("fp1", "choose", 0, None) + + assert not called + assert not [i for i in self.queue.items if isinstance(i, BulkTagWriteTask)] + errors = get_tag_write_errors() + assert len(errors) == 1 + assert "no longer in the database" in errors[0]["error"] def test_resolve_drifted_prompt_requeues_fresh_prompt(self) -> None: """A candidate with no issue id falls back to replay, which can drift.""" diff --git a/tests/test_settings_comicbox_config.py b/tests/test_settings_comicbox_config.py new file mode 100644 index 000000000..7e5c4585f --- /dev/null +++ b/tests/test_settings_comicbox_config.py @@ -0,0 +1,28 @@ +""" +COMICBOX_CONFIG overlay regression tests. + +comicbox's confuse template nests ``loglevel`` and ``delete_keys`` under the +``general`` section; a flat overlay is *silently ignored*. That regression +made renames carry comicfn2dict remainder junk from the old filename into +the new one (e.g. ``Wolverine (0000) #023.cbz`` → +``… Power Grab[(0000)].cbz``) because ``remainders`` was never deleted from +the merged metadata. +""" + +from codex.settings import COMICBOX_CONFIG, LOGLEVEL + + +def test_delete_keys_overlay_reaches_comicbox() -> None: + """The unused-field delete list must survive the config overlay.""" + keys = COMICBOX_CONFIG.general.delete_keys + # Non-empty proves the nested overlay applied at all. + assert keys + # Filename junk must not leak into rename targets. + assert "remainders" in keys + # A field codex consumes must never be deleted. + assert "series" not in keys + + +def test_loglevel_overlay_reaches_comicbox() -> None: + """Comicbox logs at codex's configured level, not its own default.""" + assert COMICBOX_CONFIG.general.loglevel == LOGLEVEL From a5f37d87a900b8610568dd7600beaa04003f4997 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sat, 11 Jul 2026 01:16:50 -0700 Subject: [PATCH 22/66] update deps and format --- bun.lock | 46 +- frontend/bun.lock | 88 +++- frontend/package.json | 8 +- frontend/tests/unit/rename-toggle.test.js | 2 +- package.json | 8 +- uv.lock | 538 +++++++++++----------- 6 files changed, 366 insertions(+), 324 deletions(-) diff --git a/bun.lock b/bun.lock index d7db82ab8..5b1a31185 100644 --- a/bun.lock +++ b/bun.lock @@ -169,7 +169,7 @@ "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.61.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.61.1", "@typescript-eslint/types": "^8.61.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.61.1", "", { "dependencies": { "@typescript-eslint/types": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1" } }, "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0" } }, "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A=="], "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg=="], @@ -177,7 +177,7 @@ "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.61.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.61.1", "@typescript-eslint/tsconfig-utils": "8.61.1", "@typescript-eslint/types": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.61.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.63.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA=="], "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.1", "", { "dependencies": { "@typescript-eslint/types": "8.61.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w=="], @@ -225,7 +225,7 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.12.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA=="], - "@vitest/eslint-plugin": ["@vitest/eslint-plugin@1.6.21", "", { "dependencies": { "@typescript-eslint/scope-manager": "^8.58.0", "@typescript-eslint/utils": "^8.58.0" }, "peerDependencies": { "@typescript-eslint/eslint-plugin": "*", "eslint": ">=8.57.0", "typescript": ">=5.0.0", "vitest": "*" }, "optionalPeers": ["@typescript-eslint/eslint-plugin", "typescript", "vitest"] }, "sha512-5nu7QuW5Dg9v4I9t9ZiU1Hh91BoG0x3lOzOWYTsr1bqpIqcHYzFSQ052LvZwGtEm7Trh+Mr2heYEmZ4LkDjfaA=="], + "@vitest/eslint-plugin": ["@vitest/eslint-plugin@1.6.23", "", { "dependencies": { "@typescript-eslint/scope-manager": "^8.58.0", "@typescript-eslint/utils": "^8.58.0" }, "peerDependencies": { "@typescript-eslint/eslint-plugin": "*", "eslint": ">=8.57.0", "typescript": ">=5.0.0", "vitest": "*" }, "optionalPeers": ["@typescript-eslint/eslint-plugin", "typescript", "vitest"] }, "sha512-CbrXxQpIUMbeyylGlxaxTgnWYM44et4Nx58swl7+vrTyC9Ttx+hQoy8BGCregbbZ08K5qFhRHeoad1VvERUbkg=="], "@xml-tools/parser": ["@xml-tools/parser@1.0.11", "", { "dependencies": { "chevrotain": "7.1.1" } }, "sha512-aKqQ077XnR+oQtHJlrAflaZaL7qZsulWc/i/ZEooar5JiWj1eLt0+Wg28cpa+XLney107wXqneC+oG1IZvxkTA=="], @@ -365,7 +365,7 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.6.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg=="], + "eslint": ["eslint@10.7.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ=="], "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], @@ -381,7 +381,7 @@ "eslint-plugin-compat": ["eslint-plugin-compat@7.0.2", "", { "dependencies": { "@mdn/browser-compat-data": "^6.1.1", "ast-metadata-inferer": "^0.8.1", "browserslist": "^4.25.2", "find-up": "^5.0.0", "globals": "^15.7.0", "lodash.memoize": "^4.1.2", "semver": "^7.6.2" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-gN8hF+4NzMsHUbr4m/TYZK0FtW3DcV4g8rXpTsY2EV5xiRD8jsilUlB9lNSkGGX0veDCxMhKSWbSd+faJByQDA=="], - "eslint-plugin-de-morgan": ["eslint-plugin-de-morgan@2.1.2", "", { "peerDependencies": { "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" } }, "sha512-6/MXP77FUrFGTdJ2Lny3Jv027bE3SUHDf+/VBUBckpH5TskOY5G4UX+AebqJeUpt3nXjy3S0KWBRwTItVQwgvw=="], + "eslint-plugin-de-morgan": ["eslint-plugin-de-morgan@2.1.3", "", { "peerDependencies": { "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" } }, "sha512-vuU/AIPvzsRhCyAfgaWwRVy0QUGvtv2pCn3N6tV4LZRFRNKPuV+Md1mYLvLuM9tORKLe8plzx5DqS9/II/3dew=="], "eslint-plugin-depend": ["eslint-plugin-depend@1.5.0", "", { "dependencies": { "empathic": "^2.0.0", "module-replacements": "^2.10.1", "semver": "^7.6.3" }, "peerDependencies": { "eslint": ">=8.40.0" } }, "sha512-i3UeLYmclf1Icp35+6W7CR4Bp2PIpDgBuf/mpmXK5UeLkZlvYJ21VuQKKHHAIBKRTPivPGX/gZl5JGno1o9Y0A=="], @@ -783,7 +783,7 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "prettier": ["prettier@3.9.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg=="], + "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], "prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="], @@ -1127,6 +1127,14 @@ "@npmcli/promise-spawn/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.63.0", "", {}, "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q=="], + + "@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], + + "@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.63.0", "", {}, "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q=="], + + "@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.63.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.63.0", "@typescript-eslint/tsconfig-utils": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q=="], + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -1149,8 +1157,6 @@ "eslint-mdx/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils": ["@typescript-eslint/utils@8.63.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA=="], - "eslint-plugin-sonarjs/globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="], "eslint-plugin-unicorn/browserslist": ["browserslist@4.28.5", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", "electron-to-chromium": "^1.5.387", "node-releases": "^2.0.50", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ=="], @@ -1221,15 +1227,17 @@ "@npmcli/promise-spawn/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + "@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "eslint-mdx/espree/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.63.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.63.0", "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0" } }, "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A=="], + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.63.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.63.0", "", {}, "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q=="], + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.63.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.63.0", "@typescript-eslint/tsconfig-utils": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q=="], + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + + "eslint-mdx/espree/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "eslint-plugin-unicorn/browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="], @@ -1265,20 +1273,10 @@ "@npmcli/map-workspaces/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], - - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.63.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.63.0", "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ=="], - - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.63.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg=="], - - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "npm-pick-manifest/npm-package-arg/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], } } diff --git a/frontend/bun.lock b/frontend/bun.lock index a0aa825e8..60a495036 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -324,7 +324,7 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="], + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], @@ -378,7 +378,7 @@ "@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="], - "@vue/language-core": ["@vue/language-core@3.3.6", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.0", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-LgBMZAy2sR3cQWknpyaxnI6yBkqDfLBPkbdhwRhQCvzfNJRQXPilgQIrdI/v4ytJ0sAq9bWhaPsjqBqneomJ3Q=="], + "@vue/language-core": ["@vue/language-core@3.3.7", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-LzmkKinXAMMoh8Jfi/jMUSDUjuPdv8mynH5WJGKfXyZtDw3hQ6GBaoI6Bcnl/Xqlu32q/0Z6i/trp4VXykzyLw=="], "@vue/reactivity": ["@vue/reactivity@3.5.39", "", { "dependencies": { "@vue/shared": "3.5.39" } }, "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog=="], @@ -392,7 +392,7 @@ "@vue/test-utils": ["@vue/test-utils@2.4.11", "", { "dependencies": { "js-beautify": "^1.14.9", "vue-component-type-helpers": "^3.0.0" }, "peerDependencies": { "@vue/compiler-dom": "3.x", "@vue/server-renderer": "3.x", "vue": "3.x" }, "optionalPeers": ["@vue/server-renderer"] }, "sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA=="], - "@vue/typescript-plugin": ["@vue/typescript-plugin@3.3.6", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.6", "@vue/shared": "^3.5.0", "path-browserify": "^1.0.1", "vue-component-meta": "3.3.6" } }, "sha512-3jubnULC27+N2DYw2gLWMeQMdQuS5lUjeR6yzvDh76tx35zYO2c4z+LNLenJqncAOXdhqkCRLzuBgDSkTEJCqg=="], + "@vue/typescript-plugin": ["@vue/typescript-plugin@3.3.7", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.7", "@vue/shared": "^3.5.0", "path-browserify": "^1.0.1", "vue-component-meta": "3.3.7" } }, "sha512-ME/ORVBj7wy3KWhSVld8YtWy3xlPSg3ErifRI1/UTdsQxtZDDzcFF+9VszGSfNadTM5qO9kwZmnFa9hm2PFQsw=="], "@vuetify/loader-shared": ["@vuetify/loader-shared@2.1.2", "", { "dependencies": { "upath": "^2.0.1" }, "peerDependencies": { "vue": "^3.0.0", "vuetify": ">=3" } }, "sha512-X+1jBLmXHkpQEnC0vyOb4rtX2QSkBiFhaFXz8yhQqN2A4vQ6k2nChxN4Ol7VAY5KoqMdFoRMnmNdp/1qYXDQig=="], @@ -874,7 +874,7 @@ "valibot": ["valibot@1.4.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g=="], - "vite": ["vite@8.1.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA=="], + "vite": ["vite@8.1.4", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.16", "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ=="], "vite-plugin-checker": ["vite-plugin-checker@0.14.4", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "chokidar": "^5.0.0", "npm-run-path": "^6.0.0", "picocolors": "^1.1.1", "picomatch": "^4.0.4", "proper-lockfile": "^4.1.2", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@biomejs/biome": ">=2.4.12", "eslint": ">=9.39.4", "meow": "^13.2.0 || ^14.0.0", "optionator": "^0.9.4", "oxlint": ">=1", "stylelint": ">=16.26.1", "typescript": "*", "vite": ">=5.4.21", "vue-tsc": "~2.2.10 || ^3.0.0" }, "optionalPeers": ["@biomejs/biome", "eslint", "meow", "optionator", "oxlint", "stylelint", "typescript", "vue-tsc"] }, "sha512-Tw0U9UgHIRiZ+Yoe4Gh0RrYoBiCVmO9j4tomVdYr0KUjUsqXMPhqW8ouoSWmOzGp5Iimipbl3bNXZcK7OeP7Qg=="], @@ -890,7 +890,7 @@ "vue": ["vue@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/compiler-sfc": "3.5.39", "@vue/runtime-dom": "3.5.39", "@vue/server-renderer": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA=="], - "vue-component-meta": ["vue-component-meta@3.3.6", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.6", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-kPNTwuLzqYDvsE2CiDPSbt89aypZwYoCRxtbUfbkfOgy3DV130G3Z1jJuRBct64vxB2Lii6fcih8FJba8dLoXQ=="], + "vue-component-meta": ["vue-component-meta@3.3.7", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.7", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-rqMY/EGOHGLcUbCb1VRZNBlDpUS12O0kMJL133Z3TrWpKmwtdGNLALTwEX1jBskoV+ibQp/aWEW+87a79FexCA=="], "vue-component-type-helpers": ["vue-component-type-helpers@3.3.5", "", {}, "sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA=="], @@ -900,7 +900,7 @@ "vue-router": ["vue-router@5.1.0", "", { "dependencies": { "@babel/generator": "^8.0.0-rc.4", "@vue-macros/common": "^3.1.1", "@vue/devtools-api": "^8.1.2", "ast-walker-scope": "^0.9.0", "chokidar": "^5.0.0", "json5": "^2.2.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "scule": "^1.3.0", "tinyglobby": "^0.2.16", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1", "yaml": "^2.9.0" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34", "pinia": "^3.0.4", "vite": "^7.0.0 || ^8.0.0", "vue": "^3.5.34" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g=="], - "vuetify": ["vuetify@4.1.3", "", { "peerDependencies": { "typescript": ">=4.7", "vite-plugin-vuetify": ">=2.1.0", "vue": "^3.5.0", "webpack-plugin-vuetify": ">=3.1.0" }, "optionalPeers": ["typescript", "vite-plugin-vuetify", "webpack-plugin-vuetify"] }, "sha512-tI6GHgICixvnoncWkSBNMKfwzjbt6H8FxEtGvUxYirOIBu00u5TUANgUbu1ly6wr9sVjvwqakCjUiAYJFsqpMw=="], + "vuetify": ["vuetify@4.1.4", "", { "peerDependencies": { "typescript": ">=4.7", "vite-plugin-vuetify": ">=2.1.0", "vue": "^3.5.0", "webpack-plugin-vuetify": ">=3.1.0" }, "optionalPeers": ["typescript", "vite-plugin-vuetify", "webpack-plugin-vuetify"] }, "sha512-8/H0Yl6h9t07/ExuyZFIRJqAenpKeCiVaRJzbHGfTtuZRZIs5EbtjdFYikPIu6h9thBJAx15PrU4ofa4LvdJag=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], @@ -984,6 +984,8 @@ "@vue/language-core/@vue/compiler-dom": ["@vue/compiler-dom@3.5.39", "", { "dependencies": { "@vue/compiler-core": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg=="], + "@vue/language-core/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "@vue/server-renderer/@vue/shared": ["@vue/shared@3.5.38", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="], "ast-walker-scope/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], @@ -1020,7 +1022,11 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "vite/rolldown": ["rolldown@1.1.3", "", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-x64-msvc": "1.1.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g=="], + "vite/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "vite/rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], + + "vitest/vite": ["vite@8.1.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA=="], "vue/@vue/compiler-dom": ["@vue/compiler-dom@3.5.39", "", { "dependencies": { "@vue/compiler-core": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg=="], @@ -1116,35 +1122,39 @@ "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.3", "", { "os": "android", "cpu": "arm64" }, "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g=="], + "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], - "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw=="], + "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], - "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw=="], + "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], - "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw=="], + "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], - "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.3", "", { "os": "linux", "cpu": "arm" }, "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg=="], + "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], - "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA=="], + "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], - "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w=="], + "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], - "vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw=="], + "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], - "vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA=="], + "vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], - "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg=="], + "vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], - "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g=="], + "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], - "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.3", "", { "os": "none", "cpu": "arm64" }, "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ=="], + "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], - "vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.3", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg=="], + "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], - "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g=="], + "vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], - "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA=="], + "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], + + "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], + + "vitest/vite/rolldown": ["rolldown@1.1.3", "", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-x64-msvc": "1.1.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g=="], "vue-router/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.1.3", "", { "dependencies": { "@vue/devtools-shared": "^8.1.3", "birpc": "^2.6.1", "hookable": "^5.5.3", "perfect-debounce": "^2.0.0" } }, "sha512-cRn7GXiCQkMYU2Z3h3pM4YO/ndbx9FY1yLDAqIqPLcmIq4H6zAOJHein6tvZU3AfPwgrodqLiPBEF+YQaS8AxA=="], @@ -1252,6 +1262,36 @@ "vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + "vitest/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.3", "", { "os": "android", "cpu": "arm64" }, "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g=="], + + "vitest/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw=="], + + "vitest/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw=="], + + "vitest/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw=="], + + "vitest/vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.3", "", { "os": "linux", "cpu": "arm" }, "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg=="], + + "vitest/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA=="], + + "vitest/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w=="], + + "vitest/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw=="], + + "vitest/vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA=="], + + "vitest/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg=="], + + "vitest/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g=="], + + "vitest/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.3", "", { "os": "none", "cpu": "arm64" }, "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ=="], + + "vitest/vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.3", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg=="], + + "vitest/vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g=="], + + "vitest/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA=="], + "vue-router/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@8.1.3", "", {}, "sha512-CM3uIPL+v+lrJUk33+pxspYo0MhuMWlCvf7zC9fybifvCPyM2jUbYRPwoYEJgYbwRqPikm5HozbUhp60MF2QuA=="], "vue-router/@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], @@ -1272,8 +1312,12 @@ "vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "vitest/vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + "@devframes/hub/nostics/oxc-parser/@oxc-parser/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "devframe/nostics/oxc-parser/@oxc-parser/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "vitest/vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], } } diff --git a/frontend/package.json b/frontend/package.json index fa4eae834..9613f9113 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,25 +26,25 @@ "vue": "^3.5.39", "vue-pdf-embed": "^2.1.5", "vue-router": "^5.1.0", - "vuetify": "^4.1.3", + "vuetify": "^4.1.4", "xior": "^0.8.3" }, "devDependencies": { "@mdi/font": "^7.4.47", "@mdi/js": "^7.4.47", "@pinia/testing": "^1.0.3", - "@types/node": "^26.1.0", + "@types/node": "^26.1.1", "@unhead/bundler": "^3.1.7", "@vitejs/plugin-vue": "^6.0.7", "@vue/test-utils": "^2.4.11", - "@vue/typescript-plugin": "^3.3.6", + "@vue/typescript-plugin": "^3.3.7", "happy-dom": "^20.10.6", "rollup-plugin-visualizer": "^7.0.1", "sass": "^1.101.0", "sass-loader": "^17.0.0", "toml": "^4.1.2", "typeface-roboto": "^1.1.13", - "vite": "^8.1.3", + "vite": "^8.1.4", "vite-plugin-checker": "^0.14.4", "vite-plugin-dynamic-base": "^1.4.1", "vite-plugin-run": "^0.9.1", diff --git a/frontend/tests/unit/rename-toggle.test.js b/frontend/tests/unit/rename-toggle.test.js index 6a2182567..a57953621 100644 --- a/frontend/tests/unit/rename-toggle.test.js +++ b/frontend/tests/unit/rename-toggle.test.js @@ -114,7 +114,7 @@ describe("EditPanel — rename toggle", () => { await wrapper.vm.fetchRenamePreview(); expect(HTTP.post).toHaveBeenCalledWith( "/admin/tag-write/preflight", - // eslint-disable-next-line vitest/valid-expect -- false positive: the plugin misreads expect.any() as chai's `.any` flag chain + expect.objectContaining({ patch: expect.any(String) }), ); expect(wrapper.vm.renamePreviews).toEqual([ diff --git a/package.json b/package.json index 6c296d239..b199bc52c 100644 --- a/package.json +++ b/package.json @@ -64,13 +64,13 @@ "@fsouza/prettierd": "^0.28.0", "@prettier/plugin-xml": "^3.4.2", "@stylistic/eslint-plugin": "^5.10.0", - "@vitest/eslint-plugin": "^1.6.21", - "eslint": "^10.6.0", + "@vitest/eslint-plugin": "^1.6.23", + "eslint": "^10.7.0", "eslint_d": "^15.0.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-array-func": "^5.1.1", "eslint-plugin-compat": "^7.0.2", - "eslint-plugin-de-morgan": "^2.1.2", + "eslint-plugin-de-morgan": "^2.1.3", "eslint-plugin-depend": "^1.5.0", "eslint-plugin-html": "^8.1.4", "eslint-plugin-import-x": "^4.17.1", @@ -92,7 +92,7 @@ "eslint-plugin-vue-scoped-css": "^3.1.1", "eslint-plugin-yml": "^3.6.0", "postcss-scss": "^4.0.9", - "prettier": "^3.9.4", + "prettier": "^3.9.5", "prettier-plugin-nginx": "^1.0.3", "prettier-plugin-packagejson": "^3.0.2", "prettier-plugin-toml": "^2.0.6", diff --git a/uv.lock b/uv.lock index cd2dfa415..cbb5f4841 100644 --- a/uv.lock +++ b/uv.lock @@ -485,63 +485,63 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/56/10a88e00039537d74bd420f0457c52ab8f58a1af56126e3b9f1b1c8c4724/charset_normalizer-3.4.8.tar.gz", hash = "sha256:d9bf144d6faf12c70d58e47f7512992ae2882b820031d6cef68152deb645bf2d", size = 151790, upload-time = "2026-07-06T15:27:58.477Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/c2/39de60ef5687662f467bed3d1e6944c67a4f0d057141d0404002b8f405ae/charset_normalizer-3.4.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:faac37c4904598daa00cb4c9b32f3b4cc814fb5f145d7a531ceb4a70f2114132", size = 319040, upload-time = "2026-07-06T15:26:15.854Z" }, - { url = "https://files.pythonhosted.org/packages/a7/57/a9474c3aeaa337c8a330c0dc5df266527d56da3b189c029529f6b08af2a4/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f191c19a32dc6cec0fb8079789d786254653a9ce906fcab04ccd2eed07bba233", size = 215541, upload-time = "2026-07-06T15:26:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/13/a9/be1ff7e81f6e086dced2a7a7a28b789be351d9796084ccaf6136a4ffafb3/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05811b76943d477bb90822dedb5c4565cef70148847a59d574e2b35043aeb563", size = 236913, upload-time = "2026-07-06T15:26:18.482Z" }, - { url = "https://files.pythonhosted.org/packages/8f/75/d8c5eae93da26d463f9ebe46a4937ca44434dc2937a565b92437befb3d94/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3868a3e4ec1e40b419e060d063f93eac6f046fa21426c4816421223ae7dc8ab8", size = 232815, upload-time = "2026-07-06T15:26:19.734Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/98e301ca944bcca5e6bc312406b579c8a6d81546c1b494afb3a9478495d6/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25f93d194eb6264c64416cabff46a91f6d99b97e7525a1b4f35c77a99e75cc68", size = 223995, upload-time = "2026-07-06T15:26:20.931Z" }, - { url = "https://files.pythonhosted.org/packages/fa/df/f5222366b76dcb31453a9bd922610c893540d0e729fd390439b0d3e972ee/charset_normalizer-3.4.8-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:ee6a62492f18d432cca031fabd158f400a8c25bf7b9458f50953393a2a23d97a", size = 208522, upload-time = "2026-07-06T15:26:22.214Z" }, - { url = "https://files.pythonhosted.org/packages/74/52/293220d59d8ddfb8aa56836b33bd6df58e70795d8a102a858c2984480f00/charset_normalizer-3.4.8-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c16cb4fc35e4b064f5ee78d849f15a550ada1729c3372916672e38f1f01d1d4", size = 219660, upload-time = "2026-07-06T15:26:23.493Z" }, - { url = "https://files.pythonhosted.org/packages/c3/2c/81a298e66f3d01e61bfc6f7064bbb553b067a9f1d979e5962bf00733069b/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2fbd0edb0426ab28e70fac9d1d4ef549eb5a64a2521f0428c441d75e4387e6c2", size = 218230, upload-time = "2026-07-06T15:26:24.629Z" }, - { url = "https://files.pythonhosted.org/packages/0d/45/f1dd2328cbc3340705f82072c09bd4c68d6e079191cde05810c1eac77eee/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccb9052771216170015f810b88065fd9e13b1e0b391f92abb9b47e0919a42aad", size = 210006, upload-time = "2026-07-06T15:26:25.837Z" }, - { url = "https://files.pythonhosted.org/packages/38/63/28697000620e117eb413424caaf60b6f98ddb1b09b2c11f7c0038d9936a7/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3809ba5d3cd02aca0894597f2669a825bdfe2229061515c128b0f4e5533b4ab5", size = 225771, upload-time = "2026-07-06T15:26:27.079Z" }, - { url = "https://files.pythonhosted.org/packages/12/a7/d5844e315f5f35e7938c415f07a1df144eed1cf993f1b43cc16c980c5b46/charset_normalizer-3.4.8-cp312-cp312-win32.whl", hash = "sha256:de63c31666a049f653ada24e800192e3c019e96bc7d70fb449a000bccf26a36f", size = 150922, upload-time = "2026-07-06T15:26:28.514Z" }, - { url = "https://files.pythonhosted.org/packages/9b/82/eb8b72f184b1e4986dd9daec15d7f6d9285a6728d2b07b7f04656829f473/charset_normalizer-3.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:14a4bbe066f3fb05c6ba70e9cf9d34614b57a2fd70ea8c27cc30f34155e16a58", size = 162294, upload-time = "2026-07-06T15:26:29.745Z" }, - { url = "https://files.pythonhosted.org/packages/09/5a/ab810134aa41034a08ffe94c058102016e6ad9bce62f3cdba547b4723385/charset_normalizer-3.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:2b5b0c0dca0a02c3f816f89abf18af3d20416dedbc3d3aa5f3981045f88ae7b0", size = 152409, upload-time = "2026-07-06T15:26:31.034Z" }, - { url = "https://files.pythonhosted.org/packages/11/49/fe5a8572a70cd9cba79f80af9388ac8c5c914ed4459b956f940244e499a5/charset_normalizer-3.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:057f8609f7341618c98e5aa9a6109fa116acff2a658497d47ab3325b5e8f2b08", size = 317424, upload-time = "2026-07-06T15:26:32.23Z" }, - { url = "https://files.pythonhosted.org/packages/e5/59/d71c96616b6825425a876f79f38fa440db30b32cc1166179a839f6259150/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c591f9a82adc5b89a039b90df74e43de2b9177fb46771172bed7b80722a70db0", size = 214723, upload-time = "2026-07-06T15:26:33.635Z" }, - { url = "https://files.pythonhosted.org/packages/c6/35/dc9eeb297f19b7b6ada39709ccb74937e6c51f0947958ae601a977cedd5d/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b56f449132d9adefe55b87635d05177a914ed5d070438a74725e1d77a280002", size = 236200, upload-time = "2026-07-06T15:26:34.99Z" }, - { url = "https://files.pythonhosted.org/packages/0a/37/6775fe852b4acad8bf7e0575fbe8aa9f41b546e33251acbded3c04a6b0d9/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1004c3b5831a301dadfb9e916f38e78e2ff3e08db24a1ad7c354db8ee3dea9c3", size = 231740, upload-time = "2026-07-06T15:26:36.498Z" }, - { url = "https://files.pythonhosted.org/packages/e4/28/1bcc3f5f3bac81532384adcfcdd9362c7f46a188a19deacc1ddaf7bdaa00/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60883e22821d17c9e5b4f3ca1ef8074f766e3db28791f851b665929c515635c0", size = 222888, upload-time = "2026-07-06T15:26:38.161Z" }, - { url = "https://files.pythonhosted.org/packages/47/81/9f3993ca62ef090c58059da641e49e3129e74700a6a3beb58436cdb8d4b9/charset_normalizer-3.4.8-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:21e4dbb942c8a6342e2685f232dd2a7bc73465697bd26ead4f118271d28be383", size = 207649, upload-time = "2026-07-06T15:26:39.628Z" }, - { url = "https://files.pythonhosted.org/packages/11/f4/679f636bcbdc2d53d06b1f4039be310450dca95a9f76bbf22f09985556e8/charset_normalizer-3.4.8-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84bcae14c65e645ca66b661339183d32b8c846a17c96e3e81ab3d346e1c498d4", size = 218908, upload-time = "2026-07-06T15:26:40.911Z" }, - { url = "https://files.pythonhosted.org/packages/7e/44/96e8c81867ba8a45ff893c8e7474c2d6b9633f7aa663da7901d040214d3e/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b4e4d44b8287aa13a25e16e29393d494b0643b24894f7c8266c6f6788dd36337", size = 217096, upload-time = "2026-07-06T15:26:42.148Z" }, - { url = "https://files.pythonhosted.org/packages/a5/bf/4d53f04f29bdb22601701f4f9f4d038edfb27976c296fcb7400c02736a6e/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:f68545d1b267dbfafd5d253b6d1cb161562c4e61ab25b5c4cdb7d9e5923e441e", size = 209355, upload-time = "2026-07-06T15:26:43.557Z" }, - { url = "https://files.pythonhosted.org/packages/ad/60/92b3f630798d777fa880ad289a3f9f2fc663e4b4beb24783c53318820254/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1936e48214adea74922a20c8ab41b1393ae27cc9e329eb1f0b937d3416824f36", size = 224732, upload-time = "2026-07-06T15:26:44.892Z" }, - { url = "https://files.pythonhosted.org/packages/bd/85/eafa0a3c7bb6fe9f02f4c7901f02071933cac85ee634197e17280818c6de/charset_normalizer-3.4.8-cp313-cp313-win32.whl", hash = "sha256:1f8e3521860187d597f3867d8466da225b9179ea2833bb26de1bb026144d07c3", size = 150358, upload-time = "2026-07-06T15:26:46.153Z" }, - { url = "https://files.pythonhosted.org/packages/f5/8c/879fafff7b47bb1166d289f2d2472cb31b9922f9f4ca1f392edf85ec16be/charset_normalizer-3.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:8b654b6f52a0a9a6be38e88f3e1dc68f1093ebeb2abbadafc7c82da0786a34be", size = 161685, upload-time = "2026-07-06T15:26:47.423Z" }, - { url = "https://files.pythonhosted.org/packages/c3/46/b57c7e778a7b578f28d35fd38544687d4f8d9c019585eebc5ad936073fad/charset_normalizer-3.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:d2d5a250ee26e29468b7607d97479221b069fa8aaf6f929ac84ec0e962e15154", size = 152333, upload-time = "2026-07-06T15:26:48.689Z" }, - { url = "https://files.pythonhosted.org/packages/1c/bc/0a8540b8cd494951cca1428606373942803f5ffcec40fe798f819c5a8adb/charset_normalizer-3.4.8-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:77e993ecf65f21ab1f82266ff5e84a7de2c879e7d9b8bc006009df83f22a1d5e", size = 316993, upload-time = "2026-07-06T15:26:49.962Z" }, - { url = "https://files.pythonhosted.org/packages/0e/99/a0868f0a1f0a045fd374d1f2cf7042d8ad5d7fb4dd1f4ac7365e319f7e32/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:524939917f17f6de502dfda30b472550965740d7f126659d4c4f8dd1569cce22", size = 215638, upload-time = "2026-07-06T15:26:51.338Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e9/43c4d09a09b5557cc5fe1d87c9d96f86a3942aec0517d2b5408cef87ca75/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a4508989ba8e2ce43ef989453d18188b261546e8188cbdd4ef451fb9e4c3b467", size = 236456, upload-time = "2026-07-06T15:26:52.531Z" }, - { url = "https://files.pythonhosted.org/packages/e2/67/492ca98b3ab785b736b5da10c1bc233e1c8fec6c0cdb29b482c38bfc52a2/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9e44127f7d11eee4548ad2cdf1f4e1b6eaaddd5cb92d15ad65f6ecc9bcf403ab", size = 232253, upload-time = "2026-07-06T15:26:53.838Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fd/1e6eff58c14f1aace1e26d80defbeaea2d35e075dbe4b611111ee4b47fa8/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb90317359f7e67bb6df615999a95e0980877468e617ddce8b6c2f8e7fe60d95", size = 222886, upload-time = "2026-07-06T15:26:55.009Z" }, - { url = "https://files.pythonhosted.org/packages/40/7a/90056a5326b0c4b9a3f924d337729c344c11542e5bc7191e50410db61587/charset_normalizer-3.4.8-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:35d9e7a9c960520ae89d1f4e305d1c047a74dea2e0f73a0e84f879356c2e8776", size = 206482, upload-time = "2026-07-06T15:26:56.306Z" }, - { url = "https://files.pythonhosted.org/packages/18/ff/94761d31a33878dbb5008ddbd918615061fcf5c0a612aa3075450e60f628/charset_normalizer-3.4.8-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e322b054c7ff886f78feab7360736bb45de2e18cf4a0ee84e8fc5a08d53a19", size = 218929, upload-time = "2026-07-06T15:26:57.422Z" }, - { url = "https://files.pythonhosted.org/packages/2b/dc/00b9675acd7c4b926b9102ee3f0d1a570ce943901be73b87485001393fe1/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c0086d97094363556206dc3bcf43f7edcfc043ea7a568a46f45efea74858bd1", size = 218069, upload-time = "2026-07-06T15:26:58.719Z" }, - { url = "https://files.pythonhosted.org/packages/04/11/94ada5a0482ee4bf688d04be4c7d6fd945d37370d04a95671040dfe2b416/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0752c849b51198267df2aba013c4de3a2955bd014a4fd70828809946c1acbc0c", size = 207146, upload-time = "2026-07-06T15:27:00.058Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f7/246bd36762207ab4752cd436b64e5d81a1668b15ddea7b5b2d0e8545e727/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2a4707e09eca11e81ece4fced600c5a0a801f568b962244f6f517bc274745fc9", size = 224896, upload-time = "2026-07-06T15:27:01.599Z" }, - { url = "https://files.pythonhosted.org/packages/6f/f7/3510622d1fbe13b0ebf827c475e40a27e2be427140d792878b63ab6425cc/charset_normalizer-3.4.8-cp314-cp314-win32.whl", hash = "sha256:8ea67f427c073ae3da0923aa55f3715131fa613a61a7f2f8d762bde75eaf00ae", size = 150851, upload-time = "2026-07-06T15:27:02.964Z" }, - { url = "https://files.pythonhosted.org/packages/32/2b/9ce65dd21672b55cf800cca5f4433afa1586fda1d78731067ec9ec544c62/charset_normalizer-3.4.8-cp314-cp314-win_amd64.whl", hash = "sha256:ff71018850863362e5c7533769d0a9f77715c31af1502d523630ce822922f5c9", size = 162549, upload-time = "2026-07-06T15:27:04.249Z" }, - { url = "https://files.pythonhosted.org/packages/2f/34/9a5967eed666a88f31a0866884606d9ec3c2cd6091e2ccd7e0b4c4176c35/charset_normalizer-3.4.8-cp314-cp314-win_arm64.whl", hash = "sha256:44464e66f4da2f21dea7145c7693f9f60717ca4794a954dea5bf8c2c932678bd", size = 153079, upload-time = "2026-07-06T15:27:05.608Z" }, - { url = "https://files.pythonhosted.org/packages/02/4f/aa44cc81d8987f105352c74c0bf919007f8b80e9880d28bcf0393c1a816e/charset_normalizer-3.4.8-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:50a0c2e58ad2c203adb616fef28941b7e13716adbc25e0dfaeec29f5afe6382f", size = 338586, upload-time = "2026-07-06T15:27:06.86Z" }, - { url = "https://files.pythonhosted.org/packages/1d/2b/b0392e2b235c08ff0623d905c2ee8ac820620544043c1ce92ce0b3d64c55/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1e589fdb95c76f08288bbb346230cdd8994db74903db6637b380f7b5fc9336", size = 222764, upload-time = "2026-07-06T15:27:08.23Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a1/7d466879190731f5559662c22232646f2ae2dace2323c3e5aefcf78d458a/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3d7c887444c5a7ef0d68d358d81e758a850bc626f8e639e2ca5667153272b20", size = 241331, upload-time = "2026-07-06T15:27:09.512Z" }, - { url = "https://files.pythonhosted.org/packages/70/17/8b89e797137aa28c8fb0bafbafc243246a7afe21620a13b00e37624ece1d/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:65c389b96c0cfff3a3f0458fa1c7ce554a30e23101a88a49f03997afce6a929f", size = 239323, upload-time = "2026-07-06T15:27:10.86Z" }, - { url = "https://files.pythonhosted.org/packages/d7/98/1c1940730ed22d50983be4e243c722c89d5136d6f073bd840d1128bfddcb/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:593403fc47dcdf55e2987b2e3cc2e064127e2b908929f1f18b2e4a4652cbd780", size = 229964, upload-time = "2026-07-06T15:27:12.113Z" }, - { url = "https://files.pythonhosted.org/packages/53/2d/bb8e81b7ff603d3f77e9a8a5d1ad34fcabbf3c54d300c29d99fba581fa23/charset_normalizer-3.4.8-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:606088e9fa2b7469ab9c42d4da8e05a415622a07714edd2fcd8fed48dda4c853", size = 212405, upload-time = "2026-07-06T15:27:13.447Z" }, - { url = "https://files.pythonhosted.org/packages/ce/1f/e52a3a53b13da591bb8f21d29e63877268eadf20686b7762351d4b89062c/charset_normalizer-3.4.8-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0317406326fed512f42a1632ad91a96228a7616c06547666a6dd79967f1bd6ca", size = 226918, upload-time = "2026-07-06T15:27:14.89Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f9/32996d79c57189af9722fe618f46d8a86b7be035ca98887b8d0c3821f141/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b67d50ee47e5c57a0064a9cb575b963a7125819dfd1fd094d44d378fff94659b", size = 225113, upload-time = "2026-07-06T15:27:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d2/9248c18e695696513774523a794cfb8b677521ce9ad7554d301cb10a9b20/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79e402b869f270140afa5e2b0e2ac100585358d812fe3dd093d424f7a72964e0", size = 214966, upload-time = "2026-07-06T15:27:17.418Z" }, - { url = "https://files.pythonhosted.org/packages/1e/9d/4b19432d406179a40f924691906ee5b15ac664b408971c973295192444ea/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2970b9f7ab69ec3a0423ec6b6ac718e79fbf4a282c0bc103ef88c1ef50dfa15a", size = 231699, upload-time = "2026-07-06T15:27:19.131Z" }, - { url = "https://files.pythonhosted.org/packages/be/41/bdbdf71e8c3ccff10ef3cc2bb9467a7fdb3dc94b9a406d1a3c44afd39632/charset_normalizer-3.4.8-cp314-cp314t-win32.whl", hash = "sha256:458c2972a78043b7261c9726670029f15f722e70669bcbe961153a01968f589f", size = 155333, upload-time = "2026-07-06T15:27:20.681Z" }, - { url = "https://files.pythonhosted.org/packages/bd/f8/e05c69323bd50091ec39f5f885385b884624b0131a6885a0c83a6217ba7a/charset_normalizer-3.4.8-cp314-cp314t-win_amd64.whl", hash = "sha256:0c926329a1df7cd56d7d8349fe354460d20aefd2e394c9e159e479d018b2b359", size = 167378, upload-time = "2026-07-06T15:27:22.042Z" }, - { url = "https://files.pythonhosted.org/packages/c2/04/cbaf1a2f5e2bbf70760e774380cbf052b10849fc35e770905df31af5cf00/charset_normalizer-3.4.8-cp314-cp314t-win_arm64.whl", hash = "sha256:2232baea80a2b01783679fed4e625ccdb19a974f44c9cf0fba21a777a4c8179c", size = 157782, upload-time = "2026-07-06T15:27:23.312Z" }, - { url = "https://files.pythonhosted.org/packages/23/52/d5bee5b6ea81882d549b566d2545b044bbcbc33fe5fbe001008a7e745a21/charset_normalizer-3.4.8-py3-none-any.whl", hash = "sha256:b7c1fb310df524e01fbe84d43b7f95aa4f808f8eaa0dafc185f64ba395e37d54", size = 64279, upload-time = "2026-07-06T15:27:57.043Z" }, +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] [[package]] @@ -1060,16 +1060,16 @@ wheels = [ [[package]] name = "django" -version = "6.0.6" +version = "6.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/29/ac41e16097af67066d97a7d5775c5d8e7efc5d0284f6b0a159e07b9adb92/django-6.0.6.tar.gz", hash = "sha256:ad03916ba59523d781ae5c3f631960c23d69a9d9c43cecda52fc23b47e953713", size = 10905525, upload-time = "2026-06-03T13:02:46.503Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/50/23f9dc45483419a3cc2085b498b25adfbf10642b2941c73e6d2dfaffc9ab/django-6.0.6-py3-none-any.whl", hash = "sha256:25148b1194c47c2e685e5f5e9c5d59c78b075dfd282cb9618861ba6c1708f4d2", size = 8373354, upload-time = "2026-06-03T13:02:41.72Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ec/1ce5334b6a2c52ce619c23a0be8d366a57a0e080ebb2d88266e5c849157c/django-6.0.7-py3-none-any.whl", hash = "sha256:a037427c2288443a8c02a1b02295a31c239663aa682bc50b1976afb7cf6a769e", size = 8373344, upload-time = "2026-07-07T13:51:20.007Z" }, ] [[package]] @@ -1204,7 +1204,7 @@ wheels = [ [[package]] name = "djlint" -version = "1.40.3" +version = "1.40.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1215,45 +1215,45 @@ dependencies = [ { name = "pyyaml" }, { name = "regex" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/75/98/fdee72fa3bd20af0ca42c136d076c8a2069cd7303d66cb77c60fc901914f/djlint-1.40.3.tar.gz", hash = "sha256:4fe34e932c55f5b87c8df0daf60c07afbe8f620e1a24da1a2bde0ea2651c430e", size = 59215, upload-time = "2026-07-04T11:48:31.126Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/ef/c9b7728d1345ef773698983119e15cda95aedacc9a978e0776184a73c898/djlint-1.40.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e2dbb2219d9133ac5d3dfb9b6bb6a7db582ebcfe005679c16d6cbf284a8fe5", size = 577267, upload-time = "2026-07-04T11:47:46.954Z" }, - { url = "https://files.pythonhosted.org/packages/b7/97/d41a15487a18e544bb9b7c2bbc07a38cf56e13b62cae85fd78a6ffb7df50/djlint-1.40.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47b29ce89517a2ebbfdfc5de74c49f39b0f1c19a01d04aeaed4f8ddf844be367", size = 542842, upload-time = "2026-07-04T11:47:48.057Z" }, - { url = "https://files.pythonhosted.org/packages/93/5e/2dbc6d1ef05716b53db877b52fb2b6de1b0cff3ce7309cec041236563b3f/djlint-1.40.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b54bf1812e2abcff9a97d9208c6fcd0943ed311d345a0c3364fb2e8adf907eda", size = 575133, upload-time = "2026-07-04T11:47:49.585Z" }, - { url = "https://files.pythonhosted.org/packages/1d/c7/25eac5e98b878f572bae8a4e6697b8495666f21aa1db59d0f336d26b1760/djlint-1.40.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec29ff8987808121fcc962e14c66cd929d656d63d3b5b2e04c34c435adf162fe", size = 602415, upload-time = "2026-07-04T11:47:50.674Z" }, - { url = "https://files.pythonhosted.org/packages/ac/20/78d983573e742f8df01a7942c5b0ec57e0b9b0f7fe6b533a7f003916e6ea/djlint-1.40.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62dfa80599ab3f0a1d3c43d85dffa4b42a11933626a6c5fcf03823c066109c19", size = 583668, upload-time = "2026-07-04T11:47:51.717Z" }, - { url = "https://files.pythonhosted.org/packages/80/9b/6dfa0574e980741c9bcc75168c3c790f714647dd526af88a42acfdc6b45f/djlint-1.40.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6715fb4204436013a4eb7e1e7f7edcce44eb03ec94e428ca886c5f1787540e63", size = 611619, upload-time = "2026-07-04T11:47:52.825Z" }, - { url = "https://files.pythonhosted.org/packages/a2/1b/a9f764dcf2dfa058472b95e7e148432b109aa9569e1714ca5c22787ce6d0/djlint-1.40.3-cp312-cp312-win32.whl", hash = "sha256:54e34f363278b292b274c28326542bc0f42c06a6bd99867c630864fd772b50d7", size = 437224, upload-time = "2026-07-04T11:47:53.973Z" }, - { url = "https://files.pythonhosted.org/packages/22/8e/c9d4d8f36bda28784d42fae7c067e8951e7b3ac7e3d1e3fd5f043bc42cad/djlint-1.40.3-cp312-cp312-win_amd64.whl", hash = "sha256:00e053bf26d5c9ddcad67455c597a6ef47ad8debfc59911c00d17b72c17625ab", size = 487134, upload-time = "2026-07-04T11:47:54.997Z" }, - { url = "https://files.pythonhosted.org/packages/72/38/0f665b8a24db2818b15abb459590e56a44909016cd8056906768c0790507/djlint-1.40.3-cp312-cp312-win_arm64.whl", hash = "sha256:33ca64c999010426643977eb4538e8fa790150f601cfb7af778e2cd1652d9ad8", size = 430019, upload-time = "2026-07-04T11:47:56.217Z" }, - { url = "https://files.pythonhosted.org/packages/d9/32/3b9519f888b3983ef3bfbaf220b3a493ccbf68d7a1752e05189fc9309b56/djlint-1.40.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e02a4a3cd5f5489b5dd6dc8f0807105cdb1faba1c7ff011cdc539e215b3f945d", size = 574381, upload-time = "2026-07-04T11:47:57.323Z" }, - { url = "https://files.pythonhosted.org/packages/4c/32/61d2c87fadc03044a8ec131d10087ce54196192dde864ec300a3b0494282/djlint-1.40.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ea2e27d4f23da2d397f7dd1af7a9b38b6866dc5c3697229469f31449cb8e756", size = 541643, upload-time = "2026-07-04T11:47:58.609Z" }, - { url = "https://files.pythonhosted.org/packages/66/d6/69518734dd0b266778d3800c8f6871c645dbdce64241514d4fc531f4202e/djlint-1.40.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191c439e57be56327be260e2b6d9d888f81b1016f388af5f55dc063146e6e290", size = 572995, upload-time = "2026-07-04T11:47:59.775Z" }, - { url = "https://files.pythonhosted.org/packages/01/ad/cf94cfae8f07d98b5c2f8b41f7755d20982edcad571bd1d17331af8b162f/djlint-1.40.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7704817e41e795e2d62b2e1646c8ef512678b484bae9b15d10a4968ff6cc77c", size = 600618, upload-time = "2026-07-04T11:48:00.844Z" }, - { url = "https://files.pythonhosted.org/packages/78/1a/0fae63824b1cd6f17788e474ca149bccbc79896960e1f75e8c10fb35b240/djlint-1.40.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:94a5d64dd7e0396ecdc953f7cbc3f935427330f63b5a3ee80551c3ca56a11bd2", size = 581418, upload-time = "2026-07-04T11:48:01.909Z" }, - { url = "https://files.pythonhosted.org/packages/a5/25/789fcf430e3eb5e07abfe637407cbb29ad620d1f1a186c6dd113ef5c4b7a/djlint-1.40.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bd6d14cc6ac7732a81d704276bf0865749c640c149dc7218da940c6b9a89e4c4", size = 610274, upload-time = "2026-07-04T11:48:03.196Z" }, - { url = "https://files.pythonhosted.org/packages/30/e2/8690bc27594f50b2682873affe46eab1e1b7cb6492b1c64b140981851c1c/djlint-1.40.3-cp313-cp313-win32.whl", hash = "sha256:2e858b98c5961bbdfe20a02dd3e49115de804182601a2f1c1e27b2f6a1a68f56", size = 437031, upload-time = "2026-07-04T11:48:04.354Z" }, - { url = "https://files.pythonhosted.org/packages/47/52/04c3022eb839c063a1791560e02404b2b82f1ebb486bfe9dee93f2d945ff/djlint-1.40.3-cp313-cp313-win_amd64.whl", hash = "sha256:e7db951983c33c27bf2a32153f11a05d0ec91e2f56c6d95c92c0f9a324df7676", size = 486723, upload-time = "2026-07-04T11:48:05.616Z" }, - { url = "https://files.pythonhosted.org/packages/50/ad/8460d545a0875d777d85f7ae36300d779b09b9810579a98a05ab15a83961/djlint-1.40.3-cp313-cp313-win_arm64.whl", hash = "sha256:b12581b55e2b4acf3e9d8f57ef18188618fc31a82500ea9974276428af0a32be", size = 429482, upload-time = "2026-07-04T11:48:06.77Z" }, - { url = "https://files.pythonhosted.org/packages/80/66/36c7f35408d47b88d4547a7a707c2ce4db9128b19fb549b271a53076204c/djlint-1.40.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:669a2b157d2a878eb82e7c85a65e0c57fdfc5193b2c19508d88d64ac82e83e00", size = 572309, upload-time = "2026-07-04T11:48:07.896Z" }, - { url = "https://files.pythonhosted.org/packages/5a/8f/a83bdfe5f5ea4af351755ac5923304e21fe3d12c15659608e56ecd728a0e/djlint-1.40.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:85fe6a62e4759647c98f1800923ad3a186d5a34ecb027da29bf8c825c62236c8", size = 541432, upload-time = "2026-07-04T11:48:09.015Z" }, - { url = "https://files.pythonhosted.org/packages/03/75/ab7225f497acf5bbae18e381cd25d11de2beff9d7d60d058128b90adc172/djlint-1.40.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:498f66913d456da7621e3ba7a712719204d893c6f50b32d860595c1e4e565de7", size = 577447, upload-time = "2026-07-04T11:48:10.175Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7f/52cfe4727bdb2a574acf5e7c17588f851127a75c665b5380edc46a4e5eb3/djlint-1.40.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4539ac7689f4b37b813b36f88679e3c444b8372d0c76fa6d13788069ddf295c", size = 599804, upload-time = "2026-07-04T11:48:11.61Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/8305bf213c9065e3f955ad9fdbf1c88c97eee69a5f4783978b5f4ad8eac4/djlint-1.40.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:976b023f2cd2c564eb71ebc618b308d8c38eb67c5d24574bd0d29d445f2106e5", size = 584955, upload-time = "2026-07-04T11:48:12.928Z" }, - { url = "https://files.pythonhosted.org/packages/0a/85/8b6afa4018fe3c70adecbb6ae19f6372d883d16662a5d39caa3e68da83fc/djlint-1.40.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e61c5d4ad684eeaa4134c5e51e652eb96f3d342d05f5b617eaa629accf591096", size = 609521, upload-time = "2026-07-04T11:48:14.077Z" }, - { url = "https://files.pythonhosted.org/packages/38/42/3e087f577bae675e51c47d123e38c83b249fa5ac29140740e78512b9c36c/djlint-1.40.3-cp314-cp314-win32.whl", hash = "sha256:341dbb91b1ebb96f86804a338c7c40b3a3cb860ace949c40a79da6d45272ff11", size = 442482, upload-time = "2026-07-04T11:48:15.176Z" }, - { url = "https://files.pythonhosted.org/packages/44/15/d71bd699b69f0a1d004f4451779543f778a12bac26c3e9f6ad25a910f205/djlint-1.40.3-cp314-cp314-win_amd64.whl", hash = "sha256:7917f50c96e28c5ecb48312f1696043eba8dd09a497af99f500e020ad0613d36", size = 494803, upload-time = "2026-07-04T11:48:16.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/84/2e9a86cd16d9a17c52945cec926dd4fb3db1a1e91443a5e8002b8e2e45df/djlint-1.40.3-cp314-cp314-win_arm64.whl", hash = "sha256:b09952d9cd50874287a56aaddda8caf98b33b6a94991195d717cc3b68629438a", size = 438407, upload-time = "2026-07-04T11:48:17.482Z" }, - { url = "https://files.pythonhosted.org/packages/45/2e/ae7714a5db1747c4ed14b71583baf84c53646aa6a2aa37916b55ebc46d5d/djlint-1.40.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:97c83b7497686749f7649973882c2fdd239675662f445135957f19bcc815536c", size = 623561, upload-time = "2026-07-04T11:48:18.604Z" }, - { url = "https://files.pythonhosted.org/packages/af/83/cec6eefd521993531e66e6912db85cccd5f871df65e263715a38f6b21e12/djlint-1.40.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:19969e5e5391cb97c755d1e04158456422e51a1808aacb27af624e2c47a45814", size = 593177, upload-time = "2026-07-04T11:48:19.916Z" }, - { url = "https://files.pythonhosted.org/packages/80/39/954d6465f8a072f8ef8d7468d1e33ca7c36912428bb42bb4d4d55145817a/djlint-1.40.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c02df10cf101b3908ff31128d326e23efa66e68cfaef4430e890e32c61efc129", size = 616979, upload-time = "2026-07-04T11:48:21.086Z" }, - { url = "https://files.pythonhosted.org/packages/5c/bd/f1c1c0fdd7163256cc48192ce0d9bed54cc6e9a537ec66257ea721a9de5d/djlint-1.40.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e3900f481b4dcad5b4d149611b0e2c72c4955cdb1ceb5c1074ecb457cb2cb36", size = 638069, upload-time = "2026-07-04T11:48:22.463Z" }, - { url = "https://files.pythonhosted.org/packages/00/b2/834c1aba299454855555792e71610d4b128456748b1335ede89095158792/djlint-1.40.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:152b2854f92285b0ddb9b730426dfe9e7cc64603a92bde9934d51e80518a0e40", size = 623183, upload-time = "2026-07-04T11:48:23.617Z" }, - { url = "https://files.pythonhosted.org/packages/fc/e9/1858be94f1205c85505d8646ac6b85d1312791e8b8d19f94548cf54ccb5c/djlint-1.40.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:617be8d11f337cc1e4034b8a035ef1c21dcdf4578571c4029d6108439e55765c", size = 646847, upload-time = "2026-07-04T11:48:25.11Z" }, - { url = "https://files.pythonhosted.org/packages/cd/d8/45405e2389f7e7ff74add6abf563e66cb79934809c4298e4412a1e07f216/djlint-1.40.3-cp314-cp314t-win32.whl", hash = "sha256:a44c8248569e783e9ca5eb9588374b1b265b800b93633527d8b084ff93828287", size = 465322, upload-time = "2026-07-04T11:48:26.421Z" }, - { url = "https://files.pythonhosted.org/packages/70/e8/cac6d8e0b348c1eddb37f64bf4362e0f43dde0e122de670061386d458747/djlint-1.40.3-cp314-cp314t-win_amd64.whl", hash = "sha256:af048452b63eaf329bb9c011085b554a81ef2417724f3a0f55dd26825383d85f", size = 514250, upload-time = "2026-07-04T11:48:27.886Z" }, - { url = "https://files.pythonhosted.org/packages/13/86/69541eb6079d8eda51085fefae0e7f8603390679894231dac763d90c7ca8/djlint-1.40.3-cp314-cp314t-win_arm64.whl", hash = "sha256:5e125ec3742d710b838c49e74fb5074e0efc80c3261c2bcc36a9817510da438e", size = 456540, upload-time = "2026-07-04T11:48:29.18Z" }, - { url = "https://files.pythonhosted.org/packages/17/40/f29fc555c8fb3a0cf2d12346ee05b8e8c6183ba98b82e5c8ee5453018891/djlint-1.40.3-py3-none-any.whl", hash = "sha256:d2b396a2cb288409fa83abc3f2b17c04aa98b0238bceaeb9d02c1db66e4f0e8f", size = 65272, upload-time = "2026-07-04T11:48:30.239Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/55/34/95284072186e09ea3c7a63a18930061d1ff393f7896fd693f0308fa4aef7/djlint-1.40.4.tar.gz", hash = "sha256:76949030ddfe7334bcde9bcb022133c5d7bb7d292ea7717fc8df425bad4b41c3", size = 59869, upload-time = "2026-07-07T20:50:28.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/ee/83d9e7f8a7dc037d5068efc104435ae2292f8054013d6e2a588d8b85db23/djlint-1.40.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f4f4ab65833c3388d9f0f6a7ac999910b4b3803e9b7f5a6272f19adbae0688f", size = 591323, upload-time = "2026-07-07T20:49:42.982Z" }, + { url = "https://files.pythonhosted.org/packages/70/2f/33c235765ef02bbb48f8b2385c13a93b0168f3aa708c3974a032936c8f81/djlint-1.40.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:872fec9e9c52a71951eea86a92fe49b992e69fa7d9a8acd836b9a6d7086b091b", size = 558830, upload-time = "2026-07-07T20:49:44.252Z" }, + { url = "https://files.pythonhosted.org/packages/9c/23/34a9e0681db90d033c508b7be33a36c36cfc3c37169ecd3c0ac16114978e/djlint-1.40.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cc499a61c68bb8f6ee4dc06167bbb151fc236476f18d0630a7252ba50ddc8ef", size = 591117, upload-time = "2026-07-07T20:49:45.308Z" }, + { url = "https://files.pythonhosted.org/packages/0f/90/39e3af014ce63c0bc5c3890d8ce012d7f89d053a824c6c9c6a5dec5f6410/djlint-1.40.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451868d1009f6e84f8688c6b0788e1bb144fc89b16971e507f3e47f2cbb95e4f", size = 617193, upload-time = "2026-07-07T20:49:46.357Z" }, + { url = "https://files.pythonhosted.org/packages/8d/23/e8e465e627ccc61452c928d4af178ed0c686de74096e6248d822b38d1f5d/djlint-1.40.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab07b7cdd03b1b20f233d5e3446131e4d651f0eaa88e03c7b8952833bd402b41", size = 598865, upload-time = "2026-07-07T20:49:47.681Z" }, + { url = "https://files.pythonhosted.org/packages/9c/46/d43c57b9ac9c3adefe71343c5a705505fa05ed7caa5a197c038a24913ee3/djlint-1.40.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3656d601e6abb5111422bed75fc68c5fca3c6b709751601a8530d88309150845", size = 627900, upload-time = "2026-07-07T20:49:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/9a/fa/b035330810d3c54ab4b851e2830f1c484c4b12f8cd5449fb1635960d7c0d/djlint-1.40.4-cp312-cp312-win32.whl", hash = "sha256:4dedd0ae45b391266cbf03228e275e7d891d8243b79326b716f7570957e978b9", size = 448979, upload-time = "2026-07-07T20:49:50.103Z" }, + { url = "https://files.pythonhosted.org/packages/80/a4/c9a6ab5a416f1d9a115c61e57df6f9e39a65572ad4f13f3c9bfadec6783f/djlint-1.40.4-cp312-cp312-win_amd64.whl", hash = "sha256:41e7b9edfdb0c3a590efcbf907d747f650ac62fd93bc22fb1f0c948d81e239e7", size = 500244, upload-time = "2026-07-07T20:49:51.233Z" }, + { url = "https://files.pythonhosted.org/packages/be/37/c2a7e36bc2312136788903f24a4082e8a31cf5fd339a39df8b88374aa381/djlint-1.40.4-cp312-cp312-win_arm64.whl", hash = "sha256:d60a7493c0c5fb2f5a94e69208352e02ae9fe5f9a62cc47488234cd56b374a08", size = 441856, upload-time = "2026-07-07T20:49:52.338Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/00567d3070d86a6ea2390c7e5633be25248fe8b4af5abf9bfc665ca8d7a9/djlint-1.40.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f1cd31f87b721081100f9b234c97e2e41b5d6c235d40843e576085eecaad4cbf", size = 589048, upload-time = "2026-07-07T20:49:53.429Z" }, + { url = "https://files.pythonhosted.org/packages/8d/25/0ee438261231407ce235cc64c67e05b2ebb5dca828468b25838031ff224b/djlint-1.40.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:589db14e133bf4fec3441e3929cd97ae858e4c93a98bb974a980e71fa1924f73", size = 556757, upload-time = "2026-07-07T20:49:54.557Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fc/afb9ba2301e35a5038d78b4af6615f8302af14566fb163f692f54dbdca07/djlint-1.40.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc960f85ff848bd31d9670cd7ab6c7a9dd1adfe2d988674915be5e8920b8bf2b", size = 588960, upload-time = "2026-07-07T20:49:55.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/79/a779dde335f0d062593db0e76035826795ae27c12c0ec26b09ddb719146e/djlint-1.40.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce6f337c431482194fca840851620bd06afb38869c6874cb37bdba016b54f70", size = 615246, upload-time = "2026-07-07T20:49:57.119Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9a/84ec207bfb5d63fe4f5fc1d33a5f2d59ccd97334bc8d235107fee53de83e/djlint-1.40.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cbab944cd2e615a54be76dbaccbb86047cc10b68613acf7e181716e6a6ab9769", size = 596834, upload-time = "2026-07-07T20:49:58.147Z" }, + { url = "https://files.pythonhosted.org/packages/15/9a/98f719d99bec31f96ab925c013577fc1e33fd060173cfa1c3252423da317/djlint-1.40.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:243f6d1f8243191cd9d6886371b7cb16f2fd46aa450b8501c13dcbe147eaf70a", size = 625933, upload-time = "2026-07-07T20:49:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/46/5f/fcf825020cea241f9d8fb3b2e7669101f23acb333898dacce9026aa64f64/djlint-1.40.4-cp313-cp313-win32.whl", hash = "sha256:20d7e6b772d50d197b58ffc65e89adba27b12569759dbf3dd73d0a05954ee4a3", size = 448707, upload-time = "2026-07-07T20:50:00.886Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cc/304af3b90113dd365495220a377bc91d711cd2f063b48d66af1c9bcf925d/djlint-1.40.4-cp313-cp313-win_amd64.whl", hash = "sha256:400234f1c9a9e039b5300079e0cc83dde059d0c0262e57fa7da03adecff30446", size = 499762, upload-time = "2026-07-07T20:50:02.378Z" }, + { url = "https://files.pythonhosted.org/packages/9e/69/ddcb8186419f50035d1142e34c5bae5394a9fed8b9fc9aa9b6b28a652377/djlint-1.40.4-cp313-cp313-win_arm64.whl", hash = "sha256:bd6f97f22a089464d76fcecf72a7796e1a0b3eb552eb154b9a0aafc894528c22", size = 440973, upload-time = "2026-07-07T20:50:03.569Z" }, + { url = "https://files.pythonhosted.org/packages/65/76/512638424b5e2cf8cd33c935e70ed7f1791c70dd307773895c4d680b6c3a/djlint-1.40.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:39f7227e658d665f682932decaeed39440c844bce8f4ec2a12b790fd8f053079", size = 587508, upload-time = "2026-07-07T20:50:04.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3c/ecf53e3ea1dee58faafa1d159415cace583b07c13635d920b7ac34dad038/djlint-1.40.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:005a90d796bb221f39bc55d9af179f5568b94eb4e29d2c1415f27cddc2f9f601", size = 556571, upload-time = "2026-07-07T20:50:05.935Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5c/0b8f47a8116291b3fa0f65fc7f7c3c283d7cfb217ca7133b9edfaca2627d/djlint-1.40.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3097d3c02ed38776e30124b0ba775a6f232f147ba052d2206d1df09e666068be", size = 593178, upload-time = "2026-07-07T20:50:07.089Z" }, + { url = "https://files.pythonhosted.org/packages/9c/78/5a0cd3ed0deba2c5410b999286e31d50d45e93eb20815bca996314bb242c/djlint-1.40.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e12dfac6257d8f3643aa4657a8fbbaeb0b8b8ad53da4ca85354133fd3465d1a", size = 613952, upload-time = "2026-07-07T20:50:08.444Z" }, + { url = "https://files.pythonhosted.org/packages/a2/06/2a6a59017bd7f35e5f3e67d9fdb76729412788e50e738e29e89cec06ad9b/djlint-1.40.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c4a3e2a4ed25e179aa9fc99d907dc17b26af33b0b31a54351bf1cc17ba57d746", size = 599478, upload-time = "2026-07-07T20:50:09.538Z" }, + { url = "https://files.pythonhosted.org/packages/6d/66/8dca40d476a91128b4e96473ffc36382e9a63ef5c59065b51ea699b2d7e2/djlint-1.40.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d6258ddb3a946d47de3c465bf0e2450dea0fa9d8ec9865dc38ada3ef37fc63", size = 625083, upload-time = "2026-07-07T20:50:10.724Z" }, + { url = "https://files.pythonhosted.org/packages/25/be/9ad023536f8ca42e5f0bd730c79ca0cfbc7dc3bd1ac4653b9704bf0fea2d/djlint-1.40.4-cp314-cp314-win32.whl", hash = "sha256:905ee352144607e30be8fcf30eeb24c3b4c364f3591d79a4a0dd010597f0ee2b", size = 453854, upload-time = "2026-07-07T20:50:11.957Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a1/8367ab12ffa49cf6325d72e0e66839fcac8a763172e25b537787a313666d/djlint-1.40.4-cp314-cp314-win_amd64.whl", hash = "sha256:7932fd45f9c2287c43863ee599fb7d0967f2e7da42048218beb0d3ee8389b6bf", size = 508520, upload-time = "2026-07-07T20:50:13.178Z" }, + { url = "https://files.pythonhosted.org/packages/f8/56/0505bec0f44dac679e5d9152a0521819c60301424c54672574a7f451eeb3/djlint-1.40.4-cp314-cp314-win_arm64.whl", hash = "sha256:56035834d06a0843f806eaa31fcc0f9ecea006ffaf2710320d95779d650f7b5d", size = 450629, upload-time = "2026-07-07T20:50:14.276Z" }, + { url = "https://files.pythonhosted.org/packages/bd/0b/ddb085d9261f63a1bd3f3369afd873c35201c4ec66f90249bdbf2cb8e632/djlint-1.40.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d06097e5a8687d154872ac57d5946640fdf1ee5f24fe942687d214a13e43e99a", size = 638620, upload-time = "2026-07-07T20:50:15.423Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/6699cfab15a0e66880335ff52387ad94ce7920bc851a390cd510af32ad4c/djlint-1.40.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ddab4dbad5a0e7fd008b3831b9cc2861896f565e1727c68053279737e85d8224", size = 605979, upload-time = "2026-07-07T20:50:16.949Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ce/cfdfb7a5d41f5dcbd3d9ec3e06ea837d5ca44a833e76a3fde9e3b6706f3b/djlint-1.40.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:861862b36413fcf118253d635774d7642d6930e3ef4f99a7a520787244c6dc1b", size = 633519, upload-time = "2026-07-07T20:50:18.094Z" }, + { url = "https://files.pythonhosted.org/packages/b9/fe/3bb1965b5cd986daffd795a20fd0fad232a2effc518d415b52a037bce68d/djlint-1.40.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c2cf3e75df64c2e048c05afb70310dbd00caa8fd2656d21453169991af1826b", size = 655685, upload-time = "2026-07-07T20:50:19.38Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/0fd39f4465c3692c3ba5e38ad5fe6db353d69b9983fdb8c5f19c71784445/djlint-1.40.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9420dedc6806c952d09ed5841b89ac81322efb8e80678e7080339c67a9c59530", size = 639280, upload-time = "2026-07-07T20:50:20.736Z" }, + { url = "https://files.pythonhosted.org/packages/b3/00/e07c6420464d01aab6acdd7da85c0855766807f7d0289a6f41d6cd28e537/djlint-1.40.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e965f10fddac29a52fa21677d7e1d00e0880ad97febfc2aaa8476ffbade93814", size = 665379, upload-time = "2026-07-07T20:50:22.621Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ec/786a0021b7a6b4f066ccd848b8213aad9a736e5681d769d941e54b821eb5/djlint-1.40.4-cp314-cp314t-win32.whl", hash = "sha256:cc7f533fef2d13ef208009e67346e2566be685fa2758b51f66a423c7dc42fff1", size = 477438, upload-time = "2026-07-07T20:50:23.983Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d8/a2cee1f7d967daa9b010d362e26cb34813cd8033e26bf083cb596e05b698/djlint-1.40.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5cf5a6a9f8b9308c7ca70ff1ef1e2afaf103d67869ef3d96b3eea81c10d399a7", size = 528060, upload-time = "2026-07-07T20:50:25.201Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3f/9c9244eb8b530b8c8df82354b8cf4ec696e02b7b2f243b13a747890af79a/djlint-1.40.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8c9fc0260700bcece0260fb39d20ee13d72444ca5870afb7d0f2dd64aa5fb4f2", size = 468627, upload-time = "2026-07-07T20:50:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/91/56/164784f721f2fe07d769c5b51c540a7fd598aefaf3b488a0703afe03ad60/djlint-1.40.4-py3-none-any.whl", hash = "sha256:c6652630461fbd458ff3708ee2a89ce992f65fc13cbd4f1066afc7baa1b32b8f", size = 66573, upload-time = "2026-07-07T20:50:27.654Z" }, ] [[package]] @@ -2455,7 +2455,7 @@ wheels = [ [[package]] name = "picopt" -version = "6.6.1" +version = "6.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "confuse" }, @@ -2477,9 +2477,9 @@ dependencies = [ { name = "treestamps" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1b/df/011852b57fb5a0568daf6ebc8ad80ff56c24dcec0ae26b899931ce954fce/picopt-6.6.1.tar.gz", hash = "sha256:e3733489a5d8eef3a824a1daa5717a50ee9fa4f7a6f94f7f272b07171e098f02", size = 10372187, upload-time = "2026-07-06T17:21:49.44Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/b5/511aa39d20eef398d249715575832fbc99c9a30304ec3c95673b05ebc830/picopt-6.6.2.tar.gz", hash = "sha256:db6725fa7acc343f2ec68083bd8ef38d47e781fdd2d2236a5d17c35c3292b9cf", size = 10379632, upload-time = "2026-07-09T23:58:41.306Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/46/a72ad0c0d950a6a816d7fa0237d9be0c2387b2a835e8126c56df69fbff09/picopt-6.6.1-py3-none-any.whl", hash = "sha256:26ce77bdabd4c83fea75dbf6279a58abfc8ca4df92ee43d1d0798314cc5f2d2c", size = 131744, upload-time = "2026-07-06T17:21:47.823Z" }, + { url = "https://files.pythonhosted.org/packages/e1/10/a735e53ee81704e1acd297087fb505364d63bc87c1fc628f7be7616b681b/picopt-6.6.2-py3-none-any.whl", hash = "sha256:824344887d8d68d7e887c9b46daff7a94647b23e8835425e0fc43f5ba9cb2f69", size = 135347, upload-time = "2026-07-09T23:58:39.165Z" }, ] [[package]] @@ -2493,43 +2493,43 @@ wheels = [ [[package]] name = "pikepdf" -version = "10.9.1" +version = "10.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lxml" }, { name = "packaging" }, { name = "pillow" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/0f/a796e796e1a83e7e75b0ee061fbbe3b52889305d79ddf495a557e365ad22/pikepdf-10.9.1.tar.gz", hash = "sha256:410fcf32bc9c8a0a96d94bbd6268ba7585333b1423b93a5fa2ef3c05f4eba3da", size = 23854194, upload-time = "2026-06-19T23:53:53.947Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/b3/61b86a9e11a85ea579ca3d9465e6ddfea79f4baf6be17f7b244569b9bcdb/pikepdf-10.9.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cdc7520523da4966c7afabed20b6c57bc87de74e5e21d0e193b010495fb8f881", size = 4428335, upload-time = "2026-06-19T23:52:50.506Z" }, - { url = "https://files.pythonhosted.org/packages/ca/7a/01679a404d198170785211b6dd32fd5cab25f3f952e5dececd8e1525b4a6/pikepdf-10.9.1-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:08d2bee9fd5d7a32530cc2b0bd167600a0a296e4445823469b98f20907881bd6", size = 4748362, upload-time = "2026-06-19T23:52:52.73Z" }, - { url = "https://files.pythonhosted.org/packages/ee/46/62ecb0480f4c1002d2183609a27df290301e2161fd062152e94bb281d522/pikepdf-10.9.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea24f7f1756a7832fc7e27c9c4e1d7c98c1b847c00c3833299c5c03cf15399c", size = 2000233, upload-time = "2026-06-19T23:52:54.783Z" }, - { url = "https://files.pythonhosted.org/packages/a4/e1/6683d947603398d4db11e6ee4fdf2323f3fd7ffa3c6ad06995f213e2b736/pikepdf-10.9.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3836fc0547af990a2b8a37cda63526ef095dd36b4599cf28cda070a9994241ef", size = 2192740, upload-time = "2026-06-19T23:52:56.947Z" }, - { url = "https://files.pythonhosted.org/packages/71/30/3489d4cf70023528e2d5968a61be7f01349ea311cf25cbd7e7d2ccf1bdb7/pikepdf-10.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b9d26aeb4a5ba4bbaf03102906ef3a2333aa764785b98cc2769238635be7b4f8", size = 3638408, upload-time = "2026-06-19T23:52:59.074Z" }, - { url = "https://files.pythonhosted.org/packages/d0/8d/df1b6f6aa9cad37d6d5545a5078b7347a60e07066fd56d267cc4ef2ba9c9/pikepdf-10.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:84d2ffeac1bd20612db7346ea7b200c78d2d767cbbd6bb52a47d6e1153fa3933", size = 3836337, upload-time = "2026-06-19T23:53:01.408Z" }, - { url = "https://files.pythonhosted.org/packages/20/68/734695c9be596354e2f38e77aa1be42a919a6db669c802810e662c17df62/pikepdf-10.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:8dee389db583673a4c37036960f2980085aafc8f5b55f5843af23cd061f45fb9", size = 3296420, upload-time = "2026-06-19T23:53:03.265Z" }, - { url = "https://files.pythonhosted.org/packages/a8/0e/f99fc15ce0150455c1203e1abe68148d13da9a6f605d94e2a499d1194bed/pikepdf-10.9.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:d9ce2812bd76ef6f3549546a4ad33c81be7e64a9985997628d084e8863d157d2", size = 4428220, upload-time = "2026-06-19T23:53:05.648Z" }, - { url = "https://files.pythonhosted.org/packages/bf/2f/34deea50e306095533a8420ff4d6dd90d8c9c72ad9b46977dae63cbc4ae4/pikepdf-10.9.1-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:15880e09605f0c2f8afeacffb7c3b904e01569922596cc10ee3afc3777406206", size = 4748238, upload-time = "2026-06-19T23:53:07.852Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c1/f0c60cfde4116e8b01687f74c1792567c40a9bf3e1b3ee5ceab7267fedde/pikepdf-10.9.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84dfb8c7082d85106d291bec71990f03215215a07852a698bd5d84337ce31949", size = 2000169, upload-time = "2026-06-19T23:53:09.91Z" }, - { url = "https://files.pythonhosted.org/packages/a4/8e/533188d63f2b14bb6103bed6e048f2d5676622e2f902a3dff558ba4b40be/pikepdf-10.9.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:16b7edf32814256b2e50d31336a0301371a397c466bf7624bce7639e66c07b97", size = 2192691, upload-time = "2026-06-19T23:53:11.756Z" }, - { url = "https://files.pythonhosted.org/packages/97/40/317206653f2afbfc89ecf5e896bdce4ce26e11a78c87ca51a49bef73be29/pikepdf-10.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3311c44a8553f2c57bce37cc764df2a9c07ecc43a39690f7fc10c7fe081083e", size = 3638153, upload-time = "2026-06-19T23:53:13.692Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d3/cfdb8207c3e6906853f04d857eb9dbc6cc98624d17481360c0b7cca2d4db/pikepdf-10.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b7a5ff6cab6b2aed3058e362ec64d41f0c03e842eeecf9a5af30e21988ad6de0", size = 3836156, upload-time = "2026-06-19T23:53:16.093Z" }, - { url = "https://files.pythonhosted.org/packages/28/9c/4a7a588c748431b91dc617cd3dbbb79387d8a10fecc8a363151176df450e/pikepdf-10.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:6fc3096e88d8f030252a43c44c808a89c2c9905988f0d66b72b5731f8a9e3a6e", size = 3296374, upload-time = "2026-06-19T23:53:18.635Z" }, - { url = "https://files.pythonhosted.org/packages/36/a8/551a73c191c8742fd0436051ed542b245aeea517eb7ee2d91215b9a4047e/pikepdf-10.9.1-cp314-abi3-macosx_14_0_arm64.whl", hash = "sha256:3f9c79dc03ee8a44b8c4265baced60b8dd2c79bfa447a22eec67f9deade5d535", size = 4428390, upload-time = "2026-06-19T23:53:20.736Z" }, - { url = "https://files.pythonhosted.org/packages/d9/c5/6b6d0d204bd51517e4240a349e5b3c1d83aca096aa6b115878385ed7e67c/pikepdf-10.9.1-cp314-abi3-macosx_15_0_x86_64.whl", hash = "sha256:cfb8146c396d4d8a92f20c72df0ec94a2ef61770cece29d27821b0fa2f52ce1b", size = 4748541, upload-time = "2026-06-19T23:53:23.153Z" }, - { url = "https://files.pythonhosted.org/packages/84/30/e4d13bbdbe726be9546e8ce6d81f16a650f9786e9fe8d3ba9eb8d4055455/pikepdf-10.9.1-cp314-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbeae48b65b8e9ed28419ebdd52b9cec7bf60a52c984d311b6df2458c3eee62", size = 1997375, upload-time = "2026-06-19T23:53:25.871Z" }, - { url = "https://files.pythonhosted.org/packages/cd/64/b214a90fcfbb653d8ec5c33501028d163a3ecf51e267a72f0c5c363d328d/pikepdf-10.9.1-cp314-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c8d19462732f782d1ddd1c556fec5195453b3fc758f4d4c83d7294700cc496e0", size = 2190496, upload-time = "2026-06-19T23:53:27.996Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4c/627c73edf668113f837faa603cfba4140ed2836981547025abb8fc238526/pikepdf-10.9.1-cp314-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0c85627cd6122d5cc734069793ebfdbeccd60679cbd2c241bc7a199797cf802d", size = 3635409, upload-time = "2026-06-19T23:53:30.268Z" }, - { url = "https://files.pythonhosted.org/packages/8a/16/83df0c82a2ee31e7b47e1cecbe8ef9de66b55642833f308bde559dd469b0/pikepdf-10.9.1-cp314-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d1d0c635368b1cd14e88e6a5a007c5271c30d1798d88452de0e88e4d6ee92335", size = 3833699, upload-time = "2026-06-19T23:53:32.46Z" }, - { url = "https://files.pythonhosted.org/packages/ec/75/4bc9beb1de126f4db1b6ac807852e3db212ba884e94b85c7552fc5735204/pikepdf-10.9.1-cp314-abi3-win_amd64.whl", hash = "sha256:cd6c12ed7a1d8327b1279d8d14890aedc10090be38114f5a0893026f217c06aa", size = 3389178, upload-time = "2026-06-19T23:53:34.67Z" }, - { url = "https://files.pythonhosted.org/packages/ca/38/2bec744893aa2843af18a9bb03475dc193b9ed392ec49420946cd3c441e9/pikepdf-10.9.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c748df16d09742e34ea3dd7e44ad64f88331d2cddf62d4d73911bc4193268f62", size = 4434676, upload-time = "2026-06-19T23:53:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/dd/f2/a9b6a46b6610ed20484e1a245a4039738359448b471855de352074c972d3/pikepdf-10.9.1-cp314-cp314t-macosx_15_0_x86_64.whl", hash = "sha256:99d1f8fe53fa5c9d78a8c7c2ea4a7aad228e1b7b0571287de7f9decf0b5561c1", size = 4753523, upload-time = "2026-06-19T23:53:39.888Z" }, - { url = "https://files.pythonhosted.org/packages/da/0e/53f7a73ad0344387254c938aa39b359a9b9979341c2dd14a7348e1ecab40/pikepdf-10.9.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee509c16b86e007d205df2083b1012ec75c825fd62fdd90aca292fd4c7a4b213", size = 2004802, upload-time = "2026-06-19T23:53:41.876Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2c/13f41886d7e73fbe9056fae291a4d883fff9f37879c98b8ca36f89b1bfe5/pikepdf-10.9.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91756746333a30ef286b8d3ead7a1382f6abb067519f3841c4a6392773059309", size = 2196628, upload-time = "2026-06-19T23:53:43.938Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f1/a8f44aa54d766a547160e29b0f3358ad1d6bc7c4b1bdfac097a470ad544a/pikepdf-10.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:06584062796a2bdc201cb76c1452a2651240c7140102ffb00cde4e4808b84a21", size = 3644233, upload-time = "2026-06-19T23:53:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/a2/1e/5a1493dc73b82f3721ec75dc0abeb8d357b8dee6a1abd6edc8ca75d874c1/pikepdf-10.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8534f300fe4d1fcf311aa6ea073284d414e4a9c0564c38c0ef020f14825fb857", size = 3840741, upload-time = "2026-06-19T23:53:48.09Z" }, - { url = "https://files.pythonhosted.org/packages/5a/03/6a736a6d4be7e45c0b662f9489a519b6298f91621afa78827a5459ee5983/pikepdf-10.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8e470a1470a809da83643c30e2e05ba629131b0193d32bc91f28c7b18cb8b23f", size = 3420520, upload-time = "2026-06-19T23:53:50.276Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/95/e7/45265a52196e7b1e4d1fdee57159d25670275784f5eeb3651327cf68e137/pikepdf-10.10.0.tar.gz", hash = "sha256:9f134806b2fe608ccb21379a664ddcefeac3f6944100d343b350299d3c69754e", size = 23886210, upload-time = "2026-07-10T05:35:11.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/94/a30157ea052c9f8c8c5fee97dceba09f374dd564ed91fa8b8d9c48cfb622/pikepdf-10.10.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:ccfa2288bbc206bfe7197f8614ee20de902e511ff968da86bebf814280124156", size = 4454121, upload-time = "2026-07-10T05:34:09.054Z" }, + { url = "https://files.pythonhosted.org/packages/bc/86/313554efb00675f6e9b75bf012368e7c114174680f1c13035eba6e4a75de/pikepdf-10.10.0-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:19c14b76a240f3cd067abc77b62843268d56dce45b7787ea70fd8cceb11cc5dc", size = 4774365, upload-time = "2026-07-10T05:34:11.251Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b6/0cceaa5a1084730154e8e948b181d2eee2f7f2f3a110b919263ea29e4a0a/pikepdf-10.10.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81c938b33db9f32b7f419f06dbb97eca3e2629c57c0b18a7b719d81563c48d3f", size = 2025196, upload-time = "2026-07-10T05:34:13.727Z" }, + { url = "https://files.pythonhosted.org/packages/46/b0/fc1e0e3f0a6a652f71387c4f9061457202369e98074def130f15173a7a6c/pikepdf-10.10.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:feccd53d5620c908fbb79399b2daee9103dee27d744bc80946daca22500be90f", size = 2218460, upload-time = "2026-07-10T05:34:15.479Z" }, + { url = "https://files.pythonhosted.org/packages/7c/94/f2447204b7f63e25ec43d9641347e0736fa540b495b61315c9dceca89f03/pikepdf-10.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2333431f739205de480e547fab5dcc512668ea4af8f3f02f0d620730413d3772", size = 3663344, upload-time = "2026-07-10T05:34:18.302Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b1/d318daa40e36b1beea2faec63bc4db8d246e0945279af4b3583274edb426/pikepdf-10.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ab09282067a6e95cf0b997df020c38bb0474ed2af99edaa472ab90ef1f18929", size = 3861830, upload-time = "2026-07-10T05:34:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/4d/fb/21b6c283a216864f185fe124745bad1b72270aa0ec0912e8bb6691c16326/pikepdf-10.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:e4845031acf29026a597a207c6a07b471577e8db3b30bac172ab4ac6b4ee4be7", size = 3320968, upload-time = "2026-07-10T05:34:22.357Z" }, + { url = "https://files.pythonhosted.org/packages/da/8b/31c62f96479bb4168fed80541d8bc037bdf832e1751aef50ab62f65121ec/pikepdf-10.10.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ebfc8e36608c01271fdeec6c08e1c87148ac9c275766876e9ac1b2c91879a0db", size = 4454123, upload-time = "2026-07-10T05:34:24.573Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/fb00002df0479806408becc7ce658145269a87aa0c5355bf11371288e81d/pikepdf-10.10.0-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:e2eb3faee72fc74352e481fa174b4f0c7c2ed8516aed5bf73f9dfb7a57c3212f", size = 4774307, upload-time = "2026-07-10T05:34:26.51Z" }, + { url = "https://files.pythonhosted.org/packages/45/07/310138761c00d1fe19e6d16b81a02ec32627343a171eaca43c4f8b74054e/pikepdf-10.10.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4851b2b5c80916c0b57b9928b7fd9d1565f68c5e9c75a6304876cbd103812eed", size = 2025133, upload-time = "2026-07-10T05:34:28.536Z" }, + { url = "https://files.pythonhosted.org/packages/2d/0b/4e5271bdd40041f1b7d81cd3879003d84ece955e502f18567d4c26926e58/pikepdf-10.10.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3e725ffe276b5ab86734b2a5f7025f3e1cb3b1d26063a39adc7490834846449", size = 2218319, upload-time = "2026-07-10T05:34:30.618Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bb/361bdd9b030b974e2eee6d886ae529d3fced1378fd75b2b087ad87aa926c/pikepdf-10.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:425a76bb45ee713dbbee5b524f179f6797fd768f26d1bcf6049cc7181d479ec5", size = 3663292, upload-time = "2026-07-10T05:34:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c0/0c6e903a55cf1672921bd519823c2ea2f9ee421c5fa4b8371d2ef0b1c6c5/pikepdf-10.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d1a781fca5ad09b6ee964e2971819f82f21218636b3a5bb62fb6c89816f2b88a", size = 3861541, upload-time = "2026-07-10T05:34:34.446Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e3/6d17f19fd3c7c5d84c14d29906bba2897e739eaedff5b83e3a7cea6961d6/pikepdf-10.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:5b4460b6c610d89a1e3f52d2f48b811216b02cd18aee5c853e2cf2e8551b1ecd", size = 3320900, upload-time = "2026-07-10T05:34:36.578Z" }, + { url = "https://files.pythonhosted.org/packages/25/49/c181448d33d533003aaf7e688448355a2ffc0de132fbaa650ec6be2667b1/pikepdf-10.10.0-cp314-abi3-macosx_14_0_arm64.whl", hash = "sha256:84481f8768a9bcbd0bd490fa1b9d737bd50ef6e4c1651115b2df133c90ca229e", size = 4454276, upload-time = "2026-07-10T05:34:38.615Z" }, + { url = "https://files.pythonhosted.org/packages/8c/14/6fcaf8f95b21e167faa9095850cafeb0cbc24c8eda7e91ec288dfb1a2906/pikepdf-10.10.0-cp314-abi3-macosx_15_0_x86_64.whl", hash = "sha256:3867848e8dcca701be50464ead7e06a43a8c064ac48fb6d26faee79336e86f95", size = 4774492, upload-time = "2026-07-10T05:34:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/7a/93/5b9d3104dfa1671c6d476c161ce728176787090a4137457801d40132e6f8/pikepdf-10.10.0-cp314-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3056dbbac60d97529560d3baecd9450c03683f4d9e72e5fb4a0c5aa50f01f3cc", size = 2023029, upload-time = "2026-07-10T05:34:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a0/f55e196e18c238ec6ba88f377ca24da3af2754ad47bc284213420d101ea0/pikepdf-10.10.0-cp314-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33adc935f6e0a195e1c1aa1e0c47047d6a29835d5643d07dac9c98996a619e05", size = 2216226, upload-time = "2026-07-10T05:34:45.633Z" }, + { url = "https://files.pythonhosted.org/packages/e1/48/3724e868913669b5a715ad6bc371986d52a99e5d9747e652da29559b1b65/pikepdf-10.10.0-cp314-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc7812a9ac54442fed4cd370e61d9fc294fa05577d14c13087c310eb8cafa1c0", size = 3660978, upload-time = "2026-07-10T05:34:48.019Z" }, + { url = "https://files.pythonhosted.org/packages/de/7a/ea908e0a1daadbded49d74823b62a16bf0e2b1991f17be7c57fe269db44d/pikepdf-10.10.0-cp314-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a094ce9636652f5aff8d62c004383f13cdb4312d2d854b6096bb6e15e3c0a269", size = 3858870, upload-time = "2026-07-10T05:34:50.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/2a/5d86ec58d8ef6e295807c99196f4ef795aa89d04abde4837bcbfb4b0785f/pikepdf-10.10.0-cp314-abi3-win_amd64.whl", hash = "sha256:66567891906906e8733f1ef19cd133f46f34349cb415c858ee64db807d82413c", size = 3414578, upload-time = "2026-07-10T05:34:52.771Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2e/34a171f0789ebc029531ba4c7a54109eedd3f63ebd024ed1771309b0be3f/pikepdf-10.10.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:d29487188c9f4aa9dd6decd403f76b4cb23e886c701dcd4cac508c257dbeac0b", size = 4459828, upload-time = "2026-07-10T05:34:55.096Z" }, + { url = "https://files.pythonhosted.org/packages/f4/92/8eb3fa7ae994ebe6bc3628ab867b5b000f77e3480f180e68560830ce4218/pikepdf-10.10.0-cp314-cp314t-macosx_15_0_x86_64.whl", hash = "sha256:e19fdc44c2e71cb5a150b270f0602aecabcd2432e6f9eef6f24742ff4d498b20", size = 4781116, upload-time = "2026-07-10T05:34:57.352Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9b/b45e3b96842053ac0c8d0d0d2cefbc3e86aa9625bddd72e66f564e79220b/pikepdf-10.10.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1d830217af7a179984f156bc6d2dee3b2f6662286f993554e53b4f73452932", size = 2029491, upload-time = "2026-07-10T05:34:59.818Z" }, + { url = "https://files.pythonhosted.org/packages/65/e2/c0661a580d52c2294a6480cf63dc48695ba38bb611ad60959359d7e46a73/pikepdf-10.10.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c2ba4bafdfbfcc417a38f817e317be905e749893104c5bfa02eb29010c3c8d5", size = 2221465, upload-time = "2026-07-10T05:35:02.264Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/ea3456ee4c2957ad8f27b315b0f215f39c2f6284ed41aa1748393296692b/pikepdf-10.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3670a7eb6e1263750a50cbf01b947f0a5fefd1a43a1a7aa09e2752cc54c7f80d", size = 3667441, upload-time = "2026-07-10T05:35:04.801Z" }, + { url = "https://files.pythonhosted.org/packages/24/f4/76332de5a6755ab0910d89cc64d1a621a09821b46383ae664afd0c5396cb/pikepdf-10.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4dd93d81e77e67937d76592ae3438ad80b2b6c9619846fbc400dd9d3e71cc689", size = 3866534, upload-time = "2026-07-10T05:35:07.01Z" }, + { url = "https://files.pythonhosted.org/packages/51/ec/a249a101d8e254b887353e6c98dad29f6133fb550e593c28cc47811ee0b4/pikepdf-10.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:616adec2b1e3e2cf0640c8dc0f479ed9a07db298e32166ee7b60fb8b33dc98af", size = 3446758, upload-time = "2026-07-10T05:35:09.106Z" }, ] [[package]] @@ -3256,11 +3256,11 @@ wheels = [ [[package]] name = "rarfile" -version = "4.2" +version = "4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/3f/3118a797444e7e30e784921c4bfafb6500fb288a0c84cb8c32ed15853c16/rarfile-4.2.tar.gz", hash = "sha256:8e1c8e72d0845ad2b32a47ab11a719bc2e41165ec101fd4d3fe9e92aa3f469ef", size = 153476, upload-time = "2024-04-03T17:10:53.798Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/80/442df102d81a10d31ec2cf73d6f411f5575e3bb7adc0aaa45cbe693a37b6/rarfile-4.3.tar.gz", hash = "sha256:0d8b5a7ffb9f8e9a7b002f2398905e4420c0cb373e799b48e48f418db9c8816a", size = 154855, upload-time = "2026-07-08T19:21:54.072Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/fc/ab37559419ca36dd8dd317c3a98395ed4dcee2beeb28bf6059b972906727/rarfile-4.2-py3-none-any.whl", hash = "sha256:8757e1e3757e32962e229cab2432efc1f15f210823cc96ccba0f6a39d17370c9", size = 29052, upload-time = "2024-04-03T17:10:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/4a161de993dab0baac89b8c5b9765f420e8cc5239c5c82e0f51cb41e5a74/rarfile-4.3-py3-none-any.whl", hash = "sha256:f9dd2f8bd811b2e30fb074aa176f972f51633aa41cf909db3f2921ed040e1128", size = 29583, upload-time = "2026-07-08T19:21:52.904Z" }, ] [[package]] @@ -3279,90 +3279,90 @@ wheels = [ [[package]] name = "regex" -version = "2026.6.28" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/05/e4f219230e11e774a6c9987d2ab0d0c6b8573e13a17e143d0015bee710ef/regex-2026.6.28.tar.gz", hash = "sha256:3cb4b6c5cb3060cc31efdc1fbb27c25fb9b29044afd87e40601a1c4d9db54342", size = 416101, upload-time = "2026-06-28T19:56:55.302Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/21/44aa415873032056c43eac21c67285deb2cf66cddb2a964c3cdc8f803efc/regex-2026.6.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:81cc5793ad33a10444445e8d29d3c73e752c8fb2e120772d70fcb6d41df40fe1", size = 490480, upload-time = "2026-06-28T19:54:05.392Z" }, - { url = "https://files.pythonhosted.org/packages/8b/5f/30d4116093c2128099f78b6990dfc1698fdbf3ee528f1e1c647378034c79/regex-2026.6.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e18225243250a1f7d7e5e5d883f3b96465cd79031acf5c6db902b7025f2125d9", size = 292137, upload-time = "2026-06-28T19:54:07.088Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/ca20a0e0de49837e6337603a91ab77556aa27033ac5b975615d98698cfb3/regex-2026.6.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ecd1638b1c2db1f2d01c182a4b0d3e2e88b0e99910320a745c1727ee3638ddab", size = 289623, upload-time = "2026-06-28T19:54:08.762Z" }, - { url = "https://files.pythonhosted.org/packages/50/11/c013422a7e2c59946df8ac93e792a4922c98287f2a2181341603c78a5d98/regex-2026.6.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4303ebe16b74eeb3fe2715745023266fea92fd44a23f3e7bb2fb48c7a7bbc195", size = 796756, upload-time = "2026-06-28T19:54:10.616Z" }, - { url = "https://files.pythonhosted.org/packages/b0/95/1309645a0e1ee6fb91d954501da57a0b33d50ad2a9acb313702851a7054e/regex-2026.6.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56b856b70b96c381d837f609eee442a1bd320cd2159f5c294b679552fb1a7eaf", size = 865465, upload-time = "2026-06-28T19:54:12.742Z" }, - { url = "https://files.pythonhosted.org/packages/20/06/491802db47c6f5e2904ffa2518ad3ac27fe6bbf5a66d73210a95cc080d47/regex-2026.6.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f74675ab76ab1d005ffba4dee308e53e89efc22be6e9f9fae5b539a3f81bdff2", size = 912350, upload-time = "2026-06-28T19:54:14.508Z" }, - { url = "https://files.pythonhosted.org/packages/5e/60/3ba57840bcc7e2367090360de0c15a5ba6ad22be89314251105f2e943f43/regex-2026.6.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90581684565a93f7258af1e5d3f41ef20d7d7c61f2a428183a342bcb65485e38", size = 801261, upload-time = "2026-06-28T19:54:16.432Z" }, - { url = "https://files.pythonhosted.org/packages/eb/27/af1eb74e9a78c782b3e450b611a595e44906da8a5107e1227f4a7fd0480b/regex-2026.6.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:28f9e6c28f9b90f6f784595a33240a57e181e61b6ee3dc259b25c61e356d1aa3", size = 777072, upload-time = "2026-06-28T19:54:18.128Z" }, - { url = "https://files.pythonhosted.org/packages/20/18/fdd4c883a39e3ed00d669062af1135809bfd3281bf528150849fbd68825b/regex-2026.6.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:378a71d861fc7c8806b04ac5b133d53c0e774f92f5d9663a539872d3fa2b0417", size = 785119, upload-time = "2026-06-28T19:54:20.314Z" }, - { url = "https://files.pythonhosted.org/packages/1c/79/0aabe34b8482dcadf64355f70f96e22eba5ec6c1efb33563f89654f4061c/regex-2026.6.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc199874ecd6267a49b111052250825bfe19b5101b23b2ba80f54efa3e0994e", size = 860118, upload-time = "2026-06-28T19:54:22.368Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2c/c973323306a27c9db7d160e9584eb7e0ece2a96224ccb0d39060558b31f9/regex-2026.6.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b916a10431494ef4b4d62c6c89cab6426af7873125b8cd6c15811bf5fc58eec8", size = 765786, upload-time = "2026-06-28T19:54:24.265Z" }, - { url = "https://files.pythonhosted.org/packages/e3/df/9ca3e378e352242a4cb45573a5e9162c3ee791507702a23966fa559e36b5/regex-2026.6.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2e27727fba075f1e4409416d2f537d4c30fc11f012ea507f7bd74d3e19ecb57a", size = 852120, upload-time = "2026-06-28T19:54:25.972Z" }, - { url = "https://files.pythonhosted.org/packages/a2/3e/3e31e255c4971f53cbce6306b5e3c76cbd3735a54f419bb3b2f194e9f68c/regex-2026.6.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:700fc6a7844bb2c4149292ac79d1df8841a00acd4d45cd32c1ebc7bcc1fd0da8", size = 789503, upload-time = "2026-06-28T19:54:27.678Z" }, - { url = "https://files.pythonhosted.org/packages/72/01/d36561c21c3033d7eeb31d51b491916817de7861acefccc5fc9db8a5037c/regex-2026.6.28-cp312-cp312-win32.whl", hash = "sha256:03376d60b6a11aecb88a79fa2be06b40faa01c6693bc31ef69435cd4818b9463", size = 267109, upload-time = "2026-06-28T19:54:29.316Z" }, - { url = "https://files.pythonhosted.org/packages/a0/59/bbbb0591f38b18c65977cd65ce64749eba1c1996c99ac04e900fc30c0dcb/regex-2026.6.28-cp312-cp312-win_amd64.whl", hash = "sha256:fbd2ded482bf99e6651992bbfcde460272724d4bbc49ef3d6b46d9312867ec84", size = 277711, upload-time = "2026-06-28T19:54:31.143Z" }, - { url = "https://files.pythonhosted.org/packages/86/06/be4f6b337d773ae5739a1bc238f97c16926e72017243735853c030f4c628/regex-2026.6.28-cp312-cp312-win_arm64.whl", hash = "sha256:37294d3d7ddb64c7e89184b2894e0f8f0a19c514bc59513d71fe692c3a8d5fc6", size = 277022, upload-time = "2026-06-28T19:54:32.97Z" }, - { url = "https://files.pythonhosted.org/packages/b6/53/d5c1b3cc0b5a0c985563ad6fac93d73ff2b300cb84342d89f044625d6bc7/regex-2026.6.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b295a83426e0e44e9e60fde99789e181bd26788a1890ae7fe2a24c69bb6246ca", size = 490329, upload-time = "2026-06-28T19:54:35.775Z" }, - { url = "https://files.pythonhosted.org/packages/8d/9f/0c3503e819e91ca0e7a901a8e989ebf840ac7c7aea20b1fc7f31b6759f77/regex-2026.6.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c31665c0deb5c111557a1cac8c27bd5629e2f9e7fd5058900a03576c33b601c", size = 292039, upload-time = "2026-06-28T19:54:37.977Z" }, - { url = "https://files.pythonhosted.org/packages/bb/7f/cd004e13fcad23b3794a82307dfd222e6365eb7f598bd3caab148a830bff/regex-2026.6.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6bf295f2c59de77d1ea7de053607ae4dc9ceb3d57bbb6c7ec51ef4acc4ccff94", size = 289488, upload-time = "2026-06-28T19:54:39.545Z" }, - { url = "https://files.pythonhosted.org/packages/73/4c/293fb34586fbcdc47eac436069e9c11f71fae5dadfd4889b475d7d2e5f7a/regex-2026.6.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17c077586770f67e05bbffeba07fbee6b2b22244f4d4caf8d94e59d574befe04", size = 796772, upload-time = "2026-06-28T19:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/92/fa/c0cd1a90b7d12d9dc155cfc8bdea8df9720988ea5b07e8fa1eccbd0ab2dd/regex-2026.6.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e6cb5a61486f9062397d2e189573b39d38ecfaed698fd9fb6e2756a8ebb8762", size = 865467, upload-time = "2026-06-28T19:54:43.485Z" }, - { url = "https://files.pythonhosted.org/packages/4e/db/0b479973046d005a1eaea299d5d536aeecb9488a16d9cbb8286338102e2d/regex-2026.6.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e86e91a2664f44c3a4e363a7d78fb17c27d5046882e30ea5a877f5e89b28d2ba", size = 912345, upload-time = "2026-06-28T19:54:46.091Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5b/d65adfbd02f32212431bca1f06d1e2eb763a20b12978b454bafaf23dacb7/regex-2026.6.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4dfd1331c49233998d84fc5f1f4436cf7a435a7655f6cf0f490229bb5c7254e5", size = 801291, upload-time = "2026-06-28T19:54:48.3Z" }, - { url = "https://files.pythonhosted.org/packages/fc/09/2103686defaf9a0a31c1663782359d5b45f42524c64cca681f5481e44a5e/regex-2026.6.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cadea12805a1bce0b091c302b814207be26fb60a9c0e7f9ad2f9e21790a429fe", size = 777106, upload-time = "2026-06-28T19:54:50.326Z" }, - { url = "https://files.pythonhosted.org/packages/85/5a/b57593c0aa23ed269ec332fbcf07852abcb6b746e811d9464e0d09b4e25f/regex-2026.6.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f2c1682b67ad5d2376498f2a5a2a8f782fa2e4a06d0465b5e357799806e8a20", size = 785175, upload-time = "2026-06-28T19:54:52.172Z" }, - { url = "https://files.pythonhosted.org/packages/79/59/c36e756ad29bf14d7b6c6d7138952476b21f6160286cedb98ac13481c993/regex-2026.6.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:64e142eb55e84868087da1375d7c36ff97d55010951849f515322a91d5fef1b4", size = 860186, upload-time = "2026-06-28T19:54:54.11Z" }, - { url = "https://files.pythonhosted.org/packages/61/66/49808aea0da9649c300139360708fb91b7144be1f962fcebf96755fde948/regex-2026.6.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:abb4daabe7be63273787a62dfd6164dadf8f7a63fbec3d2730e5e5e7126d858c", size = 765754, upload-time = "2026-06-28T19:54:56.04Z" }, - { url = "https://files.pythonhosted.org/packages/be/c5/52bbd436cf2200decdf48825fa38363eaaeebb77011ea9928a1ef9e0b9f2/regex-2026.6.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec2b2ad00ab8c16a2798cc8db80c53c4d5b8b3a2441f6cbaef06625f5ca25854", size = 852085, upload-time = "2026-06-28T19:54:57.988Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c3/0390b66e3019497143fe768b3ba567b64d8b24f3812d09506deb86f4a0f0/regex-2026.6.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bfc9677982c914d9085b8e1c3b3ae6e88f139fb56531c2416d6c8f338093c22b", size = 789600, upload-time = "2026-06-28T19:54:59.977Z" }, - { url = "https://files.pythonhosted.org/packages/88/fd/ab5b03653a244975069fed93d73f4f5f7484c03a84cedb238292510d7182/regex-2026.6.28-cp313-cp313-win32.whl", hash = "sha256:bf54bc693fc4e0530e666ba5ec4bcba14dbe8f66b7cfc15c27317d1a6e40b9a5", size = 267088, upload-time = "2026-06-28T19:55:02.159Z" }, - { url = "https://files.pythonhosted.org/packages/68/55/21022f7d3143210ae8d4ff905c45306237b657375cc0b97883f49db3d423/regex-2026.6.28-cp313-cp313-win_amd64.whl", hash = "sha256:e128feaf65bf3d9eb91bec92322a8f7e4835e9c798f3e9ea4b69f4def85620e3", size = 277680, upload-time = "2026-06-28T19:55:04.185Z" }, - { url = "https://files.pythonhosted.org/packages/b6/99/7f664804f1aef924542b0b233996b78b3e4d0a52d9951358aac99f129f51/regex-2026.6.28-cp313-cp313-win_arm64.whl", hash = "sha256:695873e0ea8d3815ea9e92e2c68faf039cc450e2c0a62a31afe2049eb11be767", size = 277017, upload-time = "2026-06-28T19:55:06.29Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e1/9eb83518e159d719fd681c4932dc2aaff855ce72451e1d05d69466f25a96/regex-2026.6.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:189dbf9fc4252d9f1352bf4bd1bef885edb6cc4b7341df202a65f821aaa3891c", size = 494195, upload-time = "2026-06-28T19:55:08.292Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e2/e259c5f2f7be269d0e2fb54275c1fa6a13fb47019f389c3f3ae457447825/regex-2026.6.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9277a4c6503390aa39cb4483b87ec0384faee0850a23b5cea33d008b5d8d83f1", size = 293976, upload-time = "2026-06-28T19:55:10.014Z" }, - { url = "https://files.pythonhosted.org/packages/8d/4e/9bdf444014d22b045d0c82ca114fac7e07a597b5b5331b7c4ce6328426e2/regex-2026.6.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:17eddca4e8ea9af0b5739314776cdf0172a49731ab61f2e1ea66e066ddd46c97", size = 292340, upload-time = "2026-06-28T19:55:11.88Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3a/f49b11e59cbfe187ace0053a460bd72a0169b8cd52e7db9421a074ce7a43/regex-2026.6.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4466b8641e00c697aab5a73150150d2b2ea96b131c595691f42031abafd9f4d", size = 811704, upload-time = "2026-06-28T19:55:13.612Z" }, - { url = "https://files.pythonhosted.org/packages/2f/fb/ad04c39e149bf8b6cf357df5fff78341733ec366780a00c803a36735818c/regex-2026.6.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cfcd4b0bdcf768c498415c170d1ed2a25a99bf0b65fa253bbd02f68ceba6475", size = 871157, upload-time = "2026-06-28T19:55:15.797Z" }, - { url = "https://files.pythonhosted.org/packages/7f/64/0e5ba31c11eb8ef7aac19a690c1211fc9aa9990caf09565785ebb0081b9a/regex-2026.6.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:80c7adf1ef647f6b1e8aa2ca280e517174cd08bdf7a2e412cdfb68bd6a0917cb", size = 917287, upload-time = "2026-06-28T19:55:18.692Z" }, - { url = "https://files.pythonhosted.org/packages/11/75/6b78df2b858c2fcbbc4858fdc3f2975cf2703be374b2842db7d2c32591a7/regex-2026.6.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a043f5770e82283a22aed4cefef1a4e0f9dd8fd7184cb6ce0ad2e579e2134a9e", size = 816333, upload-time = "2026-06-28T19:55:20.973Z" }, - { url = "https://files.pythonhosted.org/packages/b4/01/ecfe665a3694d5eda9f3ec686c856438ada0943947b6005e90556a1e2cdf/regex-2026.6.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3bd630a8dba06b55254ea5ee862194edab52ec783100d2ef1cd15a9c512fee27", size = 785518, upload-time = "2026-06-28T19:55:23.003Z" }, - { url = "https://files.pythonhosted.org/packages/b4/0a/88f9cd88ff1e82881605c4ffd62d77ee67d051232cfe6f8e9a64b86cf0e8/regex-2026.6.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b77207e3cee13086f1906a6a2a12b41244c577e8ad9370d4b35ae1d548d354f3", size = 801371, upload-time = "2026-06-28T19:55:24.888Z" }, - { url = "https://files.pythonhosted.org/packages/a8/97/601483732f93275482ceb9fed57813dfed7c47d3a019db6ec4a3bb6e23e0/regex-2026.6.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:6de82c268e5d101ee9e3ffd869924aa9a371e3a21e752cf4fa17b6ce50d219f7", size = 866517, upload-time = "2026-06-28T19:55:27.232Z" }, - { url = "https://files.pythonhosted.org/packages/81/ed/385c2a0351b994a693453c1d1a6e9af9eb35db3c9460d76b5078acd70c62/regex-2026.6.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b15859e3908544fb99cf47341dcf0bfd089147d258c4c4d8a29e5b087f8085cb", size = 772834, upload-time = "2026-06-28T19:55:29.154Z" }, - { url = "https://files.pythonhosted.org/packages/06/bc/bbf4a5b3b29770d7f307d3c28b5b1bca0105b0cb424be0a4eb1339bc92cf/regex-2026.6.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:c91487a917edd48a1ea646fdf60d7936d304f0e686fa7ea8326e47efca51d816", size = 856606, upload-time = "2026-06-28T19:55:32.186Z" }, - { url = "https://files.pythonhosted.org/packages/28/26/51d74fff82f682819979249f8d700267108ba5dc4eb284b0e11b9c85e4b3/regex-2026.6.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4ac65f3e3a99fd8f3a4a74e7a6610acd1ce9dfe9b8a03d346a4922380d68aeb", size = 803475, upload-time = "2026-06-28T19:55:34.328Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3e/6be10cefdc813533fe604dbf5d3c77d2638e7ee658b2749ebadc113b6b2e/regex-2026.6.28-cp313-cp313t-win32.whl", hash = "sha256:3f6316f258bc7e6c9c2acbe9954947bbd397a81be3742a637a555f1855d6618d", size = 269126, upload-time = "2026-06-28T19:55:36.565Z" }, - { url = "https://files.pythonhosted.org/packages/3c/3c/32cda905ea1a6eeeb798291c294d8ec66ee0efe0cdba28b061e248b1d396/regex-2026.6.28-cp313-cp313t-win_amd64.whl", hash = "sha256:1484bdd6fba28422df9b5ebb04055b2e1b680e8e4f08490bb21ff0f3cc50d0ab", size = 279961, upload-time = "2026-06-28T19:55:38.456Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b9/69f4e5cd6fbe0bb420cb2dbae441ca118f2495bdda522a74da75aa9829e7/regex-2026.6.28-cp313-cp313t-win_arm64.whl", hash = "sha256:3f15020f0b69cafe57baa067ff65b29acef68ff6b1670a53bef1ca11d708e02d", size = 279266, upload-time = "2026-06-28T19:55:40.62Z" }, - { url = "https://files.pythonhosted.org/packages/3b/fb/fad3b810a5bb1e09b9e5d6913fc6ba88cab738fdf283196827a3c59a4c10/regex-2026.6.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:f7c032b0c8a73739ff8ff1aaf30c281fa19c17bf7f1543256c8507390db7807c", size = 490407, upload-time = "2026-06-28T19:55:42.724Z" }, - { url = "https://files.pythonhosted.org/packages/d6/52/b8c79d12276d93e90e707e939b396034c04980caf1235312ef790f8e11fc/regex-2026.6.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f6710f512c57b84f127a23d0f59560a03b64136eff419ae1be5ab557577fe5e3", size = 291988, upload-time = "2026-06-28T19:55:44.549Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/6a911f18279daa8d7bb8b20d771ddb6ef31fabd35f5921f9d3ba21640e80/regex-2026.6.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c0013958f427bd82509a186b9ff206d66cb8d60a81fc797a4c717afd18c5b0ba", size = 289704, upload-time = "2026-06-28T19:55:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/fd/22/ad1955c47c669291a05804d53d7071cc0732dfdf166857be38003cedc2d1/regex-2026.6.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f06cdcd6421f8e194ad312ea608020381250df9b8a57661c1b57e9e5273878", size = 797017, upload-time = "2026-06-28T19:55:48.166Z" }, - { url = "https://files.pythonhosted.org/packages/e5/67/a83159ff8703ab4d0c2cf99e76ebf289b7b4a501623241d09f88f3614f80/regex-2026.6.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec9689392f7494ff4e3f8e7e8522f9158f11023f337eaaf04a64542fc45bbf26", size = 866112, upload-time = "2026-06-28T19:55:51.047Z" }, - { url = "https://files.pythonhosted.org/packages/b9/09/7bff2d6dbbd77421b3274aa51db1c887381cbc5b6eda93598c3e882ea345/regex-2026.6.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aa084684e6d2078bf6139e374d1fc2af5ddc1ac7122759a2db716d68169f6fd0", size = 911554, upload-time = "2026-06-28T19:55:53.707Z" }, - { url = "https://files.pythonhosted.org/packages/29/44/ae59c3826e7ba492e56795cdf74ea2a7b5b7c5ea116afb79ee4956a5dff1/regex-2026.6.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40455e6840dc4e96a6fe50f4cedc957de2752c954d91e789812be55d49be199a", size = 800665, upload-time = "2026-06-28T19:55:55.875Z" }, - { url = "https://files.pythonhosted.org/packages/d6/19/6fd033d2ab00f35d445aaeaf3307c1e721424dcbfd48f6f65c857cb939cf/regex-2026.6.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:530b5c223b9ca5dd8370ac502e080aee0e4ded32be987c6564b425fb5523d581", size = 777243, upload-time = "2026-06-28T19:55:57.909Z" }, - { url = "https://files.pythonhosted.org/packages/4e/9d/99730f26df4938049ab1e652ca75e967b4c6739444e18d9707bfdb8af20c/regex-2026.6.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e0ed273ecd1a89be84466c1749bfe58609cc2a32b5d5e05006c4625ba96411b", size = 785784, upload-time = "2026-06-28T19:56:00.072Z" }, - { url = "https://files.pythonhosted.org/packages/48/49/105cd57162f5fc5c04cc917a1388a060cf8427e5c14353cd9044660fbf4d/regex-2026.6.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0ab0d5344311fc8e8667078942056c3b9c9b4a4b1cc99f2eb8a5af54554f4acc", size = 860914, upload-time = "2026-06-28T19:56:02.017Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a5/788245a95b69018f58bff2f4fd27d007cacaea088cdb390979743f1b2571/regex-2026.6.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:eacb79625323d9f7e7925366b917f492b8356fad58f5dc4fa12ff8c21d8f4ca9", size = 765915, upload-time = "2026-06-28T19:56:05.021Z" }, - { url = "https://files.pythonhosted.org/packages/ca/01/292065a39a004b05e67a337b18213670a7cb919d6856ac2d7df7f1a10dbb/regex-2026.6.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:20f4d87702702aa1d572721e146f301660c50eef6fd6cb596e48a22b0ace17db", size = 851404, upload-time = "2026-06-28T19:56:07.251Z" }, - { url = "https://files.pythonhosted.org/packages/98/9e/a93d865db0e13483ae1a01d81e2ce16d4a7fe2f9b9fe4aac4cc08590b136/regex-2026.6.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e693940a3b9e6d6e4dc2a54ecaa74b74934f77af1ef95f518a74261ef7cc1bc", size = 789373, upload-time = "2026-06-28T19:56:09.894Z" }, - { url = "https://files.pythonhosted.org/packages/82/0c/38b1685ad4017d78efbc8fa7dbbf96d8113b53750c8aa2d3609defd46605/regex-2026.6.28-cp314-cp314-win32.whl", hash = "sha256:234a51e20ebc18ab83b2c0600cf28f2e884560a0e00f743878f0b7d8e7c4cf03", size = 272496, upload-time = "2026-06-28T19:56:11.83Z" }, - { url = "https://files.pythonhosted.org/packages/55/50/e19f261ff9ba9b50722a529e09b1743ecf65eb348be99d0fd2cd7fcede1c/regex-2026.6.28-cp314-cp314-win_amd64.whl", hash = "sha256:7b15c437bc4604f03ceb3f8d37eae2f8930e320e1bc556b259848c639d9eec1a", size = 280754, upload-time = "2026-06-28T19:56:13.758Z" }, - { url = "https://files.pythonhosted.org/packages/36/b8/c9e68f3a9e33be73f20990b2c065b144ff2d0aa242608a950d8c4f3b56e8/regex-2026.6.28-cp314-cp314-win_arm64.whl", hash = "sha256:c6e6f790d01380a74ad564f216c533b86504afb61bf66f2b2e11e7f1a3e287a7", size = 280979, upload-time = "2026-06-28T19:56:15.928Z" }, - { url = "https://files.pythonhosted.org/packages/03/e6/21c425a37880c650d007c4171c6a80325446d830d85f5fbf335e7205b1e7/regex-2026.6.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3527a72adcbe9e3600f1553b497d397c1a371d227580d41d96c3c5964109b65c", size = 494282, upload-time = "2026-06-28T19:56:18.049Z" }, - { url = "https://files.pythonhosted.org/packages/07/50/6647a7ccf5ffff995ba955a0b7d766440f4e58ce1666549c8ee998f2b972/regex-2026.6.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a644f6408692812f5ead82519eed680e08d5d546fddbd9f7d9514e3c73899aa5", size = 293977, upload-time = "2026-06-28T19:56:20.145Z" }, - { url = "https://files.pythonhosted.org/packages/8c/dc/a3e141a4eaf125e50f63105570c01fa477c06ac5259dcfa95e9b90760e84/regex-2026.6.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8e2fae6bb883648346f84db270dc9aafc29d8e895f62b88a75ccc83b09519820", size = 292432, upload-time = "2026-06-28T19:56:22.345Z" }, - { url = "https://files.pythonhosted.org/packages/35/ee/2ac1a6b9f167f8ff69f5a789938cc103b60cff41b24a6990daced8b88e34/regex-2026.6.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:debe623e09cee97ef9404575e936c610aac9bb08358c5099aaef14644a6871f2", size = 811877, upload-time = "2026-06-28T19:56:25.056Z" }, - { url = "https://files.pythonhosted.org/packages/df/7b/9a5505ee92180bcae300b1018b9ff3d3c19962436e66f2505f255e9fde35/regex-2026.6.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc579c91fb4605773483a8d940b136bcc5b854fff44fa14a1572a038f46563f1", size = 871212, upload-time = "2026-06-28T19:56:27.352Z" }, - { url = "https://files.pythonhosted.org/packages/24/4d/d61a702a9f9d1bd29b22cbef1aed6d477baa961232a7eb4d91b7775b0b3e/regex-2026.6.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7c42be203d84ecf7d487ff23f8a61ef0eb0534fa0fc317a2fce8c065d20618f", size = 917507, upload-time = "2026-06-28T19:56:29.762Z" }, - { url = "https://files.pythonhosted.org/packages/d4/60/1308066f5966b65fbb6905b99ba37e9f1cd753dd0ac08485f8257334ee92/regex-2026.6.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8184b4e2fdaf9cdfe77e38f15a4d9dc149168c9c29eb0ea17c5481d3bb80546", size = 816389, upload-time = "2026-06-28T19:56:32.043Z" }, - { url = "https://files.pythonhosted.org/packages/bd/5c/57ce2cb8d714ee0b7f11c7ee4cfe2af66df2b90f147feadcb538609a3a02/regex-2026.6.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:697f103104f5872d64078d8eeac59979960be8ee76115a2d3f31096312e2a400", size = 785890, upload-time = "2026-06-28T19:56:34.492Z" }, - { url = "https://files.pythonhosted.org/packages/ff/fd/1d5350d3a8a327bff0fccacb911732baf7b5b6f5529c0e3fa602a23e7dad/regex-2026.6.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:714d2b1aa29beef0ddfcdc72ad0771c05326551a8bb0680b0ddf74bfaad87387", size = 801451, upload-time = "2026-06-28T19:56:36.749Z" }, - { url = "https://files.pythonhosted.org/packages/f3/79/3c9e4f8a0306e030ad5a43bbbc01625fb28d58a813bc52d42fd1cc63fb2e/regex-2026.6.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0f09f62e450cc2f113018cc8412aeea3a120a04e1ca7e801a0d441583f9a3b06", size = 866504, upload-time = "2026-06-28T19:56:38.994Z" }, - { url = "https://files.pythonhosted.org/packages/65/12/f747de475b54f4709efb24dd0fbc8467c64cec91f5db0d047b079646ee78/regex-2026.6.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:731ea12d5aeb2577eaef2393d6428b995f76eb35f68a89e03e15a97719d1de19", size = 773047, upload-time = "2026-06-28T19:56:41.061Z" }, - { url = "https://files.pythonhosted.org/packages/58/3c/f02f860e0500c1b2d61a79dec7e214b37fb9656281dcddc92397edf96678/regex-2026.6.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:51e952c8783eabd4706d0f63922f219bcfc1bef9b8cb35941c0d1a0396578858", size = 856665, upload-time = "2026-06-28T19:56:43.466Z" }, - { url = "https://files.pythonhosted.org/packages/4d/6c/28b3fa222513484be9dee26b7222bda109056c43ea28aa2314262ca48816/regex-2026.6.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43248fe4c0ab8fbb223588a0795b11268940072c97bba30ea8f9b49d8cdfde34", size = 803573, upload-time = "2026-06-28T19:56:45.791Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/8f86cf1a1fd85c5ab0c503c9fe4607ad4ad48978b2d8b435d94465e134c7/regex-2026.6.28-cp314-cp314t-win32.whl", hash = "sha256:fc1eddc25ad23c0f1344ab280d961ac595ead48292d7c779497975942373f493", size = 274515, upload-time = "2026-06-28T19:56:47.948Z" }, - { url = "https://files.pythonhosted.org/packages/0f/de/f8613c03b36786ddef2c930d28f9bcae861fcd541cc9203a870956cf1e83/regex-2026.6.28-cp314-cp314t-win_amd64.whl", hash = "sha256:ede8d8e53b6dde0a50f7eca902f0af76d87ab02a55aba7542da68ae3e5dfe83d", size = 283650, upload-time = "2026-06-28T19:56:50.614Z" }, - { url = "https://files.pythonhosted.org/packages/4d/f3/f5ec86839bbabe33b6dee649b62ff9a445d43de6b0ad780cf6b83c56f61e/regex-2026.6.28-cp314-cp314t-win_arm64.whl", hash = "sha256:4da6f6a72f8700b97a1a765e837fb7d5750bfd9f13acea7bae498f573e3a70a8", size = 283338, upload-time = "2026-06-28T19:56:52.879Z" }, +version = "2026.7.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/37/451aaddbf50922f34d744ad5ca919ae1fcfac112123885d9728f52a484b3/regex-2026.7.10.tar.gz", hash = "sha256:1050fedf0a8a92e843971120c2f57c3a99bea86c0dfa1d63a9fac053fe54b135", size = 416282, upload-time = "2026-07-10T19:49:46.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/9c/2503d4ccf3452dc323f8baa3cf3ee10406037d52735c76cfced81423f183/regex-2026.7.10-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7252b48b0c60100095088fbeb281fca9a4fcf678a4e04b1c520c3f8613c952c4", size = 497114, upload-time = "2026-07-10T19:47:16.22Z" }, + { url = "https://files.pythonhosted.org/packages/91/eb/04534f4263a4f658cd20a511e9d6124350044f2214eb24fee2db96acf318/regex-2026.7.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:da6ef4cb8d457aab0482b50120136ae94238aaa421863eaa7d599759742c72d6", size = 297422, upload-time = "2026-07-10T19:47:17.794Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2d/35809de392ab66ba439b58c3187ae3b8b53c883233f284b59961e5725c99/regex-2026.7.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fe7ff456c22725c9d9017f7a2a7df2b51af6df77314176760b22e2d05278e181", size = 292110, upload-time = "2026-07-10T19:47:19.188Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1e/5ce0fbe9aab071893ce2b7df020d0f561f7b411ec334124302468d587884/regex-2026.7.10-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3463a5f26be513a49e4d497debcf1b252a2db7b92c77d89621aa90b83d2dd38", size = 796800, upload-time = "2026-07-10T19:47:20.639Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/c1ccbada395c10e334763b583e1039b1660b142303ebb941d4269130b22f/regex-2026.7.10-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:948dfc62683a6947b9b486c4598d8f6e3ecc542478b6767b87d52be68aeb55c6", size = 865509, upload-time = "2026-07-10T19:47:22.135Z" }, + { url = "https://files.pythonhosted.org/packages/0e/06/f0b31afc16c1208f945b66290eb2a9936ab8becdfb23bbcedb91cc5f9d9b/regex-2026.7.10-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c2cbd385d82f63bb35edb60b09b08abad3619bd0a4a492ae59e55afaf98e1b9d", size = 912395, upload-time = "2026-07-10T19:47:24.128Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1c/8687de3a6c3220f4f872a9bf4bcd8dc249f2a96e7dddfa93de8bd4d16399/regex-2026.7.10-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f6222cafe00e072bb2b8f14142cd969637411fbc4dd3b1d73a90a3b817fa046f", size = 801308, upload-time = "2026-07-10T19:47:25.696Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e3/60a40ec02a2315d826414a125640aceb6f30450574c530c8f352110ece0e/regex-2026.7.10-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:65ee5d1ac3cd541325f5ac92625b1c1505f4d171520dd931bda7952895c5321a", size = 777120, upload-time = "2026-07-10T19:47:27.158Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9a/ec579b4f840ac59bc7c192b56e66abd4cbf385615300d59f7c94bf6863ae/regex-2026.7.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa34473fbcc108fea403074f3f45091461b18b2047d136f16ffaa4c65ad46a68", size = 785164, upload-time = "2026-07-10T19:47:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1c/60d88afd5f98d4b0fb1f8b8969270628140dc01c7ff93a939f2aa83f31a6/regex-2026.7.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d028d189d8f38d7ff292f22187c0df37f2317f554d2ed9a2908ada330af57c0", size = 860161, upload-time = "2026-07-10T19:47:30.605Z" }, + { url = "https://files.pythonhosted.org/packages/2a/40/08ae3ba45fe79e48c9a888a3389a7ee7e2d8c580d2d996da5ece02dfdcb9/regex-2026.7.10-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:396ea70e4ea1f19571940add3bad9fd3eb6a19dc610d0d01f692bc1ba0c10cb4", size = 765829, upload-time = "2026-07-10T19:47:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/12/e6/e613c6755d19aca9d977cdc3418a1991ffc8f386779752dd8fdfa888ea89/regex-2026.7.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ebbf0d83ed5271991d666e54bb6c90ac2c55fb2ef3a88740c6af85dc85de2402", size = 852170, upload-time = "2026-07-10T19:47:33.567Z" }, + { url = "https://files.pythonhosted.org/packages/03/33/89072f2060e6b844b4916d5bc40ef01e973640c703025707869264ec75ab/regex-2026.7.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58a4571b2a093f6f6ee4fd281faa8ebf645abcf575f758173ea2605c7a1e1ecb", size = 789550, upload-time = "2026-07-10T19:47:35.395Z" }, + { url = "https://files.pythonhosted.org/packages/e3/3c/4bc8be9a155035e63780ccac1da101f36194946fdc3f6fce90c7179fc6df/regex-2026.7.10-cp312-cp312-win32.whl", hash = "sha256:eac1207936555aa691ce32df1432b478f2729d54e6d93a1f4db9215bcd8eb47d", size = 267151, upload-time = "2026-07-10T19:47:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/9f5aade65bb98cc6e99c336e45a49a658300720c16721f3e687f8d754fec/regex-2026.7.10-cp312-cp312-win_amd64.whl", hash = "sha256:ecae626449d00db8c08f8f1fc00047a32d6d7eb5402b3976f5c3fda2b80a7a4f", size = 277751, upload-time = "2026-07-10T19:47:38.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/6f/d069dd12872ea1d50e17319d342f89e2072cae4b62f4245009a1108c74d8/regex-2026.7.10-cp312-cp312-win_arm64.whl", hash = "sha256:87794549a3f5c1c2bdfba2380c1bf87b931e375f4133d929da44f95e396bf5fe", size = 277063, upload-time = "2026-07-10T19:47:40.023Z" }, + { url = "https://files.pythonhosted.org/packages/e0/88/0c977b9f3ba9b08645516eca236388c340f56f7a87054d41a187a04e134c/regex-2026.7.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4db009b4fc533d79af3e841d6c8538730423f82ea8508e353a3713725de7901c", size = 496868, upload-time = "2026-07-10T19:47:41.675Z" }, + { url = "https://files.pythonhosted.org/packages/f6/51/600882cd5d9a3cf083fd66a4064f5b7f243ba2a7de2437d42823e286edaf/regex-2026.7.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b96341cb29a3faa5db05aff29c77d141d827414f145330e5d8846892119351c1", size = 297306, upload-time = "2026-07-10T19:47:43.521Z" }, + { url = "https://files.pythonhosted.org/packages/52/6f/48a912054ffcb756e374207bb8f4430c5c3e0ffa9627b3c7b6661844b30a/regex-2026.7.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14d27f6bd04beb01f6a25a1153d73e58c290fd45d92ba56af1bb44199fd1010d", size = 291950, upload-time = "2026-07-10T19:47:45.267Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c8/8e1c3c86ebcee7effccbd1f7fc54fe3af22aa0e9204503e2baea4a6ff001/regex-2026.7.10-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b6a11bf898cca3ce7bfaa17b646901107f3975677fbd5097f36e5eb5641983", size = 796817, upload-time = "2026-07-10T19:47:48.054Z" }, + { url = "https://files.pythonhosted.org/packages/65/39/3e49d9ff0e0737eb8180a00569b47aabb59b84611f48392eba4d998d91a0/regex-2026.7.10-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234f8e0d65cf1df9becadae98648f74030ee85a8f12edcb5eb0f60a22a602197", size = 865513, upload-time = "2026-07-10T19:47:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/70/57/6511ad809bb3122c65bbeeffa5b750652bb03d273d29f3acb0754109b183/regex-2026.7.10-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:91b916d495db3e1b473c7c8e68733beec4dce8e487442db61764fff94f59740e", size = 912391, upload-time = "2026-07-10T19:47:51.776Z" }, + { url = "https://files.pythonhosted.org/packages/cc/29/a1b0c109c9e878cb04b931bfe4c54332d692b93c322e127b5ae9f25b0d9e/regex-2026.7.10-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f0d4ccf70b1d13711242de0ba78967db5c35d12ac408378c70e06295c3f6644", size = 801338, upload-time = "2026-07-10T19:47:53.38Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/171c3dad4d77000e1befeff2883ca88734696dfd97b2951e5e074f32e4dd/regex-2026.7.10-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c622f4c638a725c39abcb2e680b1bd592663c83b672a4ed350a17f806d75618e", size = 777149, upload-time = "2026-07-10T19:47:54.944Z" }, + { url = "https://files.pythonhosted.org/packages/33/61/41ab0de0e4574da1071c151f67d1eb9db3d92c43e31d64d2e6863c3d89bf/regex-2026.7.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41a47c2b28d9421e2509a4583a22510dc31d83212fcf38e1508a7013140f71a8", size = 785216, upload-time = "2026-07-10T19:47:56.56Z" }, + { url = "https://files.pythonhosted.org/packages/66/28/372859ea693736f07cf7023247c7eca8f221d9c6df8697ff9f93371cca08/regex-2026.7.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:13fba679fe035037e9d5286620f88bbfd105df4d5fcd975942edd282ab986775", size = 860229, upload-time = "2026-07-10T19:47:58.278Z" }, + { url = "https://files.pythonhosted.org/packages/50/b1/e1d32cd944b599534ae655d35e8640d0ec790c0fa12e1fb29bf434d50f55/regex-2026.7.10-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8e26a075fa9945b9e44a3d02cc83d776c3b76bb1ff4b133bbfa620d5650131da", size = 765797, upload-time = "2026-07-10T19:48:00.291Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/79a2cd9556a3329351e370929743ef4f0ccc0aaff6b3dc414ae5fa4a1302/regex-2026.7.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d0834c84ae8750ae1c4cede59b0afd4d2f775be958e11b18a3eea24ed9d0d9f1", size = 852130, upload-time = "2026-07-10T19:48:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/66/58/76fec29898cf5d359ab63face50f9d4f7135cc2eca3477139227b1d09952/regex-2026.7.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64722a5031aeace7f6c8d5ea9a9b22d9368af0d6e8fa532585da8158549ea963", size = 789644, upload-time = "2026-07-10T19:48:03.748Z" }, + { url = "https://files.pythonhosted.org/packages/f6/06/3c7cec7817bda293e13c8f88aed227bbcf8b37e5990936ff6442a8fdf11a/regex-2026.7.10-cp313-cp313-win32.whl", hash = "sha256:74ae61d8573ecd51b5eeee7be2218e4c56e99c14fa8fcf97cf7519611d4be92e", size = 267130, upload-time = "2026-07-10T19:48:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/e2a6f9a6a905f923cfc912298a5949737e9504b1ca24f29eda8d04d05ece/regex-2026.7.10-cp313-cp313-win_amd64.whl", hash = "sha256:5e792367e5f9b4ffb8cad93f1beaa91837056b94da98aa5c65a0db0c1b474927", size = 277722, upload-time = "2026-07-10T19:48:07.318Z" }, + { url = "https://files.pythonhosted.org/packages/00/a6/9d8935aaa940c388496aa1a0c82669cc4b5d06291c2712d595e3f0cf16d3/regex-2026.7.10-cp313-cp313-win_arm64.whl", hash = "sha256:82ab8330e7e2e416c2d42fcec67f02c242393b8681014750d4b70b3f158e1f08", size = 277059, upload-time = "2026-07-10T19:48:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7d/e9/26decfd3e85c09e42ff7b0d23a6f51085ca4c268db15f084928ca33459c6/regex-2026.7.10-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:2b93eafd92c4128bab2f93500e8912cc9ecb3d3765f6685b902c6820d0909b6b", size = 501508, upload-time = "2026-07-10T19:48:10.668Z" }, + { url = "https://files.pythonhosted.org/packages/38/a5/5b167cebde101945690219bf34361481c9f07e858a4f46d9996b80ec1490/regex-2026.7.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3f03b92fb6ec739df042e45b06423fc717ecf0063e07ffe2897f7b2d5735e1e8", size = 299705, upload-time = "2026-07-10T19:48:12.544Z" }, + { url = "https://files.pythonhosted.org/packages/f6/20/7909be4b9f449f8c282c14b6762d59aa722aeaeebe7ee4f9bb623eeaa5e0/regex-2026.7.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bb5aab464a0c5e03a97abad5bdf54517061ebbf72340d576e99ff661a42575cc", size = 294605, upload-time = "2026-07-10T19:48:14.495Z" }, + { url = "https://files.pythonhosted.org/packages/82/88/e52550185d6fda68f549b01239698697de47320fd599f5e880b1986b7673/regex-2026.7.10-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fadb07dbe36a541283ff454b1a268afd54b077d917043f2e1e5615372cb5f200", size = 811747, upload-time = "2026-07-10T19:48:16.197Z" }, + { url = "https://files.pythonhosted.org/packages/06/98/16c255c909714de1ee04da6ae30f3ee04170f300cdc0dcf57a314ee4816a/regex-2026.7.10-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:21150500b970b12202879dfd82e7fd809d8e853140fff84d08e57a90cf1e154e", size = 871203, upload-time = "2026-07-10T19:48:18.12Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/423ed27c9bae2092a453e853da2b6628a658d08bb5a6117db8d591183d85/regex-2026.7.10-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a68b637451d64ba30ed8ae125c973fa834cc2d37dfa7f154c2b479015d477ba8", size = 917334, upload-time = "2026-07-10T19:48:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/73/87/74dac8efb500db31cb000fda6bae2be45fc2fbf1fa9412f445fbb8acbe37/regex-2026.7.10-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e23458d8903e33e7d27196d7a311523dc4e2f4137a5f34e4dbd30c8d37ff33e", size = 816379, upload-time = "2026-07-10T19:48:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9f/1859403654e3e030b288f06d49233c6a4f889d62b84c4ef3f3a28653173d/regex-2026.7.10-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae27622c094558e519abf3242cf4272db961d12c5c9a9ffb7a1b44b2627d5c6", size = 785563, upload-time = "2026-07-10T19:48:23.643Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/35d30d6bdf1ef6a5430e8982607b3a6db4df1ddedbe001e43435585d88ba/regex-2026.7.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ee877b6d78f9dff1da94fef51ae8cf9cce0967e043fdcc864c40b85cf293c192", size = 801415, upload-time = "2026-07-10T19:48:25.499Z" }, + { url = "https://files.pythonhosted.org/packages/f7/22/630f31f5ea4826167b2b064d9cac2093a5b3222af380aa432cfe1a5dabcd/regex-2026.7.10-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:2c66a8a1969cfd506d1e203c0005fd0fc3fe6efc83c945606566b6f9611d4851", size = 866560, upload-time = "2026-07-10T19:48:27.789Z" }, + { url = "https://files.pythonhosted.org/packages/8d/14/f5914a6d9c5bc63b9bed8c9a1169fb0be35dbe05cdc460e17d953031a366/regex-2026.7.10-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2bc350e1c5fa250f30ab0c3e38e5cfdffcd82cb8af224df69955cab4e3003812", size = 772877, upload-time = "2026-07-10T19:48:29.563Z" }, + { url = "https://files.pythonhosted.org/packages/c1/0f/7c13999eef3e4186f7c79d4950fa56f041bf4de107682fb82c80db605ff9/regex-2026.7.10-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:53f54993b462f3f91fea0f2076b46deb6619a5f45d70dbd1f543f789d8b900ef", size = 856648, upload-time = "2026-07-10T19:48:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/a4/71/a48e43909b6450fb48fa94e783bef2d9a37179258bc32ef2283955df7be7/regex-2026.7.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cfcec18f7da682c4e2d82112829ce906569cb8d69fa6c26f3a50dfbed5ceb682", size = 803520, upload-time = "2026-07-10T19:48:33.275Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b8/f037d1bf2c133cb24ceb6e7d81d08417080390eddab6ddfd701aa7091874/regex-2026.7.10-cp313-cp313t-win32.whl", hash = "sha256:a2d6d30be35ddd70ce0f8ee259a4c25f24d6d689a45a5ac440f03e6bcc5a21d1", size = 269168, upload-time = "2026-07-10T19:48:35.353Z" }, + { url = "https://files.pythonhosted.org/packages/b6/9c/eaac34f8452a838956e7e89852ad049678cdc1af5d14f72d3b3b658b1ea5/regex-2026.7.10-cp313-cp313t-win_amd64.whl", hash = "sha256:c57b6ad3f7a1bdd101b2966f29dc161adf49727b1e8d3e1e89db2eda8a75c344", size = 280004, upload-time = "2026-07-10T19:48:37.106Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a9/e22e997587bc1d588b0b2cd0572027d39dd3a006216e40bbf0361688c51c/regex-2026.7.10-cp313-cp313t-win_arm64.whl", hash = "sha256:3d8ef9df02c8083c7b4b855e3cb87c8e0ebbcfea088d98c7a886aaefdf88d837", size = 279308, upload-time = "2026-07-10T19:48:38.907Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4a/a7fa3ada9bd2d2ce20d56dfceec6b2a51afeed9bf3d8286355ceec5f0628/regex-2026.7.10-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:39f81d1fdf594446495f2f4edd8e62d8eda0f7a802c77ac596dc8448ad4cc5ca", size = 497087, upload-time = "2026-07-10T19:48:40.543Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7e/ca0b1a87192e5828dbc16f16ae6caca9b67f25bf729a3348468a5ff52755/regex-2026.7.10-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:441edc66a54063f8269d1494fc8474d06605e71e8a918f4bcfd079ebda4ce042", size = 297307, upload-time = "2026-07-10T19:48:42.213Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/fb40bb34275d3cd4d7a376d5fb2ea1f0f4a96fd884fa83c0c4ae869001bf/regex-2026.7.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cfeb11990f59e59a0df26c648f0adfcbf27be77241250636f5769eb08db662be", size = 292163, upload-time = "2026-07-10T19:48:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/34cbea16c8fea9a18475a7e8f5837c70af451e738bfeb4eb5b029b7dc07a/regex-2026.7.10-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:460176b2db044a292baaee6891106566739657877af89a251cded228689015a6", size = 797064, upload-time = "2026-07-10T19:48:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/77/f6805d97f15f5a710bdfd56a768f3468c978239daf9e1b15efd8935e1967/regex-2026.7.10-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9dc55698737aca028848bde418d6c51d74f2a5fd44872d3c8b56b626729adb89", size = 866155, upload-time = "2026-07-10T19:48:47.589Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e3/a2a905807bba3bcd90d6ebbb67d27af2adf7d41708175cbc6b956a0c75f1/regex-2026.7.10-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d3e10779f60c000213a5b53f518824bd07b3dc119333b26d70c6be1c27b5c794", size = 911596, upload-time = "2026-07-10T19:48:49.473Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ca/a3126888b2c6f33c7e29144fedf85f6d5a52a400024fa045ad8fc0550ef1/regex-2026.7.10-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:38a5926601aaccf379512746b86eb0ac1d29121f6c776dac6ac5b31077432f2c", size = 800713, upload-time = "2026-07-10T19:48:51.452Z" }, + { url = "https://files.pythonhosted.org/packages/66/19/9d252fd969f726c8b56b4bacf910811cc70495a110907b3a7ccb96cd9cad/regex-2026.7.10-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a72ecf5bfd3fc8d57927f7e3ded2487e144472f39010c3acaec3f6f3ff53f361", size = 777286, upload-time = "2026-07-10T19:48:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/40/7a/5f1bf433fa446ecb3aab87bb402603dc9e171ef8052c1bb8690bb4e255a3/regex-2026.7.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d50714405845c1010c871098558cfe5718fe39d2a2fab5f95c8863caeb7a82b3", size = 785826, upload-time = "2026-07-10T19:48:55.381Z" }, + { url = "https://files.pythonhosted.org/packages/99/ca/69f3a7281d86f1b592338007f3e535cc219d771448e2b61c0b56e4f9d05b/regex-2026.7.10-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ec1c44cf9bd22079aac37a07cb49a29ced9050ab5bddf24e50aba298f1e34d90", size = 860957, upload-time = "2026-07-10T19:48:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/487ff55c8d515ec9dd60d7ba3c129eeaa9e527358ed9e8a054a9e9430f81/regex-2026.7.10-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9e9aaef25a40d1f1e1bbb1d0eb0190c4a64a7a1750f7eb67b8399bed6f4fd2a6", size = 765959, upload-time = "2026-07-10T19:49:00.27Z" }, + { url = "https://files.pythonhosted.org/packages/73/e1/fa034e6fa8896a09bd0d5e19c81fdc024411ab37980950a0401dccee8f6d/regex-2026.7.10-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e54e088dc64dd2766014e7cfe5f8bc45399400fd486816e494f93e3f0f55da06", size = 851447, upload-time = "2026-07-10T19:49:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a5/b9427ed53b0e14c540dc436d56aaf57a19fb9183c6e7abd66f4b4368fbad/regex-2026.7.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:834271b1ff2cfa1f67fcd65a48bf11d11e9ab837e21bf79ce554efb648599ae8", size = 789418, upload-time = "2026-07-10T19:49:03.949Z" }, + { url = "https://files.pythonhosted.org/packages/ba/52/aab92420c8aa845c7bcbe68dc65023d4a9e9ea785abf0beb2198f0de5ba1/regex-2026.7.10-cp314-cp314-win32.whl", hash = "sha256:f988a1cec68058f71a38471813fba9e87dffe855582682e8a10e40ece12567a2", size = 272538, upload-time = "2026-07-10T19:49:05.833Z" }, + { url = "https://files.pythonhosted.org/packages/99/16/5c7050e0ef7dd8889441924ff0a2c33b7f0587c0ccb0953fe7ca997d673b/regex-2026.7.10-cp314-cp314-win_amd64.whl", hash = "sha256:2129e4a5e86f26926982d883dff815056f2e98220fdf630e59f961b578a26c43", size = 280796, upload-time = "2026-07-10T19:49:07.593Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1a/4f6099d2ba271502fdb97e697bae2ed0213c0d87f2273fe7d21e2e401d12/regex-2026.7.10-cp314-cp314-win_arm64.whl", hash = "sha256:9cd5b6805396157b4cf993a6940cbb8663161f29b4df2458c1c9991f099299c5", size = 281017, upload-time = "2026-07-10T19:49:09.767Z" }, + { url = "https://files.pythonhosted.org/packages/19/02/4061fc71f64703e0df61e782c2894c3fbc089d277767eff6e16099581c73/regex-2026.7.10-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:103e8f3acc3dcede88c0331c8612766bdcfc47c9250c5477f0e10e0550b9da49", size = 501467, upload-time = "2026-07-10T19:49:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/73/a5/8d42b2f3fd672908a05582effd0f88438bf9bb4e8e02d69a62c723e23601/regex-2026.7.10-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:538ddb143f5ca085e372def17ef3ed9d74b50ad7fc431bd85dc50a9af1a7076f", size = 299700, upload-time = "2026-07-10T19:49:14.067Z" }, + { url = "https://files.pythonhosted.org/packages/65/70/36fa4b46f73d268c0dbe77c40e62da2cd4833ee206d3b2e438c2034e1f36/regex-2026.7.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e3448e86b05ce87d4eb50f9c680860830f3b32493660b39f43957d6263e2eba", size = 294590, upload-time = "2026-07-10T19:49:15.883Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a7/b6db1823f3a233c2a46f854fdc986f4fd424a84ed557b7751f2998efb266/regex-2026.7.10-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5eab9d3f981c423afd1a61db055cfe83553c3f6455949e334db04722469dd0a2", size = 811925, upload-time = "2026-07-10T19:49:17.97Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7d/f8bee4c210c42c7e8b952bb9fb7099dd7fb2f4bd0f33d0d65a8ab08aafc0/regex-2026.7.10-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:177f930af3ad72e1045f8877540e0c43a38f7d328cf05f31963d0bd5f7ecf067", size = 871257, upload-time = "2026-07-10T19:49:19.943Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/22adf72e614ba0216b996e9aaef5712c23699e360ea127bb3d5ee1a7666f/regex-2026.7.10-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd3b6d97beb39afb412f2c79522b9e099463c31f4c49ab8347c5a2ca3531c478", size = 917551, upload-time = "2026-07-10T19:49:22.069Z" }, + { url = "https://files.pythonhosted.org/packages/03/f7/ebc15a39e81e6b58da5f913b91fc293a25c6700d353c14d5cd25fc85712a/regex-2026.7.10-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8679f0652a183d93da646fcec8da8228db0be40d1595da37e6d74c2dc8c4713c", size = 816436, upload-time = "2026-07-10T19:49:24.131Z" }, + { url = "https://files.pythonhosted.org/packages/5c/33/20bc2bdd57f7e0fcc51be37e4c4d1bca7f0b4af8dc0a148c23220e689da8/regex-2026.7.10-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:494b19a5805438aeb582de99f9d97603d8fd48e6f4cc74d0088bb292b4da3b70", size = 785935, upload-time = "2026-07-10T19:49:26.265Z" }, + { url = "https://files.pythonhosted.org/packages/b4/51/87ff99c849b56309c40214a72b54b0eef320d0516a8a516970cc8be1b725/regex-2026.7.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0911e34151a5429d0325dae538ba9851ec0b62426bdfd613060cda8f1c36ec7f", size = 801494, upload-time = "2026-07-10T19:49:28.493Z" }, + { url = "https://files.pythonhosted.org/packages/16/11/fde67d49083fef489b7e0f841e2e5736516795b166c9867f05956c1e494b/regex-2026.7.10-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b862572b7a5f5ed47d2ba5921e63bf8d9e3b682f859d8f11e0e5ca46f7e82173", size = 866549, upload-time = "2026-07-10T19:49:30.592Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/31a156c36acf10181d88f55a66c688d5454a344e53ccc03d49f4a48a2297/regex-2026.7.10-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:3f361215e000d68a4aff375106637b83c80be36091d83ee5107ad3b32bd73f48", size = 773089, upload-time = "2026-07-10T19:49:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/27/bb/734e978c904726664df47ae36ce5eca5065de5141185ae46efec063476a2/regex-2026.7.10-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4533af6099543db32ef26abc2b2f824781d4eebb309ab9296150fd1a0c7eb07d", size = 856710, upload-time = "2026-07-10T19:49:35.289Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e5/dc35cea074dbdcb9776c4b0542a3bc326ff08454af0768ef35f3fc66e7fa/regex-2026.7.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:668ab85105361d0200e3545bec198a1acfc6b0aeb5fff8897647a826e5a171be", size = 803621, upload-time = "2026-07-10T19:49:37.704Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/124564af46bc0b592785610b3985315610af0a07f4cf21fa36e06c2398dd/regex-2026.7.10-cp314-cp314t-win32.whl", hash = "sha256:dd7715817a187edd7e2a2390908757f7ba42148e59cad755fb8ee1160c628eca", size = 274558, upload-time = "2026-07-10T19:49:39.926Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/cd813ce9f3404c0443915175c1e339c5afd8fcda04310102eaf233015eef/regex-2026.7.10-cp314-cp314t-win_amd64.whl", hash = "sha256:78712d4954234df5ca24fdadb65a2ab034213f0cdfde376c272f9fc5e09866bb", size = 283687, upload-time = "2026-07-10T19:49:41.872Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d3/3dae6a6ce46144940e64425e32b8573a393a009aeaf75fa6752a35399056/regex-2026.7.10-cp314-cp314t-win_arm64.whl", hash = "sha256:749b92640e1970e881fdf22a411d74bf9d049b154f4ef7232eeb9a90dd8be7f3", size = 283377, upload-time = "2026-07-10T19:49:43.985Z" }, ] [[package]] @@ -3624,27 +3624,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, - { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, - { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, - { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, - { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, - { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, - { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, - { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, - { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, - { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, - { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, ] [[package]] @@ -3969,40 +3969,40 @@ wheels = [ [[package]] name = "treestamps" -version = "4.1.0" +version = "4.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ruamel-yaml" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/87/31328b19121029e6db528aa4c70f992f1dd5ccc74435bb2e22874970f64d/treestamps-4.1.0.tar.gz", hash = "sha256:159652cd3a2f6365e3d7c5021defb66104ba89148c0cdd87e7ec14f713870cb3", size = 210180, upload-time = "2026-07-05T19:46:29.661Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/33/db241ab2748986e86406919bbfb273333dc226ca2656121546aa655807ed/treestamps-4.1.1.tar.gz", hash = "sha256:7c287f49c118f7b6bba35d8154846ad68acb9d15be0bc2edf1d277e1f057e124", size = 211456, upload-time = "2026-07-09T23:20:58.835Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/cb/508cbbb17a23ad35cea3fe2b86c8b1811d0c37fd7476c1e3892e79025be5/treestamps-4.1.0-py3-none-any.whl", hash = "sha256:f7323de89fad48030989cee9939b809c5ede15c5a20d5153c157f8d008231494", size = 32155, upload-time = "2026-07-05T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ea/518f3ec0a673e6606608cd0a1edbca39c3909e3ba58c5e9ccf8791822628/treestamps-4.1.1-py3-none-any.whl", hash = "sha256:af747ad8e61a655308f32b8fd1d3c022ebca5d1af76d54e6682872935ef5874a", size = 32665, upload-time = "2026-07-09T23:20:57.536Z" }, ] [[package]] name = "ty" -version = "0.0.56" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/55/07/fb29aea5235b0aa8ecfc4d1cc6ddf9fba8b863d67d96c6d345694d644c43/ty-0.0.56.tar.gz", hash = "sha256:84d114dc3796361c0fc72945016eabd74d46b9ee64f198cb0e485719704681e5", size = 6050123, upload-time = "2026-07-01T16:44:56.036Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/48/bce79e7ca5c1cc529d3e0d37ddd1121aea4b68a4f749974ad1cc77161871/ty-0.0.56-py3-none-linux_armv6l.whl", hash = "sha256:186d4a53e15747c947e1ec3d7eec8e345d8e40a1ca10e634c585db52497e87dd", size = 11643066, upload-time = "2026-07-01T16:44:18.374Z" }, - { url = "https://files.pythonhosted.org/packages/80/d1/22555d8a1d719661f10050f3865d877bbf497da908961c75fe22217dd18a/ty-0.0.56-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aae1a980fd9535da0469b7ba2b2e1b54a907743a5e0f442dd57eee9f5bfd034c", size = 11407487, upload-time = "2026-07-01T16:44:20.956Z" }, - { url = "https://files.pythonhosted.org/packages/cf/2d/b3b7a74ce8bc59ef48843ad80179bb0d9598bbd6cfc0d11d519bdf6b1352/ty-0.0.56-py3-none-macosx_11_0_arm64.whl", hash = "sha256:afd3058c0a6c5f241e814734f133008c93ee805f61c9cf4ce7412b8822b5d9ad", size = 10962270, upload-time = "2026-07-01T16:44:22.959Z" }, - { url = "https://files.pythonhosted.org/packages/64/ac/6c2fd7de0304a8a7218a756af74f7e62a5e8540fdb175e0a869e51042345/ty-0.0.56-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:058b52f7a823ac13aae3cae30809dd6b5145794b64d8478f9ef38c75d79b4483", size = 11471406, upload-time = "2026-07-01T16:44:25.327Z" }, - { url = "https://files.pythonhosted.org/packages/50/b6/11d861156861c03c7726b74558f9a0e0092661aff83a4fda1279df28c425/ty-0.0.56-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c66e00c1522add1f2bbdd2e45828c953b35c306b7bef03ec9169c75a63699a0", size = 11445612, upload-time = "2026-07-01T16:44:27.531Z" }, - { url = "https://files.pythonhosted.org/packages/fb/ba/09df108582090f3c0770ec4bc8675affed60248f6793a78d909be16211d9/ty-0.0.56-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40903d71c669a30691b5a5d5728056c7877a1bd6be4f233a38883a8b28cf34d7", size = 12093889, upload-time = "2026-07-01T16:44:29.548Z" }, - { url = "https://files.pythonhosted.org/packages/d7/f7/dbb4b4ccb69cd64c209ae55b1ab788ace8222c2bc1f6845be9e7cbedbf25/ty-0.0.56-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63fe3947fe0c46c69a7d950e6832ee70a9ec17321fefbff3d2e3c20baf9e5bd0", size = 12666337, upload-time = "2026-07-01T16:44:31.586Z" }, - { url = "https://files.pythonhosted.org/packages/86/e9/73f903fe4a3d9ea02f26f57c1eb07e3b1029ec92b0e8c2364718893440e3/ty-0.0.56-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71a0c1a72f9854532e710e119b6871ffe4542c8a65146f1f65dcd78fecd885b4", size = 12280247, upload-time = "2026-07-01T16:44:33.637Z" }, - { url = "https://files.pythonhosted.org/packages/d6/90/cebd222495832f1a00dcd321ba25f3cab804221a4991b992c2178bec68ee/ty-0.0.56-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70d1665596494e24d8ebd198438872b5a56ec3cae5f2bcf6c673be797acc4e3c", size = 11991107, upload-time = "2026-07-01T16:44:36.122Z" }, - { url = "https://files.pythonhosted.org/packages/b7/07/8f7337a07250f42d975cdb6decf47fc5b421e6c7da5e3e7be1e85f63a7e5/ty-0.0.56-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:778f99e51558afc1dbbe48ee38ab6aae7b31390ed8c1a1ef1499b295e9f1e82f", size = 12298970, upload-time = "2026-07-01T16:44:38.243Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b9/a52cd59034a48f5f18c6b155cc2cc36861d874b6d0af204b12c898024c3d/ty-0.0.56-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:867bc5708e0066bb4ff6c7db524bd5deea2676c62bfe71d3303138b3be850af0", size = 11425683, upload-time = "2026-07-01T16:44:40.473Z" }, - { url = "https://files.pythonhosted.org/packages/1d/2e/48e42d33357d52eefb695c0c3fcfc96879b73668a7447d1d1e0ad774fedc/ty-0.0.56-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6012f4189c928edb330a37deb9930f982380bd4aa7c4b8e0428eec9651c7551", size = 11469258, upload-time = "2026-07-01T16:44:42.513Z" }, - { url = "https://files.pythonhosted.org/packages/d5/01/ad1b4138be1e3fa97863af3925aa2134f17a593240c35dc38c3429fb5ad1/ty-0.0.56-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee83de1a7ff4cc32837ec06134ce391d441bc5b35ecd8d3cfe053f120f3e4c1", size = 11758736, upload-time = "2026-07-01T16:44:44.567Z" }, - { url = "https://files.pythonhosted.org/packages/09/34/9d81967ff240eaa57e9249728ef7b7790747cf6d3c9a98ec86b2cfdcc8ee/ty-0.0.56-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:62619b3b0e2c6248ef30d3f0e2f2217ae9893040585be07f32324242f197cd6f", size = 12100242, upload-time = "2026-07-01T16:44:46.584Z" }, - { url = "https://files.pythonhosted.org/packages/c3/36/f51d4666d2de6cf33c1f3a1fcc4bb6b70b197dd6ceaa491eef71d78fe8e8/ty-0.0.56-py3-none-win32.whl", hash = "sha256:b30687bb5cd9729d34c889a289edf32770388d9bb05243e534e723fb45e0381b", size = 11093759, upload-time = "2026-07-01T16:44:49.171Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b4/8fb5d4acfa4afb152245b20fa263069a7547bd1f8e4bfca4eda280c897d7/ty-0.0.56-py3-none-win_amd64.whl", hash = "sha256:ad4c8c47b6f4e3f9ed3fc0b1a5d650088d229e17dd8f63c1826d6bbe94cc3235", size = 12100327, upload-time = "2026-07-01T16:44:51.26Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fc/6a183e71edde90d0c35c2303f23f7a45b6891d1a2c45daf7b8f869831e19/ty-0.0.56-py3-none-win_arm64.whl", hash = "sha256:57538f273d444a5f1293fa7860e967178afe3917611fc5eff16b64e1204fe0d6", size = 11538780, upload-time = "2026-07-01T16:44:53.8Z" }, +version = "0.0.58" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/4c/26c90732658903aeb1d289208f7b7b492fa21029e0c4d6c51bdd6f8f5e51/ty-0.0.58.tar.gz", hash = "sha256:8f22484174e65c630660a454bf81b80cae7a3a7e70479f19c170d6cd87949258", size = 6133665, upload-time = "2026-07-10T03:09:30.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e1/5d1aa2a75829459834689f080e4be7a9d8828ce14b939ebed69161a35811/ty-0.0.58-py3-none-linux_armv6l.whl", hash = "sha256:47412850b6fbef61c42f244f6a51aa2f2c9e91f08cfbafd2d1e3730d2419d317", size = 11706915, upload-time = "2026-07-10T03:08:51.028Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/929eda9cc72a9afe39a03c76f946a503508d37343cc8ff2e64226afda105/ty-0.0.58-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79deb7bb4e5b3a1eee6ab9abc724d6ce3559d4977982707f310a139ee11fc703", size = 11532079, upload-time = "2026-07-10T03:08:53.771Z" }, + { url = "https://files.pythonhosted.org/packages/07/43/ebc58b3fc7d86a7abba2829f1674f7d4ae3a08f9794c1f31b707950c871f/ty-0.0.58-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a28af3187e661708a386d44a4fc32896a5f589fb07b734a11ab2f516e7572b7", size = 11092983, upload-time = "2026-07-10T03:08:56.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/6e/9547dbb8e51e47749cfb721a02b4fc862f9a932fa0f66a34a4d6dc429bb1/ty-0.0.58-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21a6977e34bc362fb378add46e59d5d56331c1c36727e6904767217ca8479718", size = 11490492, upload-time = "2026-07-10T03:08:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/d9/76/9f83b51b5e7795d6c8d76b4bb1bb7cebf4c10d609e846c02ab138e404556/ty-0.0.58-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3ac23e6bf6105ceca46632debf1b10b98125aaf60202aeae02f6abfeea242b3d", size = 11503696, upload-time = "2026-07-10T03:09:00.741Z" }, + { url = "https://files.pythonhosted.org/packages/47/9d/9fd48a0696c680f74e50f31cd54e524eeb58c97ea9bb1c3b8e04230ba215/ty-0.0.58-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb6df6a8c6a21894807a49851370ec7fc64aa910296c78ada31db0ef19359112", size = 12158653, upload-time = "2026-07-10T03:09:02.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ea/b5de845d2d8edae04d901c7585af7f04a057e18b26311f7fa4ca62b2da30/ty-0.0.58-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9df5847eebc026cde088b44420a03f7c6c169a7db747176bdf0a656eb1144713", size = 12723019, upload-time = "2026-07-10T03:09:05.291Z" }, + { url = "https://files.pythonhosted.org/packages/17/1c/54083b23eeff1e101f50b6df6a2c7f1e14b31abe0577c91bcae9e2c9395d/ty-0.0.58-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53a331a7f1f85872c810676a0f16096ef98c1b95c8c9a573fe7fde64d0a93e7c", size = 12275715, upload-time = "2026-07-10T03:09:07.564Z" }, + { url = "https://files.pythonhosted.org/packages/22/88/16925434b06faa49d36aa7e7508a6821ec6feacb429ca2fd80a3d52716b4/ty-0.0.58-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb0b05cd479fdcedc2e6781d376ee1a33569f37ae7f58357004635f615c4374c", size = 12033075, upload-time = "2026-07-10T03:09:10.222Z" }, + { url = "https://files.pythonhosted.org/packages/58/2b/b55708dd483982ae03d14da3667e3f0346cd384e11cca3dd3674a8b598c1/ty-0.0.58-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a0012786077e5becbb6add9fe51eda1d4d36d249afd3ae6cd141d554af15ae3", size = 12367729, upload-time = "2026-07-10T03:09:12.338Z" }, + { url = "https://files.pythonhosted.org/packages/91/a3/99ad66652956408f7e9ac3db6b4a416199d773b6c073cf95b0eea126d340/ty-0.0.58-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4ede96d7f6d149156da254e784b062b817736b68d4c6d660555a3d02d96966fb", size = 11439798, upload-time = "2026-07-10T03:09:14.518Z" }, + { url = "https://files.pythonhosted.org/packages/ee/23/344ceed4fe02ed498711e1f4a47b6e311fb1e9c4fecc19d31894be7a3472/ty-0.0.58-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:47608e58f73901b989402e6f283249cda4c2314282ec368aa74ab61761c62bd5", size = 11512695, upload-time = "2026-07-10T03:09:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/55/cf/3801831812c468f3fd0b3043a80f557a9aa90e6c27375763d7c3121e03f2/ty-0.0.58-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9757de17cc17e4c6bc26e18d4e26dea52ffee53d41a10d9087c6465e6ab12e2b", size = 11812253, upload-time = "2026-07-10T03:09:19.151Z" }, + { url = "https://files.pythonhosted.org/packages/7b/80/bce4f245787b77d1ec9feec7d9161eade5e01a77dbc132e016b24df83b0d/ty-0.0.58-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f3776d54c1d935fcb8f814bd58efba402c86c555f93e1144c59d087e7aa8b906", size = 12123918, upload-time = "2026-07-10T03:09:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/bab3d6268e7e88c792bf7cdde81bd11b31aa587eaba75196502b729747c8/ty-0.0.58-py3-none-win32.whl", hash = "sha256:8f50ec0ac3b42baa4c75895dc367071dc86b00ab440d29fdc72c633286a94815", size = 11230897, upload-time = "2026-07-10T03:09:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/9c/68/d9504c895864aaa84a840dce6ac7f8e681f6938be1882c8d4f60832dbe57/ty-0.0.58-py3-none-win_amd64.whl", hash = "sha256:de8847b3a65475ae4773bddd3126bfcf29f017e88967c4dcc9c75d743a4d3e5c", size = 12299376, upload-time = "2026-07-10T03:09:26.292Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c9847cb680b5fe8e1f7d7b483edd5cedcc29394496e7b8ed40d96be796ba/ty-0.0.58-py3-none-win_arm64.whl", hash = "sha256:7334bb38789878f60677f2eb9c1de4bfdf4583e2443790989c77da9b10fe0989", size = 11710495, upload-time = "2026-07-10T03:09:28.478Z" }, ] [[package]] @@ -4074,11 +4074,11 @@ wheels = [ [[package]] name = "tzdata" -version = "2026.2" +version = "2026.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, ] [[package]] From e45ef9094bc3097c985efea27b927151152b1e4b Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sun, 12 Jul 2026 19:25:49 -0700 Subject: [PATCH 23/66] Native OIDC single sign-on (Admin Auth tab) (#798) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth): native OIDC login via django-allauth Codex becomes an OIDC Relying Party (Authentik/Authelia) with a config-gated login flow: - [auth.oidc] TOML section + CODEX_AUTH_OIDC_* env overrides - allauth apps installed unconditionally; behavior gated on AUTH_OIDC_ENABLED (all OIDC paths 404 when off) - CodexSocialAccountAdapter: username linking (superusers included, documented trust boundary), optional email linking, claim-chain username mapping with sub-hash collision suffix, groups-claim sync to existing Django groups, admin-group grant/revoke, error redirects to the SPA (never an allauth template) - Branded throttled init endpoint /api/v4/auth/oidc/login; allauth login/callback mounted at /sso/ (outside the namespaced API tree so allauth's internal reverses work) - RP-initiated logout URL via cached discovery document using the spec's client_id parameter (no stored tokens needed) - /session payload gains public oidcEnabled/oidcProviderName/ oidcLoginUrl and authenticated oidcLogoutUrl - Profile username locks per-user when an OIDC identity is linked - OIDC failures reuse the failed-login log line format for fail2ban Co-Authored-By: Claude Opus 4.8 * feat(frontend): SSO login button, RP logout, and sso-error page - auth store: oidc admin flags, loginSSO() full-page navigation, logout() follows oidcLogoutUrl for RP-initiated logout - SsoLoginButton shared by the login dialog (with divider) and the unauthorized lock screen - /auth/sso-error route + page mapping backend error codes to human messages, with retry hidden for non-retryable codes Co-Authored-By: Claude Opus 4.8 * docs(auth): OIDC setup guide + complete tinyauth forward-auth recipe - README: native OIDC section (config table, redirect URI with prefix, Authentik/Authelia walkthroughs, identity-mapping and admin-linking trust warning, session-lifetime and OPDS caveats) - README: full nginx auth_request recipe for tinyauth with header override hardening, Traefik/Caddy equivalents, and a forward-auth deployment checklist (OPDS + WebSocket gating, spoof test) - schema test: allauth views stay out of the OpenAPI schema - test typing fixes surfaced by basedpyright Co-Authored-By: Claude Opus 4.8 * update deps * fix(settings): nest comicbox loglevel/delete_keys under general section An un-nested overlay is silently ignored by confuse. * fix(onlinetag): resolve prompts against current DB path, surface apply failures Answering a deferred prompt fetched the chosen issue against the path serialized into the prompt at scan time. When an earlier write for the same comic ran with rename enabled (the comic's other source's prompt, or a stored-id prefetch), that path was stale and the apply died with an uncaught FileNotFoundError — after the prompt was already consumed, so the admin's pick vanished with no feedback. - _apply_resolution now re-reads the comic's path from the DB by pk; a missing row reports to the Tagging error panel instead of fetching a dead path. - fetch/replay failures (ComicboxError, OSError) and non-resolving explicit ids now land on the Tagging error panel instead of only the log, since the pick can no longer be re-prompted. - stored-id prefetch and tag_by_id also catch OSError so a vanished file degrades gracefully. - regression test for the COMICBOX_CONFIG general-section overlay (un-nested loglevel/delete_keys were silently ignored, letting comicfn2dict remainders like "(0000)" leak into rename targets). Co-Authored-By: Claude Fable 5 * feat(auth): move OIDC config from codex.toml to the Admin UI Auth tab OIDCSettings DB singleton (EmailSettings pattern) becomes the sole config source, read at request time: - OIDCSettings model + migration 0047 (seeds pk=1, one-time courtesy import of any pre-GUI [auth.oidc] TOML values); client_secret encrypted at rest via EncryptedCharField - get_oidc_settings()/oidc_enabled() in settings.db; cachalot makes admin edits live on the next request, no restart - codex/oidc.py rewired to request-time reads; new adapter list_apps override builds an unsaved SocialApp from the row (per-app settings['scope'] wins in allauth's get_scope), so disabled state keeps allauth's own DoesNotExist -> 404 gating - RP-initiated logout and session flags read the row - AdminOIDCSettingsView GET/PUT (write-only secret + clientSecretSet mirror, discovery-cache invalidation on save) and AdminOIDCTestView (discovery-document probe) at /api/v4/admin/oidc-settings[/test] - New Admin UI Auth tab mirroring the Email tab: draft/dirty tracking, never-echoed secret with Clear Credential, redirect-URI display, Test Connection endpoint report - [auth.oidc] TOML section and CODEX_AUTH_OIDC_* env overrides removed; README updated - Tests now seed the DB row instead of patching module constants Co-Authored-By: Claude Opus 4.8 * format * update deps and fix * fix(admin): gate the OIDC enable switch on server URL + client ID The Auth tab's Enable OIDC Login checkbox is disabled until a valid server URL and a client ID are entered (it can always be unchecked so clearing a field never strands the switch). The serializer enforces the same invariant for API clients and partial updates that blank a prerequisite while enabled — previously such a save produced a silently inert enabled=true row. Co-Authored-By: Claude Opus 4.8 * feat(admin): Auth tab gains Account & Access flags, tinyauth note, name gate - Move the Account & Access flag cards (Registration, Verify New User Email, Non-Users) from the Users tab to the Auth tab — they govern how people get in, which is that tab's subject - Auth tab prose explains that forward-auth gateways like tinyauth are not OIDC providers and points them at Remote-User header auth, which coexists with OIDC - Provider name joins server URL and client ID as an enable prerequisite, in the UI switch gate and the serializer invariant Co-Authored-By: Claude Opus 4.8 * style(admin): visually nest the OIDC subsections under their header AdminSection gains a sub variant: a small uppercase overline title (h4, $text-meta) and an indented left rule, with tighter sibling rhythm than top-level sections. The Auth tab wraps the whole OIDC block — prose, Identity Provider, User Mapping, Logout, and Test Connection — in one parent 'OIDC Single Sign-On' AdminSection with the config groups as sub sections, so their subordination to the OIDC header is unmistakable next to the sibling Account & Access section. Documented in DESIGN.md. Co-Authored-By: Claude Opus 4.8 * style(admin): collapse the OIDC section when OIDC is disabled Most admins never configure OIDC, so the section body — prose, config sub-sections, and Test Connection — hides behind an AdminExpandToggle disclosure. It starts expanded only when OIDC is already enabled; otherwise a one-line hint summarizes what's inside next to a Configure toggle. The disclosure is initialized once from the saved state so saving a disable doesn't slam the panel shut mid-edit. Co-Authored-By: Claude Opus 4.8 * docs(admin): plain-English hints for PKCE and other OIDC jargon fields PKCE, Client ID, Username Claim, and Groups Claim now carry hints an admin who has never touched OIDC can act on — including what a claim is and why PKCE should stay on. Co-Authored-By: Claude Opus 4.8 * test(sso): authentik + tinyauth manual test harness in test-proxy/ Adds a docker-compose IdP stack and nginx wiring so SSO can be manually verified before release: - compose.yaml: authentik (OIDC provider, :9010) + tinyauth (forward auth, :3232), everything bound to localhost with throwaway creds - authentik/blueprints/codex-test.yaml: auto-applied fixtures — readers and codex-admins groups, testuser/testadmin, and the codex-test OIDC client with callback URIs for proxied and direct, prefixed and bare - forwardauth.conf: nginx :8081 gating Codex behind tinyauth auth_request with an overriding Remote-User header - README.md: step-by-step test matrix covering native OIDC (login, group sync, admin mapping, RP logout, linking, error page, disabled 404) and forward-auth (login, gating, spoof-proofing, coexistence) tinyauth DB path pinned to the writable /data volume (workdir is root-owned). test-proxy/ excluded from eslint: authentik !Find tags and compose healthcheck arrays require flow-style YAML the yml plugin bans. Co-Authored-By: Claude Opus 4.8 * test(sso): run the harness nginx as a compose service nginx joins authentik + tinyauth in compose.yaml, so only Codex runs on the host. The native bin/run-test-proxy.sh path still works — both share server.conf/forwardauth.conf, with the sole native-vs-container difference (backend addresses) isolated into named upstreams: - upstreams-native.conf: localhost backends (host nginx) - upstreams-docker.conf: host.docker.internal + tinyauth service name - connection-upgrade.conf: the ws-upgrade map, now shared - ssl-listen.conf / ssl-listen-none.conf: SSL/QUIC listeners split out so the container serves plain HTTP (native keeps the 8443 listeners) The compose nginx mounts these into the stock image's conf.d and reaches host Codex via host.docker.internal (extra_hosts host-gateway for Linux). Also fixes a latent harness bug that would break OIDC through the proxy: X-Forwarded-Host used $host (strips the port), so Django's build_absolute_uri produced a portless redirect_uri that couldn't match the registered callback. Now $http_host, port included. Co-Authored-By: Claude Opus 4.8 * test(sso): fix tinyauth boot crash on localhost app URL tinyauth v5 derives a cookie domain from its app URL at startup and rejects single-label hosts and IPs ('invalid app url, must be at least second level domain'), so http://localhost:3232 crash-looped. Move the forward-auth path onto *.localtest.me (all subdomains resolve to 127.0.0.1 via public DNS, every browser, no /etc/hosts): - tinyauth app url -> http://tinyauth.localtest.me:3232 - gated Codex -> http://codex.localtest.me:8081 - shared cookie -> .localtest.me (spans both) The @tinyauth_login redirect and README Test 2 follow. OIDC/authentik stay on localhost (no cross-host cookie needed there). Also documents in README Troubleshooting that the harness publishes only 9010/8080/8081/3232 and never binds Vite's 5173 — a blocked HMR is a stale vite process, and 8080/8081 clashes come from running native make dev-reverse-proxy alongside the compose nginx. Co-Authored-By: Claude Opus 4.8 * test(sso): set authentik provider grant_types; drop IPv6 host-gateway Two issues from the first live OIDC run: - 'Login with Authentik' failed with authentik logging 'Invalid grant_type for provider'. authentik 2026.x added an explicit grant_types model field that defaults to an EMPTY list, so a blueprint that omits it creates a provider allowing no grants and the authorize step returns invalid_request. Set grant_types: [authorization_code, refresh_token] on the provider. - nginx logged 'connect() to [fd..::254]:9810 Network unreachable' then fell back to IPv4. The IPv6 came from extra_hosts host-gateway (a Docker Desktop IPv6 ULA gateway Granian doesn't listen on). Comment it out — Docker Desktop provides an IPv4 host.docker.internal built-in; Linux users uncomment it. README troubleshooting covers both, including re-applying the blueprint to an already-running authentik. Co-Authored-By: Claude Opus 4.8 * fix(auth): refresh public flags when OIDC is toggled or on logout The 'Login with ' button (adminFlags.oidcEnabled) went stale after disabling OIDC: OIDCSettings is a singleton with no admin.flags.changed websocket broadcast, and logout() left adminFlags untouched, so the button lingered on the login screen until a manual page reload. - admin.updateOidcSettings now calls auth.loadAdminFlags after a save, resyncing the public OIDC flags immediately. - auth.logout now re-fetches public flags (except when doing an RP-initiated full-page redirect, which reloads anyway), so the logged-out login screen always reflects current settings. Co-Authored-By: Claude Opus 4.8 * update deps * chore(lint): clear radon CC/MI and remark warnings - codex/oidc.py: extract CodexSocialAccountAdapter._sync_admin from _sync_user (rank C -> B); keyword-only bool arg for FBT001. - tests: split the 940-line test_onlinetag_session_manager (MI rank B, pre-existing on develop) — move the TagPassRunner and stored-id-map classes into test_onlinetag_tag_pass.py, importing the shared doubles from the session-manager module (as test_opds_schema already does). Both files now MI rank A. - test-proxy/README.md: wrap the bare http://localhost autolink in <> and the [fd..::254] nginx error in backticks so remark-lint stops reading it as a link reference. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- README.md | 129 ++++- bin/run-test-proxy.sh | 6 +- bun.lock | 109 ++-- codex/failed_login_log.py | 18 +- codex/migrations/0047_oidc_settings.py | 127 +++++ codex/models/admin.py | 54 ++ codex/oidc.py | 392 +++++++++++++ codex/serializers/admin/oidc.py | 98 ++++ codex/serializers/auth.py | 34 +- codex/settings/__init__.py | 48 +- codex/settings/codex.toml.default | 4 + codex/settings/db.py | 31 +- codex/urls/api/v4/admin.py | 11 + codex/urls/api/v4/auth.py | 2 + codex/urls/app.py | 3 + codex/urls/root.py | 4 + codex/views/admin/oidc.py | 79 +++ codex/views/session.py | 21 +- eslint.config.js | 4 + frontend/DESIGN.md | 4 +- frontend/bun.lock | 430 ++++---------- frontend/package.json | 4 +- frontend/src/api/v4/admin.js | 9 + .../components/admin/tabs/admin-section.scss | 25 + .../components/admin/tabs/admin-section.vue | 9 +- .../src/components/admin/tabs/auth-tab.vue | 537 ++++++++++++++++++ .../src/components/admin/tabs/user-tab.vue | 16 +- frontend/src/components/auth/login-dialog.vue | 13 + frontend/src/components/auth/sso-error.vue | 83 +++ .../src/components/auth/sso-login-button.vue | 34 ++ frontend/src/components/unauthorized.vue | 7 + frontend/src/plugins/router.js | 8 + frontend/src/stores/admin.js | 54 ++ frontend/src/stores/auth.js | 35 ++ frontend/tests/unit/admin-oidc-store.test.js | 158 ++++++ frontend/tests/unit/sso-auth.test.js | 164 ++++++ package.json | 5 +- pyproject.toml | 1 + test-proxy/README.md | 194 +++++++ .../authentik/blueprints/codex-test.yaml | 114 ++++ test-proxy/compose.yaml | 160 ++++++ test-proxy/connection-upgrade.conf | 10 + test-proxy/forwardauth.conf | 78 +++ test-proxy/nginx.conf | 15 +- test-proxy/server.conf | 33 +- test-proxy/ssl-listen-none.conf | 4 + test-proxy/ssl-listen.conf | 10 + test-proxy/upstreams-docker.conf | 14 + test-proxy/upstreams-native.conf | 11 + tests/test_admin_oidc_settings.py | 200 +++++++ tests/test_oidc_login.py | 422 ++++++++++++++ tests/test_oidc_settings.py | 86 +++ tests/test_onlinetag_session_manager.py | 135 ----- tests/test_onlinetag_tag_pass.py | 160 ++++++ uv.lock | 327 ++++++----- 55 files changed, 4007 insertions(+), 736 deletions(-) create mode 100644 codex/migrations/0047_oidc_settings.py create mode 100644 codex/oidc.py create mode 100644 codex/serializers/admin/oidc.py create mode 100644 codex/views/admin/oidc.py create mode 100644 frontend/src/components/admin/tabs/auth-tab.vue create mode 100644 frontend/src/components/auth/sso-error.vue create mode 100644 frontend/src/components/auth/sso-login-button.vue create mode 100644 frontend/tests/unit/admin-oidc-store.test.js create mode 100644 frontend/tests/unit/sso-auth.test.js create mode 100644 test-proxy/README.md create mode 100644 test-proxy/authentik/blueprints/codex-test.yaml create mode 100644 test-proxy/compose.yaml create mode 100644 test-proxy/connection-upgrade.conf create mode 100644 test-proxy/forwardauth.conf create mode 100644 test-proxy/ssl-listen-none.conf create mode 100644 test-proxy/ssl-listen.conf create mode 100644 test-proxy/upstreams-docker.conf create mode 100644 test-proxy/upstreams-native.conf create mode 100644 tests/test_admin_oidc_settings.py create mode 100644 tests/test_oidc_login.py create mode 100644 tests/test_oidc_settings.py create mode 100644 tests/test_onlinetag_tag_pass.py diff --git a/README.md b/README.md index 502193fd7..6b8ea1f06 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,9 @@ border-radius: 128px; ### 🔌 Integrations - **OPDS 1 & 2** syndication with streaming, search, and authentication. -- **Remote-User HTTP header SSO** for reverse-proxy single sign-on. +- **OIDC single sign-on** with identity providers like Authentik and Authelia. +- **Remote-User HTTP header SSO** for reverse-proxy single sign-on (tinyauth, + Authelia, Authentik proxy mode). - **Fail2Ban** log for IP-banning failed login attempts. ### 🪶 Operations @@ -608,6 +610,9 @@ to 2 queries per second. `1` (trust XFF), which is correct when Codex sits behind a reverse proxy. Set to `0` when Codex is exposed directly so that clients can't forge `X-Forwarded-For` to poison the log. +- OIDC single sign-on has no environment variables or TOML keys — it is + configured in the Admin UI under the **Auth** tab. See the + [OIDC Single Sign On](#oauth--oidc) section below. ### Reverse Proxy @@ -672,29 +677,133 @@ article. ##### OAuth & OIDC -Codex is not an OIDC client at this time. However the following Remote-User and -Token Authentication methods may assist other services in providing Single Sign -On. +Codex is a native OIDC client. Point it at an identity provider like +[Authentik](https://goauthentik.io/) or [Authelia](https://www.authelia.com/) +and a "Login with …" button appears on the login dialog and the unauthorized +screen. (tinyauth is not an OIDC provider — tinyauth users should use the +[Remote-User](#remote-user-authentication) method below instead.) + +Configure it in the Admin UI under the **Auth** tab — there are no TOML keys or +environment variables for OIDC. Set at minimum the **Provider Name** (the login +button label), the issuer **Server URL** (discovery is fetched from +`/.well-known/openid-configuration`), and the **Client ID**, then +flip the enable switch; the client secret is stored encrypted at rest. A **Test +Connection** button fetches the provider's discovery document and reports the +endpoints it advertises before you commit to the config. Every change takes +effect on the next request — no restart. The tab also carries the user-mapping +knobs (username claim, auto-provisioning, email linking, group sync, admin +group) and RP-initiated logout, all documented inline. + +Register this redirect URI with your identity provider (including your +`url_path_prefix` if you use one): + +```text +https://your-host[/url_path_prefix]/sso/oidc/login/callback/ +``` + +**Identity mapping.** Users are keyed on the stable OIDC `sub` claim. On first +login, Codex links the login to an existing local user with the same username +(and by email if **Link by Email** is on); otherwise it creates a new user when +**Create Users on First Login** is on. With **Sync Groups** on, the identity +provider's groups claim replaces the user's Codex groups on every login — only +existing Codex groups are matched, never created — so IdP groups can drive Codex +library access controls. **Admin Group** members are granted (and, when absent +from the claim, revoked) Codex admin rights. + +⚠️ Username linking includes admin accounts: an identity-provider user named +`admin` links to Codex's built-in `admin` account. Only enable OIDC against an +identity provider whose username namespace you control, and change the default +admin password regardless. ⚠️ + +**Provider notes.** + +- **Authentik:** create an OAuth2/OpenID provider + application. The default + `openid profile email` scope works as-is; `groups` is included in the profile + scope. RP-initiated logout works. +- **Authelia:** register Codex as a client in `identity_providers.oidc` with a + hashed client secret. Keep **Fetch Userinfo** on (Authelia serves username and + email claims only from the userinfo endpoint) and add `groups` to the scope + for group sync. Authelia does not implement RP-initiated logout; leave it off. + +**Good to know.** + +- The client secret is entered in the Admin UI and stored encrypted at rest — it + never appears in `codex.toml` or the API responses. +- Codex sessions live 60 days; disabling a user at the identity provider does + not end their existing Codex session. Deactivate the user in Codex too, or + lower `SESSION_COOKIE_AGE`-equivalent exposure by logging them out. +- OIDC-created users have no local password, so OPDS readers can't use HTTP + Basic auth for them. Use the per-user API token (User Profile in the sidebar) + for OPDS, or set a local password. +- On first start after upgrading, Codex applies a few small new database tables + for the OIDC account linkage and the settings singleton. +- OIDC login failures are recorded in the [Failed-Login Log](#failed-login-log) + when it is enabled. ##### Remote-User Authentication Remote-User authentication tells Codex to accept a username from the webserver and assume that authentication has already been done. This is very insecure if -you haven't configured an authenticating reverse proxy in front of Codex. +you haven't configured an authenticating reverse proxy in front of Codex. This +is the right method for forward-auth gateways like +[tinyauth](https://tinyauth.app/), and also works with Authelia's `auth_request` +mode and Authentik's proxy outpost. -Here's a snipped for configuring nginx with tinyauth to provide this header. -This snipped it incomplete and assumes that the rest of nginx tinyauth config -has been done: +Here's a complete nginx location for the tinyauth case, assuming the tinyauth +server itself is already configured and reachable at `tinyauth:3000`: ```nginx -auth_request_set $tinyauth_remote_user $upstream_http_remote_user; -proxy_set_header Remote-User $tiny_auth_user; +# In the http {} block (shared with the WebSocket map above). +# The internal auth endpoint nginx consults for every request. +location = /tinyauth { + internal ; + proxy_pass http://tinyauth:3000/api/auth/nginx; + proxy_set_header Host $host; + proxy_set_header X-Original-URL $scheme://$http_host$request_uri; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; +} + +location ^~ /codex { + auth_request /tinyauth; + # Pass the authenticated username to Codex, OVERRIDING anything the + # client sent. Never pass the client's own Remote-User through. + auth_request_set $remote_user $upstream_http_remote_user; + proxy_set_header Remote-User $remote_user; + proxy_pass http://codex:9810; + # ... the same proxy_set_header / WebSocket lines as the + # Reverse Proxy example above ... +} +``` + +The equivalent Traefik middleware (`forwardAuth` with +`authResponseHeaders: [Remote-User]`) or Caddy directive (`forward_auth` with +`copy_headers Remote-User`) works the same way: the proxy authenticates, then +injects `Remote-User` toward Codex. + +Then enable it in Codex with `CODEX_AUTH_REMOTE_USER=1` or: + +```toml +[auth] +remote_user = true ``` ⚠️ Only turn on the `CODEX_AUTH_REMOTE_USER` environment variable if your webserver sets the `Remote-User` header itself every time for the Codex location, overriding any malicious client that might set it themselves. ⚠️ +Checklist for a forward-auth deployment: + +- The auth gate covers the **whole** Codex location — including `/opds/` and the + WebSocket path `/api/v4/ws` — not just the HTML pages. If you exempt OPDS for + reader apps, those requests fall back to Codex's own Basic/token auth, which + is fine. +- A request with a client-forged `Remote-User` header and no proxy auth must be + rejected or have the header overwritten (test it with + `curl -H "Remote-User: admin"`). +- Remote-User and OIDC can be enabled at the same time; they are independent + paths to a normal Codex session. + ##### HTTP Token Authentication You can also configure your proxy to add token authentication to the headers. diff --git a/bin/run-test-proxy.sh b/bin/run-test-proxy.sh index c2fbf7dc8..208aec10b 100755 --- a/bin/run-test-proxy.sh +++ b/bin/run-test-proxy.sh @@ -82,9 +82,11 @@ mkdir -p "$scaffold/tmp" nginx -p "$scaffold" -c "$scaffold/nginx.conf" -t # Best-effort: surface the ports/backend the committed config actually uses. +# The SSL listeners live in ssl-listen.conf and the backend in +# upstreams-native.conf (both shared with the compose nginx service). http_port="$(awk '/^[[:space:]]*listen [0-9]+ / && !/ssl/ && !/quic/ {print $2; exit}' "$scaffold/server.conf")" -https_port="$(awk '/^[[:space:]]*listen [0-9]+ ssl/ {print $2; exit}' "$scaffold/server.conf")" -backend="$(awk '/proxy_pass[[:space:]]+http:\/\// {gsub(/.*http:\/\/|;.*/, ""); print; exit}' "$scaffold/server.conf")" +https_port="$(awk '/^[[:space:]]*listen [0-9]+ ssl/ {print $2; exit}' "$scaffold/ssl-listen.conf")" +backend="$(awk '/upstream codex_backend/{f=1;next} f&&/server/{gsub(/;/,"",$2); print $2; exit}' "$scaffold/upstreams-native.conf")" cat <=4.8.4 <6.1.0" } }, "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.63.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.63.0", "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ=="], "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0" } }, "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.63.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.61.1", "", {}, "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.63.0", "", {}, "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.61.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.61.1", "@typescript-eslint/tsconfig-utils": "8.61.1", "@typescript-eslint/types": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.63.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.63.0", "@typescript-eslint/tsconfig-utils": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q=="], "@typescript-eslint/utils": ["@typescript-eslint/utils@8.63.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.1", "", { "dependencies": { "@typescript-eslint/types": "8.61.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.12.2", "", { "os": "android", "cpu": "arm" }, "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w=="], @@ -249,17 +252,17 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.38", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], - "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "browserslist": ["browserslist@4.28.5", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", "electron-to-chromium": "^1.5.387", "node-releases": "^2.0.50", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -267,7 +270,7 @@ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], + "caniuse-lite": ["caniuse-lite@1.0.30001803", "", {}, "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -347,7 +350,7 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "electron-to-chromium": ["electron-to-chromium@1.5.375", "", {}, "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q=="], + "electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -359,7 +362,7 @@ "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], - "es-toolkit": ["es-toolkit@1.47.1", "", {}, "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q=="], + "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -369,7 +372,7 @@ "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], - "eslint-fix-utils": ["eslint-fix-utils@0.4.2", "", { "peerDependencies": { "@types/estree": ">=1", "eslint": ">=8" }, "optionalPeers": ["@types/estree"] }, "sha512-n7ZTcwwkP5scedlhvWMcqxED+O1NzXcj5Rxn/0kJQMP88k02vRcBfQ1qsk/JHb6Aw8bajFoetFCCBiNIcNCsvA=="], + "eslint-fix-utils": ["eslint-fix-utils@0.4.3", "", { "peerDependencies": { "@types/estree": ">=1", "eslint": ">=8" }, "optionalPeers": ["@types/estree"] }, "sha512-EKzNhsNavV5DdkHVltHBM9TqfsonCmiUhOm1Ra97zo03M9IAx3wtM1eC27eWGMj/D3LrALmHHNe1QlQH1P7Nxw=="], "eslint-import-context": ["eslint-import-context@0.1.9", "", { "dependencies": { "get-tsconfig": "^4.10.1", "stable-hash-x": "^0.2.0" }, "peerDependencies": { "unrs-resolver": "^1.0.0" }, "optionalPeers": ["unrs-resolver"] }, "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg=="], @@ -497,7 +500,7 @@ "identifier-regex": ["identifier-regex@1.1.0", "", { "dependencies": { "reserved-identifiers": "^1.0.0" } }, "sha512-SLX4H/vtcYlYnL7XqnuJKHU7Z8517TgsW9nmQiGOgMCjQ8V/deLYu6bEmbGoXe7WMMhc9+EUGyFFneHja8KabA=="], - "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], @@ -589,7 +592,7 @@ "lowercase-keys": ["lowercase-keys@3.0.0", "", {}, "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="], - "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "make-asynchronous": ["make-asynchronous@1.1.0", "", { "dependencies": { "p-event": "^6.0.0", "type-fest": "^4.6.0", "web-worker": "^1.5.0" } }, "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg=="], @@ -715,7 +718,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.13", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q=="], + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], @@ -723,7 +726,7 @@ "natural-orderby": ["natural-orderby@5.0.0", "", {}, "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg=="], - "node-releases": ["node-releases@2.0.48", "", {}, "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA=="], + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], "nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], @@ -769,11 +772,11 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], - "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], "postcss-safe-parser": ["postcss-safe-parser@7.0.1", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A=="], @@ -959,7 +962,7 @@ "scslre": ["scslre@0.3.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.8.0", "refa": "^0.12.0", "regexp-ast-analysis": "^0.7.0" } }, "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ=="], - "semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1003,7 +1006,7 @@ "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], + "svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="], "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "^0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], @@ -1099,7 +1102,7 @@ "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "yaml-eslint-parser": ["yaml-eslint-parser@2.0.0", "", { "dependencies": { "eslint-visitor-keys": "^5.0.0", "yaml": "^2.0.0" } }, "sha512-h0uDm97wvT2bokfwwTmY6kJ1hp6YDFL0nRHwNKz8s/VD1FH/vvZjAKoMUE+un0eaYBSG7/c6h+lJTP+31tjgTw=="], + "yaml-eslint-parser": ["yaml-eslint-parser@2.1.0", "", { "dependencies": { "eslint-visitor-keys": "^5.0.0", "yaml": "^2.0.0" } }, "sha512-1zo9KRfp6vIAXBEhWAz09ex3hUwh3T+/6dGbGteHvNseA0Uk4FgQnPES55klZ6cDzSy9FpgKS0D/CoLUvCCj4w=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -1107,8 +1110,6 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@fsouza/prettierd/prettier": ["prettier@3.8.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="], - "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@npmcli/config/proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="], @@ -1127,14 +1128,6 @@ "@npmcli/promise-spawn/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], - "@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.63.0", "", {}, "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q=="], - - "@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], - - "@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.63.0", "", {}, "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q=="], - - "@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.63.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.63.0", "@typescript-eslint/tsconfig-utils": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q=="], - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -1157,21 +1150,15 @@ "eslint-mdx/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], - "eslint-plugin-sonarjs/globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="], - - "eslint-plugin-unicorn/browserslist": ["browserslist@4.28.5", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", "electron-to-chromium": "^1.5.387", "node-releases": "^2.0.50", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ=="], + "eslint-plugin-sonarjs/globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="], "eslint-plugin-unicorn/globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="], - "eslint-plugin-unicorn/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "eslint-plugin-yml/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "eslint_d/eslint": ["eslint@10.5.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ=="], - "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "is-builtin-module/builtin-modules": ["builtin-modules@5.2.0", "", {}, "sha512-02yxLeyxF4dNl6SlY6/5HfRSrSdZ/sCPoxy2kZNP5dZZX8LSAD9aE2gtJIUgWrsQTiMPl3mxESyrobSwvRGisQ=="], + "is-builtin-module/builtin-modules": ["builtin-modules@5.3.0", "", {}, "sha512-hMQUl2bUFG339QygPM97E+mc8OY1IAchORZxm4a/frcYwKzozMzRVDBwHW0NjOqGElLm2O37AVQE8ikxlZHrMQ=="], "jsonc-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], @@ -1221,39 +1208,17 @@ "@npmcli/git/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - "@npmcli/map-workspaces/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + "@npmcli/map-workspaces/minimatch/brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="], "@npmcli/package-json/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@npmcli/promise-spawn/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - "@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.63.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.63.0", "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ=="], - - "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.63.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg=="], - - "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], - "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], "eslint-mdx/espree/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "eslint-plugin-unicorn/browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="], - - "eslint-plugin-unicorn/browserslist/caniuse-lite": ["caniuse-lite@1.0.30001803", "", {}, "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg=="], - - "eslint-plugin-unicorn/browserslist/electron-to-chromium": ["electron-to-chromium@1.5.388", "", {}, "sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw=="], - - "eslint-plugin-unicorn/browserslist/node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], - - "eslint_d/eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "eslint_d/eslint/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], - - "eslint_d/eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - - "glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="], "normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -1273,8 +1238,6 @@ "@npmcli/map-workspaces/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "npm-pick-manifest/npm-package-arg/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], diff --git a/codex/failed_login_log.py b/codex/failed_login_log.py index d6446f2d4..da5f0202d 100644 --- a/codex/failed_login_log.py +++ b/codex/failed_login_log.py @@ -67,14 +67,28 @@ def not_failed_login_filter(record) -> bool: return not record["extra"].get(_FAILED_LOGIN_KEY) +def log_failed_login(request: "HttpRequest | None", username: str = _MISSING) -> None: + """ + Emit one failed-login record in the standard format. + + Public helper for callers outside the ``user_login_failed`` signal + (e.g. OIDC callback failures in :mod:`codex.oidc`) so every failure + shares one line format for banning-tool regexes. When the failed-login + log is disabled the tagged record is dropped by the main sinks' + :func:`not_failed_login_filter` and no dedicated sink exists, so + calling this is always safe. + """ + ip = get_client_ip(request) + _failed_login_logger.warning(f"Failed login from {ip} user={username or _MISSING}") + + def on_login_failed(sender, credentials=None, request=None, **_kwargs) -> None: """Receiver for ``django.contrib.auth.signals.user_login_failed``.""" del sender if request is None: request = _current_request.get() - ip = get_client_ip(request) username = (credentials or {}).get("username") or _MISSING - _failed_login_logger.warning(f"Failed login from {ip} user={username}") + log_failed_login(request, username) class RequestContextMiddleware: diff --git a/codex/migrations/0047_oidc_settings.py b/codex/migrations/0047_oidc_settings.py new file mode 100644 index 000000000..33b6ee4a0 --- /dev/null +++ b/codex/migrations/0047_oidc_settings.py @@ -0,0 +1,127 @@ +"""Generated by Django 6.0.6 on 2026-07-11 07:49.""" + +from django.conf import settings +from django.db import migrations, models + +import codex.models.fields +from codex.settings.config import get_bool, get_str + + +def seed_oidc_settings(apps, _schema_editor): + """ + Create the OIDCSettings singleton. + + One-time courtesy import of any ``[auth.oidc]`` TOML keys from the + pre-GUI config era (the section was never in a release, but a test + deployment may have used it). Falls back to model defaults. + """ + config = getattr(settings, "CODEX_CONFIG", {}) + oidc_settings = apps.get_model("codex", "OIDCSettings") + oidc_settings.objects.get_or_create( + pk=1, + defaults={ + "enabled": get_bool(config, "auth.oidc.enabled", default=False), + "provider_name": get_str(config, "auth.oidc.provider_name", default="SSO"), + "server_url": get_str(config, "auth.oidc.server_url", default=""), + "client_id": get_str(config, "auth.oidc.client_id", default=""), + "client_secret": get_str(config, "auth.oidc.client_secret", default=""), + "scope": get_str(config, "auth.oidc.scope", default="openid profile email"), + "pkce": get_bool(config, "auth.oidc.pkce", default=True), + "token_auth_method": get_str( + config, "auth.oidc.token_auth_method", default="" + ), + "fetch_userinfo": get_bool( + config, "auth.oidc.fetch_userinfo", default=True + ), + "username_claim": get_str( + config, "auth.oidc.username_claim", default="preferred_username" + ), + "create_users": get_bool(config, "auth.oidc.create_users", default=True), + "link_by_email": get_bool(config, "auth.oidc.link_by_email", default=False), + "sync_groups": get_bool(config, "auth.oidc.sync_groups", default=False), + "groups_claim": get_str(config, "auth.oidc.groups_claim", default="groups"), + "admin_group": get_str(config, "auth.oidc.admin_group", default=""), + "rp_initiated_logout": get_bool( + config, "auth.oidc.rp_initiated_logout", default=False + ), + }, + ) + + +class Migration(migrations.Migration): + """Migrate db.""" + + dependencies = [ + ("codex", "0046_comicboxtaggingdefaults_rename_files"), + ] + + operations = [ + migrations.CreateModel( + name="OIDCSettings", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("enabled", models.BooleanField(default=False)), + ( + "provider_name", + models.CharField(blank=True, default="SSO", max_length=128), + ), + ("server_url", models.URLField(blank=True, default="", max_length=512)), + ("client_id", models.CharField(blank=True, default="", max_length=128)), + ( + "client_secret", + codex.models.fields.EncryptedCharField( + blank=True, default="", max_length=512 + ), + ), + ( + "scope", + models.CharField( + blank=True, default="openid profile email", max_length=128 + ), + ), + ("pkce", models.BooleanField(default=True)), + ( + "token_auth_method", + models.CharField(blank=True, default="", max_length=128), + ), + ("fetch_userinfo", models.BooleanField(default=True)), + ( + "username_claim", + models.CharField( + blank=True, default="preferred_username", max_length=128 + ), + ), + ("create_users", models.BooleanField(default=True)), + ("link_by_email", models.BooleanField(default=False)), + ("sync_groups", models.BooleanField(default=False)), + ( + "groups_claim", + models.CharField(blank=True, default="groups", max_length=128), + ), + ( + "admin_group", + models.CharField(blank=True, default="", max_length=128), + ), + ("rp_initiated_logout", models.BooleanField(default=False)), + ], + options={ + "verbose_name_plural": "OIDCSettings", + "get_latest_by": "updated_at", + "abstract": False, + }, + ), + migrations.RunPython( + code=seed_oidc_settings, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/codex/models/admin.py b/codex/models/admin.py index e1f55e668..ca05f4363 100644 --- a/codex/models/admin.py +++ b/codex/models/admin.py @@ -32,6 +32,7 @@ "ComicboxTaggingDefaults", "EmailSettings", "LibrarianStatus", + "OIDCSettings", "ThrottleSettings", "Timestamp", ) @@ -174,6 +175,59 @@ class Meta(BaseModel.Meta): verbose_name_plural = "EmailSettings" +class OIDCSettings(BaseModel): + """ + Singleton OIDC (SSO) client configuration. + + The sole source of OIDC config — edited in the Admin UI Auth tab and + read at request time (``codex.settings.db.get_oidc_settings``), so + enabling, disabling, or repointing the identity provider takes + effect on the next request without a restart. Toggling only adds or + removes a login method: existing sessions and local password login + are unaffected, so an admin cannot lock themselves out here. + + OIDC is effectively enabled only when ``enabled`` AND ``server_url`` + AND ``client_id`` are all set — see + :func:`codex.settings.db.oidc_enabled`. + + ``client_secret`` is encrypted at rest via :class:`EncryptedCharField` + using ``FIELD_ENCRYPTION_KEY``. + """ + + enabled = BooleanField(default=False) + provider_name = CharField(max_length=MAX_NAME_LEN, blank=True, default="SSO") + # Issuer URLs run long (e.g. Authentik's /application/o//). + server_url = URLField(max_length=512, blank=True, default="") + client_id = CharField(max_length=MAX_NAME_LEN, blank=True, default="") + client_secret = EncryptedCharField() + scope = CharField( + max_length=MAX_NAME_LEN, blank=True, default="openid profile email" + ) + pkce = BooleanField(default=True) + token_auth_method = CharField(max_length=MAX_NAME_LEN, blank=True, default="") + fetch_userinfo = BooleanField(default=True) + username_claim = CharField( + max_length=MAX_NAME_LEN, blank=True, default="preferred_username" + ) + create_users = BooleanField(default=True) + link_by_email = BooleanField(default=False) + sync_groups = BooleanField(default=False) + groups_claim = CharField(max_length=MAX_NAME_LEN, blank=True, default="groups") + admin_group = CharField(max_length=MAX_NAME_LEN, blank=True, default="") + rp_initiated_logout = BooleanField(default=False) + + @override + def save(self, *args, **kwargs): + """Enforce singleton: always use pk=1.""" + self.pk = 1 + super().save(*args, **kwargs) + + class Meta(BaseModel.Meta): + """Constraints.""" + + verbose_name_plural = "OIDCSettings" + + class ThrottleSettings(BaseModel): """ Singleton DRF rate-limit configuration. diff --git a/codex/oidc.py b/codex/oidc.py new file mode 100644 index 000000000..b1338221d --- /dev/null +++ b/codex/oidc.py @@ -0,0 +1,392 @@ +""" +Native OIDC (SSO) login via django-allauth. + +The allauth apps are always installed (see ``codex.settings``); everything +here is gated at runtime on the :class:`codex.models.admin.OIDCSettings` +DB singleton, edited in the Admin UI Auth tab. cachalot caches the row and +invalidates it on write, so enabling, disabling, or repointing the +identity provider takes effect on the next request without a restart. +The flow is: + +1. ``GET /api/v4/auth/oidc/login`` (:class:`OIDCLoginRedirectView`, throttled, + 404 when disabled) redirects to allauth's provider login view. +2. allauth redirects to the identity provider's authorize endpoint + (state + PKCE bound to the Django session). allauth discovers the + provider app through :meth:`CodexSocialAccountAdapter.list_apps`, + which builds an unsaved ``SocialApp`` from the DB singleton — when + OIDC is off there is no app, so allauth's own + ``SocialApp.DoesNotExist -> Http404`` keeps every path a clean 404. +3. The provider redirects back to ``/sso/oidc/login/callback/`` where + allauth exchanges the code and builds a ``SocialLogin``. +4. :class:`CodexSocialAccountAdapter` links or provisions the Django user + and allauth logs it in with a normal session cookie, landing on the SPA + root where ``loadSession()`` picks up the session. + +Every signup path resolves inside the adapter: failures and policy +rejections redirect to the SPA's ``/auth/sso-error`` route instead of ever +rendering an allauth template. +""" + +from hashlib import sha256 +from typing import TYPE_CHECKING, NoReturn, override +from urllib.parse import urlencode + +import requests +from allauth.account.utils import user_username +from allauth.core.exceptions import ImmediateHttpResponse +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from django.contrib.auth.models import Group, User +from django.core.cache import cache +from django.http import Http404, HttpResponseRedirect +from django.shortcuts import redirect +from django.urls import reverse +from loguru import logger +from rest_framework.views import APIView + +from codex.failed_login_log import log_failed_login +from codex.settings import GRANIAN_URL_PATH_PREFIX +from codex.settings.db import get_oidc_settings, oidc_enabled +from codex.throttling import ScopedRateThrottle + +if TYPE_CHECKING: + from allauth.socialaccount.models import SocialApp, SocialLogin + from django.http import HttpRequest + + from codex.models.admin import OIDCSettings + +_SSO_ERROR_PATH = f"{GRANIAN_URL_PATH_PREFIX}/auth/sso-error" +_OIDC_PROVIDER_ID = "oidc" +_DEFAULT_USERNAME_CLAIM = "preferred_username" +# OAuth2 error codes passed through to the SPA error page; anything else +# collapses to server_error so the redirect never reflects attacker input. +_KNOWN_ERRORS = frozenset( + { + "access_denied", + "email_exists", + "server_error", + "signup_disabled", + "temporarily_unavailable", + } +) + + +DISCOVERY_CACHE_KEY = "codex-oidc-discovery" +_DISCOVERY_CACHE_TTL = 60 * 60 +_DISCOVERY_FAILURE_TTL = 60 * 5 +_DISCOVERY_TIMEOUT = 5 +_WELL_KNOWN = "/.well-known/openid-configuration" + + +def user_is_oidc_managed(user) -> bool: + """Return whether the user's identity is owned by the identity provider.""" + return bool( + user and user.pk and user.is_authenticated and user.socialaccount_set.exists() + ) + + +def discovery_url(server_url: str) -> str: + """Return the well-known discovery URL for an issuer URL.""" + if "/.well-known/" in server_url: + return server_url + return server_url.rstrip("/") + _WELL_KNOWN + + +def fetch_discovery_document(server_url: str) -> dict: + """ + Fetch the provider's OIDC discovery document, uncached. + + Raises ``requests.RequestException`` or ``ValueError`` on failure — + used directly by the Admin UI's Test Connection endpoint. + """ + response = requests.get(discovery_url(server_url), timeout=_DISCOVERY_TIMEOUT) + response.raise_for_status() + return response.json() + + +def _discovery_document(server_url: str) -> dict: + """ + Fetch and cache the provider's OIDC discovery document. + + Failures are negative-cached briefly so an unreachable identity + provider can't turn every session boot into a hanging request. + """ + doc = cache.get(DISCOVERY_CACHE_KEY) + if doc is not None: + return doc + try: + doc = fetch_discovery_document(server_url) + except (requests.RequestException, ValueError) as exc: + logger.warning(f"OIDC discovery fetch failed: {type(exc).__name__}") + cache.set(DISCOVERY_CACHE_KEY, {}, _DISCOVERY_FAILURE_TTL) + return {} + cache.set(DISCOVERY_CACHE_KEY, doc, _DISCOVERY_CACHE_TTL) + return doc + + +def oidc_logout_url(request: "HttpRequest") -> str: + """ + Build the RP-initiated logout URL, or "" when unavailable. + + Uses the spec's ``client_id`` + ``post_logout_redirect_uri`` + parameters — no ``id_token_hint`` because allauth does not retain the + raw ID token JWT. Authentik accepts this; Authelia has no end_session + endpoint, so its discovery document simply omits the key. + """ + row = get_oidc_settings() + if not (oidc_enabled(row) and row and row.rp_initiated_logout): + return "" + end_session = _discovery_document(row.server_url).get("end_session_endpoint") + if not end_session: + return "" + post_logout = request.build_absolute_uri(GRANIAN_URL_PATH_PREFIX or "/") + params = urlencode( + {"client_id": row.client_id, "post_logout_redirect_uri": post_logout} + ) + return f"{end_session}?{params}" + + +def _raise_sso_error(error: str) -> NoReturn: + """Abort the login flow with a redirect to the SPA's SSO error page.""" + if error not in _KNOWN_ERRORS: + error = "server_error" + raise ImmediateHttpResponse(redirect(f"{_SSO_ERROR_PATH}?error={error}")) + + +def merged_claims(sociallogin: "SocialLogin") -> dict: + """ + Merge ID-token and userinfo claims into one mapping; userinfo wins. + + allauth's openid_connect provider stores ``extra_data`` as + ``{"id_token": {...}, "userinfo": {...}}`` but its ``_pick_data`` + *prefers* userinfo rather than merging, so a claim present only in the + ID token (e.g. Authentik's ``groups``) would be invisible. Merging + tolerates both Authentik (claims in the ID token) and Authelia + (claims only in userinfo). + """ + extra = sociallogin.account.extra_data or {} + id_token = extra.get("id_token") or {} + userinfo = extra.get("userinfo") or {} + if id_token or userinfo: + return {**id_token, **userinfo} + # Pre-65.11 allauth stored a flat claims dict. + return dict(extra) + + +def _map_username(claims: dict, row: "OIDCSettings | None") -> str: + """Map claims to a Codex username: username_claim -> email -> sub.""" + claim = (row.username_claim if row else "") or _DEFAULT_USERNAME_CLAIM + username = claims.get(claim) or claims.get("email") or claims.get("sub") or "" + return str(username) + + +def _build_social_app(row: "OIDCSettings") -> "SocialApp": + """ + Build allauth's unsaved SocialApp from the DB singleton. + + The same shape allauth itself builds from + ``SOCIALACCOUNT_PROVIDERS[...]["APPS"]`` entries — per-app + ``settings["scope"]`` wins over provider-level SCOPE in + ``OAuth2Provider.get_scope``. + """ + from allauth.socialaccount.models import SocialApp + + app_settings = { + "server_url": row.server_url, + "oauth_pkce_enabled": row.pkce, + "fetch_userinfo": row.fetch_userinfo, + "scope": row.scope.split(), + } + if row.token_auth_method: + app_settings["token_auth_method"] = row.token_auth_method + return SocialApp( + provider="openid_connect", + provider_id=_OIDC_PROVIDER_ID, + name=row.provider_name or "SSO", + client_id=row.client_id, + secret=row.client_secret, + settings=app_settings, + ) + + +class CodexSocialAccountAdapter(DefaultSocialAccountAdapter): + """Link, provision, and sync Codex users from OIDC claims.""" + + @override + def list_apps(self, request, provider=None, client_id=None) -> list: + """ + Blend in the OIDC app configured by the Admin UI singleton. + + Mirrors allauth's own filter semantics for settings-configured + apps. When OIDC is disabled no app exists, so allauth's provider + views 404 on their own. + """ + apps = super().list_apps(request, provider=provider, client_id=client_id) + row = get_oidc_settings() + if not (oidc_enabled(row) and row): + return apps + app = _build_social_app(row) + if client_id and app.client_id != client_id: + return apps + if provider and provider not in (app.provider, app.provider_id): + return apps + apps.append(app) + return apps + + @override + def is_open_for_signup(self, request, sociallogin) -> bool: + """Gate auto-provisioning on the create_users config knob.""" + row = get_oidc_settings() + return bool(row and row.create_users) + + @override + def pre_social_login(self, request, sociallogin) -> None: + """ + Resolve every login path before allauth's signup machinery runs. + + Linking policy (user decision, documented in the README): an OIDC + login auto-links to an existing local user with the same username — + including superusers — and by email only when ``link_by_email`` is + on. Linking records the SocialAccount keyed on the stable ``sub`` + claim, so later logins match by sub even if the username changes. + """ + row = get_oidc_settings() + claims = merged_claims(sociallogin) + if sociallogin.is_existing: + # Known sub: sync and let allauth log the user in. + self._sync_user(sociallogin.user, claims, row) + return + if user := self._find_linkable_user(claims, row): + sociallogin.connect(request, user) + self._sync_user(user, claims, row) + return + if not (row and row.create_users): + log_failed_login(request, _map_username(claims, row)) + _raise_sso_error("signup_disabled") + if self._email_conflict(claims): + # Unlinked local user owns this email and link_by_email is + # off. Without this check allauth would render its own signup + # form into the SPA-less void. + log_failed_login(request, _map_username(claims, row)) + _raise_sso_error("email_exists") + + @override + def populate_user(self, request, sociallogin, data): + """ + Apply the configured username claim chain to new users. + + If the mapped username is already taken (an already-linked account + owns it, so pre_social_login won't link), suffix with a short hash + of the sub. Without this, allauth blanks the colliding username and + falls back to a generic "user" name. + """ + user = super().populate_user(request, sociallogin, data) + row = get_oidc_settings() + if username := _map_username(merged_claims(sociallogin), row): + if User.objects.filter(username__iexact=username).exists(): + sub_hash = sha256(str(sociallogin.account.uid).encode()).hexdigest() + username = f"{username}-{sub_hash[:8]}" + user_username(user, username) + return user + + @override + def save_user(self, request, sociallogin, form=None): + """Provision a new user, then sync groups/admin from claims.""" + user = super().save_user(request, sociallogin, form) + self._sync_user(user, merged_claims(sociallogin), get_oidc_settings()) + return user + + @override + def on_authentication_error( + self, request, provider, error=None, exception=None, extra_context=None + ) -> None: + """Log the failure and land on the SPA error page, never a template.""" + del provider, extra_context + # Same record format as password failures so fail2ban regexes and + # the dedicated-log privacy story apply unchanged. When the + # failed-login log is disabled the record is dropped by the main + # sinks' filter, so this degrades gracefully. + log_failed_login(request) + code = str(error) if error else "server_error" + if exception: # pragma: no cover - depends on provider behavior + # str(exception) may embed tokens or secrets; log type only. + log_failed_login(request, f"oidc-exception={type(exception).__name__}") + _raise_sso_error(code) + + @staticmethod + def _find_linkable_user(claims: dict, row: "OIDCSettings | None") -> User | None: + """ + Find an existing unlinked local user to attach this login to. + + Skips users that already have an OIDC link (a different sub with + the same preferred_username must not hijack their account). + """ + if username := _map_username(claims, row): + user = User.objects.filter(username__iexact=username).first() + if user and not user.socialaccount_set.exists(): # pyright: ignore[reportAttributeAccessIssue], # ty: ignore[unresolved-attribute] + return user + if row and row.link_by_email and (email := claims.get("email")): + user = User.objects.filter(email__iexact=str(email)).first() + if user and not user.socialaccount_set.exists(): # pyright: ignore[reportAttributeAccessIssue], # ty: ignore[unresolved-attribute] + return user + return None + + @staticmethod + def _email_conflict(claims: dict) -> bool: + """Return whether creating this user collides with an existing email.""" + email = claims.get("email") + return bool(email and User.objects.filter(email__iexact=str(email)).exists()) + + @staticmethod + def _sync_user(user, claims: dict, row: "OIDCSettings | None") -> None: + """ + Sync Django groups and admin flags from the groups claim. + + Runs on every OIDC login so library ACLs track the identity + provider. An absent or malformed groups claim is a no-op — Authelia + omits it entirely unless the ``groups`` scope is requested. Only + EXISTING Codex groups are matched; groups are never created because + they gate library ACLs. + """ + if not (user and row and (row.sync_groups or row.admin_group)): + return + group_names = claims.get(row.groups_claim or "groups") + if not isinstance(group_names, list): + return + names = {str(name) for name in group_names} + if row.sync_groups: + user.groups.set(Group.objects.filter(name__in=names)) + if row.admin_group: + # The identity provider is authoritative: grants AND revokes. + CodexSocialAccountAdapter._sync_admin( + user, is_admin=row.admin_group in names + ) + + @staticmethod + def _sync_admin(user, *, is_admin: bool) -> None: + """Grant or revoke staff+superuser to match the admin-group claim.""" + if user.is_staff != is_admin or user.is_superuser != is_admin: + user.is_staff = user.is_superuser = is_admin + user.save(update_fields=("is_staff", "is_superuser")) + + +class OIDCLoginRedirectView(APIView): + """ + Branded, throttled entry point for OIDC login. + + A plain GET navigation (not XHR): the SPA sets + ``window.location.href`` here and the chain of redirects ends back at + the SPA root with a session cookie. 404s when OIDC is disabled, like + RegisterView does when registration is off. + """ + + authentication_classes = () + throttle_classes = (ScopedRateThrottle,) + throttle_scope = "oidc_login" + + def get(self, request: "HttpRequest") -> HttpResponseRedirect: + """Redirect to allauth's provider login view.""" + del request + if not oidc_enabled(): + raise Http404 + url = reverse("openid_connect_login", kwargs={"provider_id": _OIDC_PROVIDER_ID}) + return HttpResponseRedirect(url) diff --git a/codex/serializers/admin/oidc.py b/codex/serializers/admin/oidc.py new file mode 100644 index 000000000..ea3880e08 --- /dev/null +++ b/codex/serializers/admin/oidc.py @@ -0,0 +1,98 @@ +"""OIDCSettings admin serializers.""" + +from typing import override + +from rest_framework.exceptions import ValidationError +from rest_framework.fields import SerializerMethodField +from rest_framework.serializers import BooleanField, CharField, Serializer, URLField + +from codex.models import OIDCSettings +from codex.serializers.models.base import BaseModelSerializer + +_ENABLE_REQUIRES_MSG = ( + "OIDC login cannot be enabled without a provider name," + " a server URL, and a client ID." +) + + +class OIDCSettingsSerializer(BaseModelSerializer): + """Serializer for the OIDCSettings singleton.""" + + # Secret is write-only — clients see whether one is set, not the value. + client_secret = CharField(write_only=True, required=False, allow_blank=True) + client_secret_set = SerializerMethodField() + + @staticmethod + def get_client_secret_set(obj) -> bool: + """Whether an OIDC client secret has been configured.""" + return bool(obj.client_secret) + + @override + def validate(self, attrs): + """ + Reject an enabled-but-unconfigured state. + + The UI disables the enable switch until a provider name, server + URL, and client ID are present; this enforces the same invariant + for API clients and for partial updates that blank a prerequisite + while leaving ``enabled`` on. Merges the incoming fields over the + saved row so partial PUTs validate against the effective result. + """ + merged = { + field: attrs.get(field, getattr(self.instance, field, "")) + for field in ("enabled", "provider_name", "server_url", "client_id") + } + if merged["enabled"] and not ( + merged["provider_name"] and merged["server_url"] and merged["client_id"] + ): + raise ValidationError({"enabled": _ENABLE_REQUIRES_MSG}) + return attrs + + class Meta(BaseModelSerializer.Meta): + """Specify model and fields.""" + + model = OIDCSettings + fields = ( + "enabled", + "provider_name", + "server_url", + "client_id", + "client_secret", + "client_secret_set", + "scope", + "pkce", + "token_auth_method", + "fetch_userinfo", + "username_claim", + "create_users", + "link_by_email", + "sync_groups", + "groups_claim", + "admin_group", + "rp_initiated_logout", + ) + read_only_fields = ("client_secret_set",) + + +class OIDCTestRequestSerializer(Serializer): + """ + Request body for the OIDC Test Connection endpoint. + + ``server_url`` is optional: when present it overrides the saved row + for this one probe, so an admin can validate an issuer URL before + saving it (the email Test Send pattern). + """ + + server_url = URLField(required=False, allow_blank=True) + + +class OIDCTestResponseSerializer(Serializer): + """Test-connection outcome: which endpoints the provider advertises.""" + + ok = BooleanField() + error = CharField(allow_null=True, required=False, default=None) + issuer = CharField(allow_null=True, required=False, default=None) + authorization_endpoint = BooleanField(required=False, default=False) + token_endpoint = BooleanField(required=False, default=False) + userinfo_endpoint = BooleanField(required=False, default=False) + end_session_endpoint = BooleanField(required=False, default=False) diff --git a/codex/serializers/auth.py b/codex/serializers/auth.py index d07e46769..391519387 100644 --- a/codex/serializers/auth.py +++ b/codex/serializers/auth.py @@ -9,10 +9,25 @@ from rest_framework.serializers import Serializer from rest_registration.api.serializers import DefaultUserProfileSerializer +from codex.oidc import user_is_oidc_managed from codex.serializers.fields.auth import TimezoneField from codex.serializers.versions import VersionsSerializer +def _username_locked(serializer: Serializer) -> bool: + """ + Return whether an external identity manages the request user's username. + + Global when ``AUTH_REMOTE_USER`` owns identity; per-user when the + account is linked to an OIDC identity provider. Renaming in either + case would desync the user from the IdP and lock them out. + """ + if settings.AUTH_REMOTE_USER: + return True + request = serializer.context.get("request") if serializer.context else None + return user_is_oidc_managed(getattr(request, "user", None)) + + class CodexProfileSerializer(DefaultUserProfileSerializer): """ Profile serializer for self-service edits of username + email. @@ -33,7 +48,7 @@ class CodexProfileSerializer(DefaultUserProfileSerializer): @override def get_fields(self): - """Make email writable; lock username under remote-user auth.""" + """Make email writable; lock username under external identity.""" fields = super().get_fields() email = fields.get("email") if email is not None: @@ -46,7 +61,7 @@ def get_fields(self): # walking the parent ``Field`` type and complaining. if hasattr(email, "allow_blank"): setattr(email, "allow_blank", True) # noqa: B010 - if settings.AUTH_REMOTE_USER and "username" in fields: + if "username" in fields and _username_locked(self): fields["username"].read_only = True return fields @@ -68,6 +83,10 @@ class AdminFlagsSerializer(Serializer): register_verification = BooleanField(read_only=True) email_enabled = BooleanField(read_only=True) remote_user_enabled = BooleanField(read_only=True) + oidc_enabled = BooleanField(read_only=True) + oidc_provider_name = CharField(read_only=True, allow_blank=True) + oidc_login_url = CharField(read_only=True, allow_blank=True) + oidc_logout_url = CharField(read_only=True, allow_blank=True) class UserSerializer(Serializer): @@ -102,9 +121,10 @@ class ProfileUpdateSerializer(Serializer): """ Writable subset of the user profile. - ``username`` is read-only when ``AUTH_REMOTE_USER`` owns identity. - Empty strings on ``email`` mean "clear it"; the - rest-registration-backed profile view accepts that semantics. + ``username`` is read-only when an external identity (remote-user + proxy or a linked OIDC provider) owns it. Empty strings on ``email`` + mean "clear it"; the rest-registration-backed profile view accepts + that semantics. """ username = CharField(required=False, allow_blank=False) @@ -113,9 +133,9 @@ class ProfileUpdateSerializer(Serializer): @override def get_fields(self): - """Lock username under remote-user auth (matches v3 profile semantics).""" + """Lock username under external identity (matches v3 profile semantics).""" fields = super().get_fields() - if settings.AUTH_REMOTE_USER and "username" in fields: + if "username" in fields and _username_locked(self): fields["username"].read_only = True return fields diff --git a/codex/settings/__init__.py b/codex/settings/__init__.py index 0186eecb2..ea1e967ba 100644 --- a/codex/settings/__init__.py +++ b/codex/settings/__init__.py @@ -521,6 +521,12 @@ def _get_installed_apps(features: FeatureFlags) -> tuple[str, ...]: "rest_framework", "rest_framework.authtoken", "rest_registration", + # allauth apps are installed unconditionally (like rest_registration); + # OIDC behavior is gated at runtime on the OIDCSettings singleton. + "allauth", + "allauth.account", + "allauth.socialaccount", + "allauth.socialaccount.providers.openid_connect", "corsheaders", ] if features.django_vite: @@ -566,6 +572,7 @@ def _get_middleware(features: FeatureFlags) -> tuple[str, ...]: if AUTH_FAILED_LOGIN_LOG: middleware.append("codex.failed_login_log.RequestContextMiddleware") middleware += [ + "allauth.account.middleware.AccountMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "codex.middleware.CodexMiddleware", @@ -698,11 +705,41 @@ def _get_middleware(features: FeatureFlags) -> tuple[str, ...]: # Authentication # ################## +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + # Authenticates completed OIDC logins (allauth). Inert unless a + # sociallogin flow runs, so safe to include unconditionally. + "allauth.account.auth_backends.AuthenticationBackend", +] if AUTH_REMOTE_USER: - AUTHENTICATION_BACKENDS = [ - "django.contrib.auth.backends.RemoteUserBackend", - "django.contrib.auth.backends.ModelBackend", - ] + AUTHENTICATION_BACKENDS.insert(0, "django.contrib.auth.backends.RemoteUserBackend") + +# django-allauth: native OIDC (SSO) login. Apps and URLs are always +# installed; behavior is gated at request time on the OIDCSettings DB +# singleton (no provider app when disabled => allauth 404s). +SOCIALACCOUNT_ADAPTER = "codex.oidc.CodexSocialAccountAdapter" +# The init endpoint is a plain GET navigation; state + PKCE provide the +# anti-forgery binding. +SOCIALACCOUNT_LOGIN_ON_GET = True +SOCIALACCOUNT_AUTO_SIGNUP = True +# Never gate logins on email_verified: Authentik defaults it to False. +SOCIALACCOUNT_EMAIL_VERIFICATION = "none" +ACCOUNT_EMAIL_VERIFICATION = "none" +# No use for stored access/refresh tokens: Codex never calls the +# identity provider on the user's behalf, and RP-initiated logout uses +# the spec's client_id parameter instead of id_token_hint (allauth does +# not retain the raw id_token JWT anyway). +SOCIALACCOUNT_STORE_TOKENS = False +# No SOCIALACCOUNT_PROVIDERS: the provider app is built at request time +# from the OIDCSettings DB singleton (Admin UI Auth tab) by +# CodexSocialAccountAdapter.list_apps, so config changes are live +# without a restart. +# allauth defaults this to "oidc", which would double up with the +# provider_id path segment: /sso/oidc/oidc/login/. Codex mounts the +# provider urls at "sso/" (urls/root.py), so /sso/oidc/login/. +SOCIALACCOUNT_OPENID_CONNECT_URL_PREFIX = "" +# Where the OIDC callback lands the user after login. +LOGIN_REDIRECT_URL = GRANIAN_URL_PATH_PREFIX or "/" # https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ @@ -819,6 +856,9 @@ def _get_middleware(features: FeatureFlags) -> tuple[str, ...]: "reset_password": ( f"{THROTTLE_RESET_PASSWORD}/hour" if THROTTLE_RESET_PASSWORD else None ), + # The OIDC init endpoint is an unauthenticated redirect; a modest + # per-IP cap keeps it from being used to spam the identity provider. + "oidc_login": "10/min", } _RENDERER_CLASSES = [ diff --git a/codex/settings/codex.toml.default b/codex/settings/codex.toml.default index 1ab6a1b3f..fac0ed580 100644 --- a/codex/settings/codex.toml.default +++ b/codex/settings/codex.toml.default @@ -123,6 +123,10 @@ # Disable if Codex is exposed directly (otherwise clients can forge their IP). # failed_login_log_trust_forwarded_for = true +# OIDC single sign-on (Authentik, Authelia, ...) is configured in the +# Admin UI under the "Auth" tab, including the client secret (encrypted +# at rest) and a Test Connection button. There are no TOML keys for it. + # [custom_covers] # Maximum size (in megabytes) of a custom-cover upload. This setting # is also exposed in the Admin UI under "Custom Covers"; the DB value diff --git a/codex/settings/db.py b/codex/settings/db.py index c69a36944..487623845 100644 --- a/codex/settings/db.py +++ b/codex/settings/db.py @@ -25,7 +25,12 @@ from codex.choices.admin import AdminFlagChoices if TYPE_CHECKING: - from codex.models.admin import AdminFlag, EmailSettings, ThrottleSettings + from codex.models.admin import ( + AdminFlag, + EmailSettings, + OIDCSettings, + ThrottleSettings, + ) THROTTLE_SCOPE_PERIOD: dict[str, str] = { "anon": "min", @@ -97,6 +102,30 @@ def get_email_settings() -> EmailSettings | None: return None +def get_oidc_settings() -> OIDCSettings | None: + """Return the OIDCSettings singleton or None when DB isn't ready.""" + try: + from codex.models.admin import OIDCSettings + except (ImportError, RuntimeError): + return None + try: + return OIDCSettings.objects.filter(pk=1).first() + except Exception: + return None + + +def oidc_enabled(row: OIDCSettings | None = None) -> bool: + """ + Return whether OIDC login is effectively on. + + Requires the enabled switch AND a server_url AND a client_id. Pass + the row in when the caller already fetched it. + """ + if row is None: + row = get_oidc_settings() + return bool(row and row.enabled and row.server_url and row.client_id) + + def _coalesce(db_value: Any, settings_value: Any) -> Any: """Prefer ``db_value`` when truthy; otherwise fall back to ``settings_value``.""" return db_value or settings_value diff --git a/codex/urls/api/v4/admin.py b/codex/urls/api/v4/admin.py index 7b9961df0..cb095e058 100644 --- a/codex/urls/api/v4/admin.py +++ b/codex/urls/api/v4/admin.py @@ -24,6 +24,7 @@ AdminFolderListView, AdminLibraryViewSet, ) +from codex.views.admin.oidc import AdminOIDCSettingsView, AdminOIDCTestView from codex.views.admin.onlinetag import ( AdminOnlineTagAbortView, AdminOnlineTagActiveView, @@ -167,6 +168,16 @@ AdminEmailTestSendView.as_view(), name="email_settings_test", ), + path( + "oidc-settings", + AdminOIDCSettingsView.as_view(), + name="oidc_settings", + ), + path( + "oidc-settings/test", + AdminOIDCTestView.as_view(), + name="oidc_settings_test", + ), path( "tagging-defaults", AdminTaggingDefaultsView.as_view(), diff --git a/codex/urls/api/v4/auth.py b/codex/urls/api/v4/auth.py index 0935ded36..2c4be1991 100644 --- a/codex/urls/api/v4/auth.py +++ b/codex/urls/api/v4/auth.py @@ -4,6 +4,7 @@ from rest_registration.api.views.change_password import ChangePasswordView from rest_registration.api.views.login import LoginView, LogoutView +from codex.oidc import OIDCLoginRedirectView from codex.views.auth import AuthToken, CSRFView, ProfileView from codex.views.register import RegisterView from codex.views.reset_password import ResetPasswordView, SendResetPasswordLinkView @@ -14,6 +15,7 @@ path("register", RegisterView.as_view(), name="register"), path("login", LoginView.as_view(), name="login"), path("logout", LogoutView.as_view(), name="logout"), + path("oidc/login", OIDCLoginRedirectView.as_view(), name="oidc-login"), path("token", AuthToken.as_view(), name="token"), path("profile", ProfileView.as_view(), name="profile"), path( diff --git a/codex/urls/app.py b/codex/urls/app.py index 53f68b550..c161418a9 100644 --- a/codex/urls/app.py +++ b/codex/urls/app.py @@ -41,6 +41,9 @@ # so Vue Router can render the reset screen; without it the catch-all below # would redirect the link to the home page. path("auth/reset-password/", IndexView.as_view(), name="reset-password"), + # Client-side auth route reached from the OIDC adapter's error + # redirects (codex/oidc.py). Same catch-all hazard as reset-password. + path("auth/sso-error", IndexView.as_view(), name="sso-error"), path("", IndexView.as_view(), name="start"), re_path( ".*", diff --git a/codex/urls/root.py b/codex/urls/root.py index 2c964c6cb..098a49b5a 100644 --- a/codex/urls/root.py +++ b/codex/urls/root.py @@ -43,6 +43,10 @@ name="robots", ), path("api/", include("codex.urls.api.root")), + # allauth's OIDC login/callback. Mounted at the root, outside the + # namespaced api tree, because allauth reverses its own URL names + # without a namespace. The views 404 when no OIDC app is configured. + path("sso/", include("allauth.socialaccount.providers.openid_connect.urls")), path("opds/", include("codex.urls.opds.root")), path("opds", RedirectView.as_view(pattern_name="opds:v1:start")), path("health", health_check_view, name="healthcheck"), diff --git a/codex/views/admin/oidc.py b/codex/views/admin/oidc.py new file mode 100644 index 000000000..26f5edeeb --- /dev/null +++ b/codex/views/admin/oidc.py @@ -0,0 +1,79 @@ +"""Admin OIDC Settings View.""" + +from __future__ import annotations + +import requests +from django.core.cache import cache +from loguru import logger +from rest_framework.response import Response + +from codex.models import OIDCSettings +from codex.oidc import DISCOVERY_CACHE_KEY, fetch_discovery_document +from codex.serializers.admin.oidc import ( + OIDCSettingsSerializer, + OIDCTestRequestSerializer, + OIDCTestResponseSerializer, +) +from codex.views.admin.auth import AdminAPIView + + +class AdminOIDCSettingsView(AdminAPIView): + """GET/PUT for the OIDCSettings singleton.""" + + def get(self, _request): + """Return the current OIDC settings.""" + row = OIDCSettings.objects.get(pk=1) + serializer = OIDCSettingsSerializer(row) + return Response(serializer.data) + + def put(self, request): + """Update OIDC settings and drop the cached discovery document.""" + row = OIDCSettings.objects.get(pk=1) + serializer = OIDCSettingsSerializer(row, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + # The issuer may have changed; the next login or logout-URL + # build must re-discover the provider's endpoints. + cache.delete(DISCOVERY_CACHE_KEY) + return Response(OIDCSettingsSerializer(row).data) + + +class AdminOIDCTestView(AdminAPIView): + """POST a discovery-document probe against the identity provider.""" + + def post(self, request): + """Fetch the discovery document and report the advertised endpoints.""" + req = OIDCTestRequestSerializer(data=request.data) + req.is_valid(raise_exception=True) + server_url = req.validated_data.get("server_url", "") + if not server_url: + row = OIDCSettings.objects.filter(pk=1).first() + server_url = row.server_url if row else "" + if not server_url: + response = OIDCTestResponseSerializer( + {"ok": False, "error": "No server URL configured."} + ) + return Response(response.data) + + try: + doc = fetch_discovery_document(server_url) + except (requests.RequestException, ValueError) as exc: + logger.warning("Codex OIDC test connection failed: {exc}", exc=exc) + response = OIDCTestResponseSerializer( + {"ok": False, "error": str(exc) or exc.__class__.__name__} + ) + return Response(response.data) + + response = OIDCTestResponseSerializer( + { + "ok": bool( + doc.get("authorization_endpoint") and doc.get("token_endpoint") + ), + "issuer": doc.get("issuer"), + "authorization_endpoint": bool(doc.get("authorization_endpoint")), + "token_endpoint": bool(doc.get("token_endpoint")), + "userinfo_endpoint": bool(doc.get("userinfo_endpoint")), + "end_session_endpoint": bool(doc.get("end_session_endpoint")), + } + ) + return Response(response.data) diff --git a/codex/views/session.py b/codex/views/session.py index b3c916446..3e9f0dc78 100644 --- a/codex/views/session.py +++ b/codex/views/session.py @@ -16,8 +16,10 @@ from codex.choices.admin import AdminFlagChoices from codex.models import AdminFlag +from codex.oidc import oidc_logout_url from codex.serializers.auth import SessionSerializer -from codex.settings.db import email_enabled +from codex.settings import GRANIAN_URL_PATH_PREFIX +from codex.settings.db import email_enabled, get_oidc_settings, oidc_enabled from codex.views.auth import AuthGenericAPIView, user_payload from codex.views.version import version_payload @@ -56,8 +58,7 @@ class SessionView(AuthGenericAPIView): permission_classes = (AllowAny,) serializer_class = SessionSerializer - @staticmethod - def _admin_flags(*, authenticated: bool) -> dict: + def _admin_flags(self, *, authenticated: bool) -> dict: """ Build the auth-relevant admin flags + settings-derived capabilities. @@ -81,9 +82,23 @@ def _admin_flags(*, authenticated: bool) -> dict: # Public: the logged-out login dialog shows the reset-password # link when email is configured. flags["email_enabled"] = email_enabled() + # Public, unlike remote_user_enabled: the logged-out shell must + # render the "Login with " button. + oidc_row = get_oidc_settings() + oidc_on = oidc_enabled(oidc_row) + flags["oidc_enabled"] = oidc_on + if oidc_on and oidc_row: + flags["oidc_provider_name"] = oidc_row.provider_name or "SSO" + flags["oidc_login_url"] = ( + f"{GRANIAN_URL_PATH_PREFIX}/api/v4/auth/oidc/login" + ) # Private: only the authenticated profile dialog reads this. if authenticated: flags["remote_user_enabled"] = bool(settings.AUTH_REMOTE_USER) + # Computed while still authenticated because the frontend + # navigates here only after the local logout POST succeeds. + if logout_url := oidc_logout_url(self.request): + flags["oidc_logout_url"] = logout_url return flags @override diff --git a/eslint.config.js b/eslint.config.js index 5ae4006be..04fafa5da 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,6 +19,10 @@ export default defineConfig([ "codex/templates/**/*.html", // Handled by djlint "codex/templates/pwa/serviceworker-register.js", // removes eslint-disable that it then complains about "tasks", + // Manual SSO test harness: authentik blueprints need flow-style + // custom tags (!Find [...]) and compose needs healthcheck arrays, + // both of which the yml plugin's block-style rules reject. + "test-proxy/", // "frontend", ], }, diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 1e2e4bf6f..953bd0403 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -75,7 +75,9 @@ Prefer these over bespoke markup. Each enforces the language so it can't drift. - **`AdminSection`** (`admin-section.vue`) — a titled block. `title` prop, optional `hint` prop or `#hint` slot, optional `#actions` slot (header-right, e.g. a Stop or Add button). Replaces the `.adminGroup` + `.adminGroupHeader` + - `

` markup. + `

` markup. The `sub` boolean prop nests a section inside another + AdminSection: a small uppercase overline title (h4) and an indented left rule + make the subordination visible (see the Auth tab's OIDC block). - **`AdminActionBar`** (`action-bar.vue`) — the Save / Revert (or Save / cancel) row. Primary button is `type="submit"` `tonal`; secondary is `text`. Props `saveText`, `saving`, `saveDisabled`, `revertDisabled`; emits `revert`. diff --git a/frontend/bun.lock b/frontend/bun.lock index 60a495036..64fc71610 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -15,25 +15,25 @@ "vue": "^3.5.39", "vue-pdf-embed": "^2.1.5", "vue-router": "^5.1.0", - "vuetify": "^4.1.3", + "vuetify": "^4.1.4", "xior": "^0.8.3", }, "devDependencies": { "@mdi/font": "^7.4.47", "@mdi/js": "^7.4.47", "@pinia/testing": "^1.0.3", - "@types/node": "^26.1.0", + "@types/node": "^26.1.1", "@unhead/bundler": "^3.1.7", "@vitejs/plugin-vue": "^6.0.7", "@vue/test-utils": "^2.4.11", - "@vue/typescript-plugin": "^3.3.6", + "@vue/typescript-plugin": "^3.3.7", "happy-dom": "^20.10.6", "rollup-plugin-visualizer": "^7.0.1", "sass": "^1.101.0", "sass-loader": "^17.0.0", "toml": "^4.1.2", "typeface-roboto": "^1.1.13", - "vite": "^8.1.3", + "vite": "^8.1.4", "vite-plugin-checker": "^0.14.4", "vite-plugin-dynamic-base": "^1.4.1", "vite-plugin-run": "^0.9.1", @@ -56,7 +56,7 @@ "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], + "@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="], "@devframes/hub": ["@devframes/hub@0.5.4", "", { "dependencies": { "birpc": "^4.0.0", "nostics": "^0.2.0", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "tinyexec": "^1.2.2" }, "peerDependencies": { "devframe": "0.5.4" } }, "sha512-NZs5RFuNb6tjQd2JBsRYQT+OqE+z16Kg9/72HG4k+uJdzK90sAGtAjOwK8bsvav/9l85KGx4agfkgxnfbl313w=="], @@ -130,89 +130,89 @@ "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], "@one-ini/wasm": ["@one-ini/wasm@0.1.1", "", {}, "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="], - "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.137.0", "", { "os": "android", "cpu": "arm" }, "sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.139.0", "", { "os": "android", "cpu": "arm" }, "sha512-22EsXTA3Vc7OvrF4bfT48PFln2UbxkVgrp/Tm32qLw76Dv7SmcInfClJe6yPYamnli6HiqasnESZ5ezN+X4ybg=="], - "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.137.0", "", { "os": "android", "cpu": "arm64" }, "sha512-WhALNzfy3x/RfC6bsqX+csavuUY0yHHE7XfgPE5M542uhoBZUUoGTPG+nkMbGoG4+gcfss5s7urMyn5QBHu0sw=="], + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.139.0", "", { "os": "android", "cpu": "arm64" }, "sha512-uASQkZV+CUQ6xXvoMzDQyfhd8OMusdv2FyCPfAi5ZDYptesapJlw7erhpGi1Lc+U8QjQV2JUrIh7d/3su2LzmQ=="], - "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.137.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFPr5hgmNMOMoyPTGtdsK4Ug21RovIPojRMgDDhSp1LtCnc/DkLwGONKjgRjszg677RlGnkYSviQ8hHaUPOVYA=="], + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.139.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vuQOv2WF5pZCkdSPEExul98o853M3MCR24DpWsGrUoPJu5KcnGE34kLXY2yFwBwZwT+eN1x40Tt4q6ZzlEdNUA=="], - "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.137.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CL5dMm1asqXIDZHg14FLxj3Mc36w8PI7xCWh1uA4is6z8g2XrIILoTcQYOxDbwzuk34RDPX5IAGUxZr6LA9KAg=="], + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.139.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-zxZB8ChS+7XactZhcyxQ292P/DNiiBaPPKYiVUlb3RC0V/hme5bokNubjcmEmbW9uTfmqLTpveAdkbe66H3pGg=="], - "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.137.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-79h8rYGnSlKPGWo7mHr2ixO6ea7aW8B0CT965SZ8SLbNnCOH5aOYBTeVXUY6eMvEaiLyWr8Skuiugr5pDYgLGw=="], + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.139.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iw2MsoCPBQwdJqywRAGsF1jEAcGoZ+DeG2LVzt5pdUvRHqajsMF46BH0rHiWoWtMwcMSia+oQYga7fHo7u7Bcw=="], - "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.137.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ASgmlSimhGyr0lksgVIo6hibz1obnDq4qJbiMX/AzltfgPnanRrzG1Q+23g8ljOHOjv6dsznkUuCYL3gg0sY1Q=="], + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.139.0", "", { "os": "linux", "cpu": "arm" }, "sha512-SwjD/k3Y5bwGJWOH313VyaDrAuaYg1Pe+4AAXCgUKvSO5IHkj+mZ0oFcOWmB5nTuOM3uwdT+leL8h4OnFlI5hw=="], - "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.137.0", "", { "os": "linux", "cpu": "arm" }, "sha512-AU2J9aa22Sx32wRGnDjybOU9TQXXQUud5sdUi+ZB0XxwM8aToWLweV+yA0wlQm0yIUVqljquqoHCYEq9II8gJQ=="], + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.139.0", "", { "os": "linux", "cpu": "arm" }, "sha512-qbeygDkcvailKFhphb2YGMMXsaZIynHlPtoOrcx4NZB/tRbUKraCCMbw8dPeFtEKasfS2qWh1VnReY+Lcys1zA=="], - "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.137.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-GdEtiG89yMr7XkUGxifgodXEEm2f+xW2f9CpDjlgAnBOwhTmrpQMvhOGobLVKUyzf/qHBXW16smk5zbF3nZU6w=="], + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.139.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-SrlL02KeImKlvx/9rCeopOLH7qKI3rqKKEguK6KBwVwEGLhV/A47dW4DNigf6/LFhrwoovaiayEQryjYAI86dQ=="], - "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.137.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ=="], + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.139.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-u9e884ChAVRmIZ1jr/m46S96FoDQnruFjISLi4Y0i6Wu/JUUmIiw7+umLyXILJsPfUuqnN5BJLe23t07+Y6+IA=="], - "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.137.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ=="], + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.139.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z9tU2b3GJfAXOdirQmz4gZQkQkVjy53i77gf91l0733MQKa/qtk73KQQE2GzDtMqim+HyjpzvemmqzBtH2IJUA=="], - "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.137.0", "", { "os": "linux", "cpu": "none" }, "sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw=="], + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.139.0", "", { "os": "linux", "cpu": "none" }, "sha512-RyUbr7hzPK84YDWKs77PRYk9VBwWbsbuYsQzQWiSmLnARXTg2zntLPGfCH/1wpfUYdmGkp/6SsqTXSsYdw9Jgw=="], - "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.137.0", "", { "os": "linux", "cpu": "none" }, "sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g=="], + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.139.0", "", { "os": "linux", "cpu": "none" }, "sha512-dcQhjtcDvtR8BgkUpt03Yz5SzxdzYvTigenIJOEsiSX6G2t6yybEMxGWjp0dOuYGror0BaqcZfTyXR58amCEig=="], - "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.137.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA=="], + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.139.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-iuGrxysV4rGUymdKpn7bgZQ6Vix8Bi/6D/rp71HYIzphq6NKrsBhsGOYsSZte+uBFL43tXh7Xr7TM72sGliJNA=="], - "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.137.0", "", { "os": "linux", "cpu": "x64" }, "sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q=="], + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.139.0", "", { "os": "linux", "cpu": "x64" }, "sha512-NxJdZZyaa2JLLvNfH/iJQXfCNfKcPMylwY4ObMpBVmW4Nq+RyUpVVQXrMMelcQ6rwx3nuhF1Iga8n+eoAKGCIA=="], - "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.137.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg=="], + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.139.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ePxBvvtzISmSsJ0RIj8FNikSCn58i1jtccj7XR4U9Li4iSzhkFyYlnJ51cQTUwkairz8WMTD4SpKoot8RyTnQA=="], - "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.137.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA=="], + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.139.0", "", { "os": "none", "cpu": "arm64" }, "sha512-b/c2+mPXMOxG5x16n8yf9cjor/ntQQScmYnSmLEWIWJ4rfXd5dokMxx0kliSLA+YAGq6DD3K9BWi+aFXHiiV1w=="], - "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.137.0", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-gW2vfkytNGgMVADiuzdvOfw0mWG9za20F/1fCJsif5aBMAvWJTSbpIXbIe0XkOe0VENk+PadpQ7cZgUy2sUJcA=="], + "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.139.0", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-rNU0pO+/CVPC2qZ+xtX5R2cOje9Q75q3UkfgwUCPlplqxMv6Iffd5tMPGVMCLGnPINxPlmi0WPsW94HltbYvfQ=="], - "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.137.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-x+pFANF0yL5uK/6T7lu6SlR5qid6sp//eZXKLq5iNsIE+EQg6EaS8/wsW7E91nXXjpnPhSoMOHXShSVhGRdn8w=="], + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.139.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-JPEmfncZfqAFiGsAN6UZSMIBqnG8wn8buywRacFOWjP+9dZwcs03SsBp4tmyjM/4l/VoH/IuThrW1Jof3OyQUw=="], - "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.137.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-sQUqym80PFi6McRsIqfJrSu2JrSClEZIXXD+/FjAFoULEKzOPsldIdFBG96xdX8aVMzCNQ9792FPx3MfkEIrFA=="], + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.139.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-c06dWBwEnFHof5JN7T9/ts23uATXS9czPEBO60d4xnjH2Am29Bo7OGKdVHRmcWQnw0OBxlTg9fIEXAvtcwOBfw=="], - "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.137.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2AsevxlvNN4WKxpEn3RtqD5zbqMaXF+T7JXblsP4gVuY+vC9dXS4ED/PwfRCliFqoeisYS3Iro4DHzxr0TEvVA=="], + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.139.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bI3/44urQjW/D495JPXe+c9d+4Q+ONi3DvDNnzwRZYTWHZ3hAlFdmirYK4mzhTfCZiAly02EbMYir7QGU+9O2Q=="], - "@oxc-project/types": ["@oxc-project/types@0.137.0", "", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="], + "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], - "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.21.3", "", { "os": "android", "cpu": "arm" }, "sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg=="], + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.23.0", "", { "os": "android", "cpu": "arm" }, "sha512-8IJyWRLVAyhTfe9/TIEbQqSQnl5rUqYJrUOS6Dkr+Mq9FGHMxDGeiEmwkBqCvDP5KckpPh/GYSgbag66O6JsCw=="], - "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.21.3", "", { "os": "android", "cpu": "arm64" }, "sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw=="], + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.23.0", "", { "os": "android", "cpu": "arm64" }, "sha512-pprVojnNhHxupwTT2gdeUlkxll6XEvWWBk3oVicOSNVWQC99OBnDhMQDoirqnzrE1bScQSMS2JgPpqdlrhz/Fg=="], - "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.21.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg=="], + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.23.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mbIrWIMAJeytyee36OyUP5XH92TP7FaKaQ2m5AjokKy7STgjrhRt7SMXqpqLjhGm6Xn721Xmsg6H3Rtd9YQETw=="], - "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.21.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg=="], + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.23.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-UnIphmZ1LazUCr9DXWaKYWtKDefPMbgLsywaoYxRqVCNHhq4MM6d2q1Nz1i9Vzxt5i+cE2nRUYpAUHr/lijNYA=="], - "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.21.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw=="], + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.23.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-aaZ/cSEYFkSxgS2hOrobT6RQcsWNviOX8dW6CEkVx2/UYkAf9MeHbjl3W0usWV53rVV//ndBdn2nb1y7jsu4lw=="], - "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.21.3", "", { "os": "linux", "cpu": "arm" }, "sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ=="], + "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.23.0", "", { "os": "linux", "cpu": "arm" }, "sha512-IoJLvO5SjLSVMaq83BNTrPCb1FppvoJc1IhZ5CoUVl3PykUBku7D+LK1j0GSurhJcIc6zfjghsvaZNpq5ev6Mg=="], - "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.21.3", "", { "os": "linux", "cpu": "arm" }, "sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ=="], + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.23.0", "", { "os": "linux", "cpu": "arm" }, "sha512-vskFpwg44T/LFsfjSCnVZ5ygcuqzPC1yUzVEiKa8BgHAQz0+QLQQW3EGWLPVi8EXFghzjR4EtgPBtOhCjU4jdw=="], - "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.21.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ=="], + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.23.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-//TcHVhrChyw5RYtgts6WO7KcWq9387c1Z5Zvhqpk/ktAbyaRYgBZrpSY1GDCFq50ASt6B6jhh+JxB1rB45IAg=="], - "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.21.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw=="], + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.23.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZFqlwiTf7CXLLSGyAR9tYiO33LiaeIEXW+xm42d8mnUGpDgPltyrCGYtQezyMMEXvjhOgCz1X+i7sbDTJEx+bg=="], - "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.21.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A=="], + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.23.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-oZ5LeN5+H1R19dRjTAxKrxQguH+AsemHcnthEfFxf4OjmBSty2doHLeSmMunKy3zpTHJQ3lh3Af+dNS+W6dYeA=="], - "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.21.3", "", { "os": "linux", "cpu": "none" }, "sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og=="], + "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.23.0", "", { "os": "linux", "cpu": "none" }, "sha512-O4ciFDyX5ebQd0qkb1bjAIg8IEfiLT03GbSeylwlwlUMK9KwBWaALwrxSbc0Msaz4U6iPj+T9eRXpD5mxBfmvA=="], - "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.21.3", "", { "os": "linux", "cpu": "none" }, "sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw=="], + "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.23.0", "", { "os": "linux", "cpu": "none" }, "sha512-P3o8Y9kISYjcxadmbO+94ThRwLhwGuDAbA7dcdd4+YLpfeF+mmobz8fXf4NmSdfSqjyRSkceJDBRZha9NVYkiQ=="], - "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.21.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q=="], + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.23.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oj03m1E3RmTFczKhcKJDzHaEDKJnPIsDcQFVxBJsSdXGSuIPdt5TvcM332FfMQgzI6yDJqyl4InrnFfXrmUTKQ=="], - "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.21.3", "", { "os": "linux", "cpu": "x64" }, "sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q=="], + "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.23.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BqJxbSC8FdP7mSuSpRePTGHm0hXWV+dfz//f7SjsteZncLaBgWTBmi/OZNv7sX6CyG/Pt/eJkPorP+DkMOhMwQ=="], - "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.21.3", "", { "os": "linux", "cpu": "x64" }, "sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA=="], + "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.23.0", "", { "os": "linux", "cpu": "x64" }, "sha512-utmw+VmUrW4K8LI5/6jhg4aGYKJHOIjQ9syYOOA6pF3w7haKu4r4enTe2U0C04/HbUvkq/Zif43xFsKW1Pnq9w=="], - "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.21.3", "", { "os": "none", "cpu": "arm64" }, "sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg=="], + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.23.0", "", { "os": "none", "cpu": "arm64" }, "sha512-V6lbRrthHa4TbvsLjPtg+EkXT1tRY+s4I8rYLXUfiHlZzGx3sLv1EH9CEOOevjvUYHLsbe/gqCIc73XnQfPb9A=="], - "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.21.3", "", { "dependencies": { "@emnapi/core": "1.11.0", "@emnapi/runtime": "1.11.0", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA=="], + "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.23.0", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-gRoOxQPdnAmIAjxcuQNBxfihvx+wjTaQM/9/eP12xwnGNawOG/+Zz9RHN4WNSxT45b5CrscK4NB8aPh+oZQXAQ=="], - "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.21.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg=="], + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.23.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-CgTGMYsJVe1eUiCdJTpGw21svXw79ITsemN1h0hcNkiswasDbN5MoibSLY+gRMWP5syfEz5iffrjZnwEP8xeUA=="], - "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.21.3", "", { "os": "win32", "cpu": "x64" }, "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q=="], + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.23.0", "", { "os": "win32", "cpu": "x64" }, "sha512-gUGJpr+Rn6zMxm5juApV0K3U845i8t47o8k+rbO0BHbi4PoJIfSPeQmrE2dgohQm2g5k6iviNFyXCGqvmaYUpw=="], "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], @@ -246,71 +246,71 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@swc/core": ["@swc/core@1.15.41", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.26" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.41", "@swc/core-darwin-x64": "1.15.41", "@swc/core-linux-arm-gnueabihf": "1.15.41", "@swc/core-linux-arm64-gnu": "1.15.41", "@swc/core-linux-arm64-musl": "1.15.41", "@swc/core-linux-ppc64-gnu": "1.15.41", "@swc/core-linux-s390x-gnu": "1.15.41", "@swc/core-linux-x64-gnu": "1.15.41", "@swc/core-linux-x64-musl": "1.15.41", "@swc/core-win32-arm64-msvc": "1.15.41", "@swc/core-win32-ia32-msvc": "1.15.41", "@swc/core-win32-x64-msvc": "1.15.41" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ=="], + "@swc/core": ["@swc/core@1.15.43", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.27" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.43", "@swc/core-darwin-x64": "1.15.43", "@swc/core-linux-arm-gnueabihf": "1.15.43", "@swc/core-linux-arm64-gnu": "1.15.43", "@swc/core-linux-arm64-musl": "1.15.43", "@swc/core-linux-ppc64-gnu": "1.15.43", "@swc/core-linux-s390x-gnu": "1.15.43", "@swc/core-linux-x64-gnu": "1.15.43", "@swc/core-linux-x64-musl": "1.15.43", "@swc/core-win32-arm64-msvc": "1.15.43", "@swc/core-win32-ia32-msvc": "1.15.43", "@swc/core-win32-x64-msvc": "1.15.43" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw=="], - "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.41", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw=="], + "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.43", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA=="], - "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.41", "", { "os": "darwin", "cpu": "x64" }, "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA=="], + "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.43", "", { "os": "darwin", "cpu": "x64" }, "sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ=="], - "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.41", "", { "os": "linux", "cpu": "arm" }, "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg=="], + "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.43", "", { "os": "linux", "cpu": "arm" }, "sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA=="], - "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.41", "", { "os": "linux", "cpu": "arm64" }, "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ=="], + "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.43", "", { "os": "linux", "cpu": "arm64" }, "sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw=="], - "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.41", "", { "os": "linux", "cpu": "arm64" }, "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A=="], + "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.43", "", { "os": "linux", "cpu": "arm64" }, "sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ=="], - "@swc/core-linux-ppc64-gnu": ["@swc/core-linux-ppc64-gnu@1.15.41", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ=="], + "@swc/core-linux-ppc64-gnu": ["@swc/core-linux-ppc64-gnu@1.15.43", "", { "os": "linux", "cpu": "ppc64" }, "sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg=="], - "@swc/core-linux-s390x-gnu": ["@swc/core-linux-s390x-gnu@1.15.41", "", { "os": "linux", "cpu": "s390x" }, "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA=="], + "@swc/core-linux-s390x-gnu": ["@swc/core-linux-s390x-gnu@1.15.43", "", { "os": "linux", "cpu": "s390x" }, "sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA=="], - "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.41", "", { "os": "linux", "cpu": "x64" }, "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA=="], + "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.43", "", { "os": "linux", "cpu": "x64" }, "sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg=="], - "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.41", "", { "os": "linux", "cpu": "x64" }, "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ=="], + "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.43", "", { "os": "linux", "cpu": "x64" }, "sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ=="], - "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.41", "", { "os": "win32", "cpu": "arm64" }, "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg=="], + "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.43", "", { "os": "win32", "cpu": "arm64" }, "sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw=="], - "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.41", "", { "os": "win32", "cpu": "ia32" }, "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw=="], + "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.43", "", { "os": "win32", "cpu": "ia32" }, "sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g=="], - "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.41", "", { "os": "win32", "cpu": "x64" }, "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw=="], + "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.43", "", { "os": "win32", "cpu": "x64" }, "sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg=="], "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], "@swc/types": ["@swc/types@0.1.27", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -332,13 +332,13 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@unhead/bundler": ["@unhead/bundler@3.1.7", "", { "dependencies": { "@vitejs/devtools-kit": "^0.3.3", "magic-string": "^0.30.21", "oxc-parser": "^0.137.0", "oxc-walker": "^1.0.0", "ufo": "^1.6.4", "unplugin": "^3.2.0" }, "peerDependencies": { "@unhead/cli": "^3.1.7", "esbuild": ">=0.17.0", "lightningcss": ">=1.20.0", "rolldown": ">=1.0.0-beta.0", "unhead": "^3.1.7", "vite": ">=6.4.2", "webpack": ">=5.0.0" }, "optionalPeers": ["@unhead/cli", "esbuild", "lightningcss", "rolldown", "vite", "webpack"] }, "sha512-yJPDwKZ3NOSmzB1a4ea5MEbeaXBq9EvOcGnav46SsPnslE7jq7QKkx42UQzxJk0q6yFPGdCZbFae2IO1HTtLPA=="], + "@unhead/bundler": ["@unhead/bundler@3.1.8", "", { "dependencies": { "@vitejs/devtools-kit": "^0.3.4", "magic-string": "^0.30.21", "oxc-parser": "^0.139.0", "oxc-walker": "^1.0.0", "ufo": "^1.6.4", "unplugin": "^3.3.0" }, "peerDependencies": { "@unhead/cli": "^3.1.8", "esbuild": ">=0.17.0", "lightningcss": ">=1.20.0", "rolldown": ">=1.0.0-beta.0", "unhead": "^3.1.8", "vite": ">=6.4.2", "webpack": ">=5.0.0" }, "optionalPeers": ["@unhead/cli", "esbuild", "lightningcss", "rolldown", "vite", "webpack"] }, "sha512-mVhM6gmvAgaE9UgENuFSvqpCghAorY8PbQgV+n3y6VyOLso/KbCOd4hm4cmI/Q/IETQZTDSZBIjVVS98mgYwdA=="], - "@unhead/vue": ["@unhead/vue@3.1.7", "", { "dependencies": { "@unhead/bundler": "3.1.7", "hookable": "^6.1.1", "unhead": "3.1.7", "unplugin": "^3.2.0" }, "peerDependencies": { "vite": ">=6.4.2", "vue": ">=3.5.18", "webpack": ">=5.0.0" }, "optionalPeers": ["vite", "webpack"] }, "sha512-UK/hy7RT9qB4TBsUfRwXGUi5p3J6O6MtA/971emkKC3G+YpezaF7cPdxJOG9o+mFtqLIjkKd+QjRg8SiJLmBGg=="], + "@unhead/vue": ["@unhead/vue@3.1.8", "", { "dependencies": { "@unhead/bundler": "3.1.8", "hookable": "^6.1.1", "unhead": "3.1.8", "unplugin": "^3.3.0" }, "peerDependencies": { "vite": ">=6.4.2", "vue": ">=3.5.18", "webpack": ">=5.0.0" }, "optionalPeers": ["vite", "webpack"] }, "sha512-mmcvZ2gchM+sG+M8HjOl+MAHRhyqTEBCrvsR3ymjTEPQDhHGQJqKwW1i5UJzYa2xNubj7AmSMOQF2OvMGIuuQQ=="], "@valibot/to-json-schema": ["@valibot/to-json-schema@1.7.1", "", { "peerDependencies": { "valibot": "^1.4.0" } }, "sha512-3qkmU6KXWh8GIThEAW3kuRHPQBMjWkKy+Ppz3WkUucx53DTpOa6siMn4xDGSOhlVyMrDaJTCTMLYPZVAIk1P0A=="], - "@vitejs/devtools-kit": ["@vitejs/devtools-kit@0.3.3", "", { "dependencies": { "@devframes/hub": "^0.5.4", "birpc": "^4.0.0", "devframe": "^0.5.4", "mlly": "^1.8.2", "nostics": "^0.3.0", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "tinyexec": "^1.2.4" }, "peerDependencies": { "vite": "*" } }, "sha512-noXyK0szYxh8ALsA7PIoFANOWx13K9NoMKqk3J1pCwGMdnhxWgSqtbhki+/REoK4itbxIFUfc0EwqsoZIKiqyg=="], + "@vitejs/devtools-kit": ["@vitejs/devtools-kit@0.3.4", "", { "dependencies": { "@devframes/hub": "^0.5.4", "birpc": "^4.0.0", "devframe": "^0.5.4", "mlly": "^1.8.2", "nostics": "^1.1.4", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "tinyexec": "^1.2.4" }, "peerDependencies": { "vite": "*" } }, "sha512-QHvb3wF0KZxvbfEplzzdGenB/dWoPBQlUnw3VVBm5qUYTdoud8rOoem9QI6h/+7a+iwy88LmLigxbSXbbv2Uyg=="], "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.7", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg=="], @@ -364,19 +364,19 @@ "@vue-macros/common": ["@vue-macros/common@3.1.2", "", { "dependencies": { "@vue/compiler-sfc": "^3.5.22", "ast-kit": "^2.1.2", "local-pkg": "^1.1.2", "magic-string-ast": "^1.0.2", "unplugin-utils": "^0.3.0" }, "peerDependencies": { "vue": "^2.7.0 || ^3.2.25" }, "optionalPeers": ["vue"] }, "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng=="], - "@vue/compiler-core": ["@vue/compiler-core@3.5.38", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.38", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ=="], + "@vue/compiler-core": ["@vue/compiler-core@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.39", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw=="], - "@vue/compiler-dom": ["@vue/compiler-dom@3.5.38", "", { "dependencies": { "@vue/compiler-core": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw=="], + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.39", "", { "dependencies": { "@vue/compiler-core": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg=="], "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.39", "@vue/compiler-dom": "3.5.39", "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg=="], - "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.38", "", { "dependencies": { "@vue/compiler-dom": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA=="], + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw=="], - "@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="], + "@vue/devtools-api": ["@vue/devtools-api@7.7.10", "", { "dependencies": { "@vue/devtools-kit": "^7.7.10" } }, "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA=="], - "@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="], + "@vue/devtools-kit": ["@vue/devtools-kit@7.7.10", "", { "dependencies": { "@vue/devtools-shared": "^7.7.10", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw=="], - "@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="], + "@vue/devtools-shared": ["@vue/devtools-shared@7.7.10", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ=="], "@vue/language-core": ["@vue/language-core@3.3.7", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-LzmkKinXAMMoh8Jfi/jMUSDUjuPdv8mynH5WJGKfXyZtDw3hQ6GBaoI6Bcnl/Xqlu32q/0Z6i/trp4VXykzyLw=="], @@ -386,7 +386,7 @@ "@vue/runtime-dom": ["@vue/runtime-dom@3.5.39", "", { "dependencies": { "@vue/reactivity": "3.5.39", "@vue/runtime-core": "3.5.39", "@vue/shared": "3.5.39", "csstype": "^3.2.3" } }, "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww=="], - "@vue/server-renderer": ["@vue/server-renderer@3.5.38", "", { "dependencies": { "@vue/compiler-ssr": "3.5.38", "@vue/shared": "3.5.38" }, "peerDependencies": { "vue": "3.5.38" } }, "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw=="], + "@vue/server-renderer": ["@vue/server-renderer@3.5.39", "", { "dependencies": { "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "vue": "3.5.39" } }, "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw=="], "@vue/shared": ["@vue/shared@3.5.39", "", {}, "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="], @@ -428,7 +428,7 @@ "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], - "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], @@ -496,15 +496,15 @@ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + "es-module-lexer": ["es-module-lexer@2.3.0", "", {}, "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw=="], - "es-toolkit": ["es-toolkit@1.47.1", "", {}, "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q=="], + "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.5.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ=="], + "eslint": ["eslint@10.7.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ=="], "eslint-import-resolver-oxc": ["eslint-import-resolver-oxc@0.15.0", "", { "dependencies": { "stable-hash-x": "^0.1.1" }, "peerDependencies": { "@rspack/cli": "*", "@rspack/core": "*", "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*", "oxc-resolver": "*", "vite": "*", "webpack": "*", "webpack-cli": "*", "webpack-merge": "*" }, "optionalPeers": ["@rspack/cli", "@rspack/core", "eslint-plugin-import", "eslint-plugin-import-x", "vite", "webpack", "webpack-cli", "webpack-merge"] }, "sha512-qod5T3t3PbJdMZX3GYb8rdhD/UBK9rJCSE2b2+ttXtVdBm39CHgyCht1pMXj/1Z0ko1wNme+az4Z5chowgXD5Q=="], @@ -524,9 +524,9 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], - "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + "exsolve": ["exsolve@1.1.0", "", {}, "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -568,7 +568,7 @@ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "immutable": ["immutable@5.1.6", "", {}, "sha512-q1swsS8K7L8usSHuOqF2TAoCCkonYz0SG38wLAggaa4Wml70zixIvt2ql4coQ2C2B3hTjltJry4r6bULwgAXLQ=="], + "immutable": ["immutable@5.1.9", "", {}, "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -594,7 +594,7 @@ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - "js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js", "js-beautify": "js/bin/js-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="], + "js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "js-beautify": "js/bin/js-beautify.js", "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="], "js-cookie": ["js-cookie@3.0.8", "", {}, "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw=="], @@ -664,7 +664,7 @@ "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], - "nanoid": ["nanoid@3.3.13", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q=="], + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -674,7 +674,7 @@ "nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], - "nostics": ["nostics@0.3.0", "", { "dependencies": { "magic-string": "^0.30.21", "oxc-parser": "^0.132.0", "unplugin": "^3.0.0" } }, "sha512-tP0hvxK4n2hZO9kK5Az7dLXIFukANDpcdtMae3tvtyRQun48gZIBVyXCJc8zk+qsdOpY7ngashH0ivQGOGzmeg=="], + "nostics": ["nostics@1.1.4", "", {}, "sha512-U4FApICSLCQ0dYiN59pxUCEZC053palQXi57yLaNdMaL6TBY4C4q3qZrJKUGcor2dGv8EhEwt8y5KvU2bo2kFg=="], "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], @@ -686,9 +686,9 @@ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - "oxc-parser": ["oxc-parser@0.137.0", "", { "dependencies": { "@oxc-project/types": "^0.137.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.137.0", "@oxc-parser/binding-android-arm64": "0.137.0", "@oxc-parser/binding-darwin-arm64": "0.137.0", "@oxc-parser/binding-darwin-x64": "0.137.0", "@oxc-parser/binding-freebsd-x64": "0.137.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.137.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.137.0", "@oxc-parser/binding-linux-arm64-gnu": "0.137.0", "@oxc-parser/binding-linux-arm64-musl": "0.137.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.137.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.137.0", "@oxc-parser/binding-linux-riscv64-musl": "0.137.0", "@oxc-parser/binding-linux-s390x-gnu": "0.137.0", "@oxc-parser/binding-linux-x64-gnu": "0.137.0", "@oxc-parser/binding-linux-x64-musl": "0.137.0", "@oxc-parser/binding-openharmony-arm64": "0.137.0", "@oxc-parser/binding-wasm32-wasi": "0.137.0", "@oxc-parser/binding-win32-arm64-msvc": "0.137.0", "@oxc-parser/binding-win32-ia32-msvc": "0.137.0", "@oxc-parser/binding-win32-x64-msvc": "0.137.0" } }, "sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg=="], + "oxc-parser": ["oxc-parser@0.139.0", "", { "dependencies": { "@oxc-project/types": "^0.139.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.139.0", "@oxc-parser/binding-android-arm64": "0.139.0", "@oxc-parser/binding-darwin-arm64": "0.139.0", "@oxc-parser/binding-darwin-x64": "0.139.0", "@oxc-parser/binding-freebsd-x64": "0.139.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.139.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.139.0", "@oxc-parser/binding-linux-arm64-gnu": "0.139.0", "@oxc-parser/binding-linux-arm64-musl": "0.139.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.139.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.139.0", "@oxc-parser/binding-linux-riscv64-musl": "0.139.0", "@oxc-parser/binding-linux-s390x-gnu": "0.139.0", "@oxc-parser/binding-linux-x64-gnu": "0.139.0", "@oxc-parser/binding-linux-x64-musl": "0.139.0", "@oxc-parser/binding-openharmony-arm64": "0.139.0", "@oxc-parser/binding-wasm32-wasi": "0.139.0", "@oxc-parser/binding-win32-arm64-msvc": "0.139.0", "@oxc-parser/binding-win32-ia32-msvc": "0.139.0", "@oxc-parser/binding-win32-x64-msvc": "0.139.0" } }, "sha512-cf1TKZN+zc0lwqigeyXKzKVk5+vNRe99Or2+wVJsXLdlhJgC+gsIniYDfj/ZEBzCJ8Xm21ZG6YtMbR262CcS2w=="], - "oxc-resolver": ["oxc-resolver@11.21.3", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.21.3", "@oxc-resolver/binding-android-arm64": "11.21.3", "@oxc-resolver/binding-darwin-arm64": "11.21.3", "@oxc-resolver/binding-darwin-x64": "11.21.3", "@oxc-resolver/binding-freebsd-x64": "11.21.3", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3", "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3", "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3", "@oxc-resolver/binding-linux-arm64-musl": "11.21.3", "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3", "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-musl": "11.21.3", "@oxc-resolver/binding-openharmony-arm64": "11.21.3", "@oxc-resolver/binding-wasm32-wasi": "11.21.3", "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3", "@oxc-resolver/binding-win32-x64-msvc": "11.21.3" } }, "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA=="], + "oxc-resolver": ["oxc-resolver@11.23.0", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.23.0", "@oxc-resolver/binding-android-arm64": "11.23.0", "@oxc-resolver/binding-darwin-arm64": "11.23.0", "@oxc-resolver/binding-darwin-x64": "11.23.0", "@oxc-resolver/binding-freebsd-x64": "11.23.0", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.23.0", "@oxc-resolver/binding-linux-arm-musleabihf": "11.23.0", "@oxc-resolver/binding-linux-arm64-gnu": "11.23.0", "@oxc-resolver/binding-linux-arm64-musl": "11.23.0", "@oxc-resolver/binding-linux-ppc64-gnu": "11.23.0", "@oxc-resolver/binding-linux-riscv64-gnu": "11.23.0", "@oxc-resolver/binding-linux-riscv64-musl": "11.23.0", "@oxc-resolver/binding-linux-s390x-gnu": "11.23.0", "@oxc-resolver/binding-linux-x64-gnu": "11.23.0", "@oxc-resolver/binding-linux-x64-musl": "11.23.0", "@oxc-resolver/binding-openharmony-arm64": "11.23.0", "@oxc-resolver/binding-wasm32-wasi": "11.23.0", "@oxc-resolver/binding-win32-arm64-msvc": "11.23.0", "@oxc-resolver/binding-win32-x64-msvc": "11.23.0" } }, "sha512-f0+l598CJMOLnYPXsXxttJALH0ljtivdRMKtvHhxRuWa5FYmw5+qODARl8oYjMC/brpzKcrpdORsOBrTqhBZ9A=="], "oxc-walker": ["oxc-walker@1.0.0", "", { "dependencies": { "magic-regexp": "^0.11.0" }, "peerDependencies": { "oxc-parser": ">=0.98.0", "rolldown": ">=1.0.0" }, "optionalPeers": ["oxc-parser", "rolldown"] }, "sha512-eMsHflAGfOskpWxtp9xP/f5b96XLEU8ifTd2gOOCkdux9HMxKGy5S1ru0Gh1B3aPu+YbfmWUUVkcb7MrZz3XyQ=="], @@ -714,7 +714,7 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "pinia": ["pinia@3.0.4", "", { "dependencies": { "@vue/devtools-api": "^7.7.7" }, "peerDependencies": { "typescript": ">=4.5.0", "vue": "^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw=="], @@ -744,7 +744,7 @@ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], + "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], "rollup-plugin-visualizer": ["rollup-plugin-visualizer@7.0.1", "", { "dependencies": { "open": "^11.0.0", "picomatch": "^4.0.2", "source-map": "^0.7.4", "yargs": "^18.0.0" }, "peerDependencies": { "rolldown": "1.x || ^1.0.0-beta || ^1.0.0-rc", "rollup": "2.x || 3.x || 4.x" }, "optionalPeers": ["rolldown", "rollup"], "bin": { "rollup-plugin-visualizer": "dist/bin/cli.js" } }, "sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg=="], @@ -758,7 +758,7 @@ "scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="], - "semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -774,13 +774,13 @@ "speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="], - "srvx": ["srvx@0.11.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw=="], + "srvx": ["srvx@0.11.21", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-GWTHjKMeekX8CwJf4VU9Oo6mJpSGaflGMddbCvR+Cmmh9sslRMiGbAoqqZacE0r1ncARh6buCEETr2W52F8b1w=="], "stable-hash-x": ["stable-hash-x@0.1.1", "", {}, "sha512-l0x1D6vhnsNUGPFVDx45eif0y6eedVC8nm5uACTrVFJFtl2mLRW17aWtVyxFCpn5t94VUPkjU8vSLwIuwwqtJQ=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -860,19 +860,19 @@ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], - "unhead": ["unhead@3.1.7", "", { "dependencies": { "hookable": "^6.1.1", "unplugin": "^3.2.0" }, "peerDependencies": { "vite": ">=6.4.2" }, "optionalPeers": ["vite"] }, "sha512-Db1M8yQmxdzhQFMmlZ6cr/ZlpGPRabTMDPBE4GGBv69TYUNP6jV8z54H8PTU08gMsWXVtqPBs9VwCldjrqknGw=="], + "unhead": ["unhead@3.1.8", "", { "dependencies": { "hookable": "^6.1.1", "unplugin": "^3.3.0" }, "peerDependencies": { "vite": ">=6.4.2" }, "optionalPeers": ["vite"] }, "sha512-8YcZ88tTvVV+CxYsqKg9O2E0h/tR7PsUHQ22tc4xodVOoUcz/Pl5OHC3Ab3IKhLIy/8OcyAWoS4LCPN7kGvwSg=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], "unplugin": ["unplugin@3.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.4", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg=="], - "unplugin-utils": ["unplugin-utils@0.3.1", "", { "dependencies": { "pathe": "^2.0.3", "picomatch": "^4.0.3" } }, "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog=="], + "unplugin-utils": ["unplugin-utils@0.3.2", "", { "dependencies": { "pathe": "^2.0.3", "picomatch": "^4.0.4" } }, "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA=="], "upath": ["upath@2.0.1", "", {}, "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "valibot": ["valibot@1.4.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g=="], + "valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="], "vite": ["vite@8.1.4", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.16", "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ=="], @@ -892,7 +892,7 @@ "vue-component-meta": ["vue-component-meta@3.3.7", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.7", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-rqMY/EGOHGLcUbCb1VRZNBlDpUS12O0kMJL133Z3TrWpKmwtdGNLALTwEX1jBskoV+ibQp/aWEW+87a79FexCA=="], - "vue-component-type-helpers": ["vue-component-type-helpers@3.3.5", "", {}, "sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA=="], + "vue-component-type-helpers": ["vue-component-type-helpers@3.3.7", "", {}, "sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg=="], "vue-eslint-parser": ["vue-eslint-parser@10.4.1", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", "eslint-visitor-keys": "^4.2.0 || ^5.0.0", "espree": "^10.3.0 || ^11.0.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA=="], @@ -932,11 +932,11 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "@babel/generator/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], + "@babel/generator/@babel/parser": ["@babel/parser@8.0.4", "", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="], "@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], "@devframes/hub/nostics": ["nostics@0.2.0", "", { "dependencies": { "magic-string": "^0.30.21", "oxc-parser": "^0.132.0", "unplugin": "^3.0.0" } }, "sha512-/WQpI46UMbqvy1okYb+V+9wW3J8/m6GJ33wm691n/tyi6YtJiZ6ssJjENAU7y4evfYrrgYN9HllKDzPvffil1w=="], @@ -946,52 +946,18 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], - - "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], - - "@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - - "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - - "@types/ws/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - - "@vue-macros/common/@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.38", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.38", "@vue/compiler-dom": "3.5.38", "@vue/compiler-ssr": "3.5.38", "@vue/shared": "3.5.38", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg=="], - - "@vue/compiler-core/@vue/shared": ["@vue/shared@3.5.38", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="], - "@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@vue/compiler-dom/@vue/shared": ["@vue/shared@3.5.38", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="], - - "@vue/compiler-sfc/@vue/compiler-core": ["@vue/compiler-core@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.39", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw=="], - - "@vue/compiler-sfc/@vue/compiler-dom": ["@vue/compiler-dom@3.5.39", "", { "dependencies": { "@vue/compiler-core": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg=="], - - "@vue/compiler-sfc/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw=="], - "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@vue/compiler-sfc/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], - - "@vue/compiler-ssr/@vue/shared": ["@vue/shared@3.5.38", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="], - "@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], "@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], "@vue/devtools-kit/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], - "@vue/language-core/@vue/compiler-dom": ["@vue/compiler-dom@3.5.39", "", { "dependencies": { "@vue/compiler-core": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg=="], - - "@vue/language-core/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], - - "@vue/server-renderer/@vue/shared": ["@vue/shared@3.5.38", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="], - "ast-walker-scope/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "buffer-image-size/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "cross-spawn/path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "devframe/nostics": ["nostics@0.2.0", "", { "dependencies": { "magic-string": "^0.30.21", "oxc-parser": "^0.132.0", "unplugin": "^3.0.0" } }, "sha512-/WQpI46UMbqvy1okYb+V+9wW3J8/m6GJ33wm691n/tyi6YtJiZ6ssJjENAU7y4evfYrrgYN9HllKDzPvffil1w=="], @@ -1004,37 +970,15 @@ "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "happy-dom/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - - "magic-regexp/unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], - "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], - "nostics/oxc-parser": ["oxc-parser@0.132.0", "", { "dependencies": { "@oxc-project/types": "^0.132.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.132.0", "@oxc-parser/binding-android-arm64": "0.132.0", "@oxc-parser/binding-darwin-arm64": "0.132.0", "@oxc-parser/binding-darwin-x64": "0.132.0", "@oxc-parser/binding-freebsd-x64": "0.132.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.132.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.132.0", "@oxc-parser/binding-linux-arm64-gnu": "0.132.0", "@oxc-parser/binding-linux-arm64-musl": "0.132.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.132.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.132.0", "@oxc-parser/binding-linux-riscv64-musl": "0.132.0", "@oxc-parser/binding-linux-s390x-gnu": "0.132.0", "@oxc-parser/binding-linux-x64-gnu": "0.132.0", "@oxc-parser/binding-linux-x64-musl": "0.132.0", "@oxc-parser/binding-openharmony-arm64": "0.132.0", "@oxc-parser/binding-wasm32-wasi": "0.132.0", "@oxc-parser/binding-win32-arm64-msvc": "0.132.0", "@oxc-parser/binding-win32-ia32-msvc": "0.132.0", "@oxc-parser/binding-win32-x64-msvc": "0.132.0" } }, "sha512-+0LAPHaqtfQlvWdpaAa09SmOaZZgP8C552xosEkGJ4+ruEwP1Vgx+sqBgcBCNfR6KDCmagGOZTde8wmAvcI/Hg=="], - - "nostics/unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], - - "rolldown/@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], - "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "vite/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], - - "vite/rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], - - "vitest/vite": ["vite@8.1.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA=="], - - "vue/@vue/compiler-dom": ["@vue/compiler-dom@3.5.39", "", { "dependencies": { "@vue/compiler-core": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg=="], - - "vue/@vue/server-renderer": ["@vue/server-renderer@3.5.39", "", { "dependencies": { "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "vue": "3.5.39" } }, "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw=="], - - "vue-router/@vue/devtools-api": ["@vue/devtools-api@8.1.3", "", { "dependencies": { "@vue/devtools-kit": "^8.1.3" } }, "sha512-73NMCvxXh8Hyozc/jiwqTFWVcCMyi11U1zmrq4DoukQJnuo8JHt6FsNu4HdeUDa8SpIp5vb7Q22GWgIq0efsXg=="], - - "vue-router/unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], + "vue-router/@vue/devtools-api": ["@vue/devtools-api@8.1.5", "", { "dependencies": { "@vue/devtools-kit": "^8.1.5" } }, "sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ=="], "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -1046,121 +990,21 @@ "@devframes/hub/nostics/oxc-parser": ["oxc-parser@0.132.0", "", { "dependencies": { "@oxc-project/types": "^0.132.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.132.0", "@oxc-parser/binding-android-arm64": "0.132.0", "@oxc-parser/binding-darwin-arm64": "0.132.0", "@oxc-parser/binding-darwin-x64": "0.132.0", "@oxc-parser/binding-freebsd-x64": "0.132.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.132.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.132.0", "@oxc-parser/binding-linux-arm64-gnu": "0.132.0", "@oxc-parser/binding-linux-arm64-musl": "0.132.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.132.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.132.0", "@oxc-parser/binding-linux-riscv64-musl": "0.132.0", "@oxc-parser/binding-linux-s390x-gnu": "0.132.0", "@oxc-parser/binding-linux-x64-gnu": "0.132.0", "@oxc-parser/binding-linux-x64-musl": "0.132.0", "@oxc-parser/binding-openharmony-arm64": "0.132.0", "@oxc-parser/binding-wasm32-wasi": "0.132.0", "@oxc-parser/binding-win32-arm64-msvc": "0.132.0", "@oxc-parser/binding-win32-ia32-msvc": "0.132.0", "@oxc-parser/binding-win32-x64-msvc": "0.132.0" } }, "sha512-+0LAPHaqtfQlvWdpaAa09SmOaZZgP8C552xosEkGJ4+ruEwP1Vgx+sqBgcBCNfR6KDCmagGOZTde8wmAvcI/Hg=="], - "@devframes/hub/nostics/unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], - "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - "@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - - "@types/ws/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - - "@vue-macros/common/@vue/compiler-sfc/@vue/shared": ["@vue/shared@3.5.38", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="], - - "@vue-macros/common/@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - - "@vue-macros/common/@vue/compiler-sfc/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], - - "@vue/language-core/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.39", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw=="], - "ast-walker-scope/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - "buffer-image-size/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "devframe/nostics/oxc-parser": ["oxc-parser@0.132.0", "", { "dependencies": { "@oxc-project/types": "^0.132.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.132.0", "@oxc-parser/binding-android-arm64": "0.132.0", "@oxc-parser/binding-darwin-arm64": "0.132.0", "@oxc-parser/binding-darwin-x64": "0.132.0", "@oxc-parser/binding-freebsd-x64": "0.132.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.132.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.132.0", "@oxc-parser/binding-linux-arm64-gnu": "0.132.0", "@oxc-parser/binding-linux-arm64-musl": "0.132.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.132.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.132.0", "@oxc-parser/binding-linux-riscv64-musl": "0.132.0", "@oxc-parser/binding-linux-s390x-gnu": "0.132.0", "@oxc-parser/binding-linux-x64-gnu": "0.132.0", "@oxc-parser/binding-linux-x64-musl": "0.132.0", "@oxc-parser/binding-openharmony-arm64": "0.132.0", "@oxc-parser/binding-wasm32-wasi": "0.132.0", "@oxc-parser/binding-win32-arm64-msvc": "0.132.0", "@oxc-parser/binding-win32-ia32-msvc": "0.132.0", "@oxc-parser/binding-win32-x64-msvc": "0.132.0" } }, "sha512-+0LAPHaqtfQlvWdpaAa09SmOaZZgP8C552xosEkGJ4+ruEwP1Vgx+sqBgcBCNfR6KDCmagGOZTde8wmAvcI/Hg=="], - "devframe/nostics/unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], + "editorconfig/minimatch/brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="], - "editorconfig/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], - - "glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], - - "happy-dom/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="], "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], - "nostics/oxc-parser/@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], - - "nostics/oxc-parser/@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.132.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SThDrSeamB/kG2+NxcJ5/wSLcV6dUqDknrPLqFYQ0ST/55mtBP4M7Q/f3QbubH6aAd11wpzZn/nwbVRSdobOpg=="], - - "nostics/oxc-parser/@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.132.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Lc0f/TYoKBghE5/2Gsv7bLXk+TJZunx2Tf61X8hG4ARXdc8UYI26dCGccFSd1AyFbK3jfaNXtMnupggDbjPXdQ=="], - - "nostics/oxc-parser/@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.132.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-RG2eJIpf7C21z9HSSXFw1bTArdpKe7Y4fwcJTwRq1yCSe1vSavaN9GA1sm9KqzemTLAGVktQ+7qBTGp0vQeUZg=="], - - "nostics/oxc-parser/@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.132.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-wQIPntPLtJ8NcBpvKPbEv3NqzV6k8eP8tP/jE9Rg8HTg/j7urZGFSsTCPCW5k77Qfw2DM4vRvc9p3I4yq/Shvw=="], - - "nostics/oxc-parser/@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.132.0", "", { "os": "linux", "cpu": "arm" }, "sha512-PixKEpeSe3yxQWqNyOCBALRYc72+Tj7ILDofUl3iXo25cVOzLA6jHUhmOINRtWIPh7dbUie3QNeabwaQpZTw6w=="], - - "nostics/oxc-parser/@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.132.0", "", { "os": "linux", "cpu": "arm" }, "sha512-sCR+DzGHlyHKnbA2z9zWjTUhIo8Sy0enJl4RDsBwPmkxYynPatpwOAWe8W5127SlW0boqUWHGtr1NWn5UwIhXQ=="], - - "nostics/oxc-parser/@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.132.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-sQBix5P2cW+IpzTcCwYxnh9yALrKSIkKJThspBvMGcygSMnbzkSvhN7SfuX1hvBk8y1XEChsdkU3ET0V5DmzUw=="], - - "nostics/oxc-parser/@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.132.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WozHg3Kc//8Sk756HXXgMbEAvqtG+Lzb9JOojwQzIGDtN78Az2dLttkb71akWYUF/8IgYfDSlfKh4Uot8is5Vw=="], - - "nostics/oxc-parser/@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.132.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CmX/ulNBOEwWTyVRmcpYKAcAizW6+OjtLJgo7fXoL9OqQvjF4VER8tPomv44vwzfSCy1BHbsB0ZlZYzYJNj4cA=="], - - "nostics/oxc-parser/@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.132.0", "", { "os": "linux", "cpu": "none" }, "sha512-j9oQS+hM90SdhviNGWbPgT4+Rlq+ac++q/zjgwPD1mVHgxHzATvoRGtDx0sXGmFOQ9J9YkwAhYGb5MAHL6TAsA=="], - - "nostics/oxc-parser/@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.132.0", "", { "os": "linux", "cpu": "none" }, "sha512-bLz+Xi+Agnfmd7kWPEsSVwCn2k4EyIalZkNBcQ0OGIv9rqn8VgCPLNd03tM9mKX/5TdlvDXalz0q71BIrOPNqg=="], - - "nostics/oxc-parser/@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.132.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-U6t2qbJU0ypTfyj9QV3W1Y6mITDTL8ai/OR6NUn85vyHthOvobKWgXzU4tu0EskSzlpuVFz1g0jFGulDIUKHxQ=="], - - "nostics/oxc-parser/@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.132.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WcEaSNHFk8yz5YFlQQAlhq6jOFmZBB/RKE7uzhyCIf+pF1Lmv9gUH4221mle2Gd9iHyWT3ySNph8yZgb1xYdWg=="], - - "nostics/oxc-parser/@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.132.0", "", { "os": "linux", "cpu": "x64" }, "sha512-iQrV4iJzQgRwK3BWRmQl1C3C6g3wYpXN2WLdQdyR+efoUnncdShZAVp9OgcojtlD3MDRbuOMGG3SjxF4fL4nlQ=="], - - "nostics/oxc-parser/@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.132.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FWzmUGrZ6GUby4U7WIwcCtab6tdmlTO3xTRRKyb5kjIJVEiaUAT8animUG/nK8ZCA8gkRkPOTId4rl6uTqUmJQ=="], - - "nostics/oxc-parser/@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.132.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-TlbMppxJI5CjWDes0QaP6G3aneVg1yikBu5QYI+DUShF9WDL66ccgKFNNGmi/Wybtszw6hxwAvv76T4DaPKnHw=="], - - "nostics/oxc-parser/@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.132.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-RH/NbFjGKqdUAUi7Oh3LQPxUk2hsWFEEQ38HSnbRQT8QjBZFKqL1fMbmsB3N4jy/KPh9iX94+9dmkEMBBbambw=="], - - "nostics/oxc-parser/@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.132.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-JUr4jQY9jxoIB/YTLXr6XofSi5xikj6p5/Ns1h0VOBDT0j1jKU+kMsv2xxv51RwnETcXpA1Yw/9oUAfcqfaqEA=="], - - "nostics/oxc-parser/@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.132.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2dapgHpA5X8DSXF4AU36hJWYf6zP0tKjMXFRAZFBD62pkevW/uhFDXoFH9Y/3Fd2EtDrw5ByNnR1wVE9X9y0SQ=="], - - "nostics/oxc-parser/@oxc-project/types": ["@oxc-project/types@0.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], - "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], - - "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], - - "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], - - "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], - - "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], - - "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], - - "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], - - "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], - - "vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], - - "vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], - - "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], - - "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], - - "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], - - "vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], - - "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], - - "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], - - "vitest/vite/rolldown": ["rolldown@1.1.3", "", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-x64-msvc": "1.1.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g=="], - - "vue-router/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.1.3", "", { "dependencies": { "@vue/devtools-shared": "^8.1.3", "birpc": "^2.6.1", "hookable": "^5.5.3", "perfect-debounce": "^2.0.0" } }, "sha512-cRn7GXiCQkMYU2Z3h3pM4YO/ndbx9FY1yLDAqIqPLcmIq4H6zAOJHein6tvZU3AfPwgrodqLiPBEF+YQaS8AxA=="], - - "vue/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.39", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw=="], - - "vue/@vue/server-renderer/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw=="], + "vue-router/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.1.5", "", { "dependencies": { "@vue/devtools-shared": "^8.1.5", "birpc": "^2.6.1", "hookable": "^5.5.3", "perfect-debounce": "^2.0.0" } }, "sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA=="], "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1208,8 +1052,6 @@ "@devframes/hub/nostics/oxc-parser/@oxc-project/types": ["@oxc-project/types@0.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], - "@vue/language-core/@vue/compiler-dom/@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "devframe/nostics/oxc-parser/@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], "devframe/nostics/oxc-parser/@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.132.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SThDrSeamB/kG2+NxcJ5/wSLcV6dUqDknrPLqFYQ0ST/55mtBP4M7Q/f3QbubH6aAd11wpzZn/nwbVRSdobOpg=="], @@ -1256,50 +1098,12 @@ "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "nostics/oxc-parser/@oxc-parser/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - - "nostics/oxc-parser/@oxc-parser/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - - "vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - - "vitest/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.3", "", { "os": "android", "cpu": "arm64" }, "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g=="], - - "vitest/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw=="], - - "vitest/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw=="], - - "vitest/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.3", "", { "os": "linux", "cpu": "arm" }, "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g=="], - - "vitest/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.3", "", { "os": "none", "cpu": "arm64" }, "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ=="], - - "vitest/vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.3", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg=="], - - "vitest/vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g=="], - - "vitest/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA=="], - - "vue-router/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@8.1.3", "", {}, "sha512-CM3uIPL+v+lrJUk33+pxspYo0MhuMWlCvf7zC9fybifvCPyM2jUbYRPwoYEJgYbwRqPikm5HozbUhp60MF2QuA=="], + "vue-router/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@8.1.5", "", {}, "sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ=="], "vue-router/@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], "vue-router/@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], - "vue/@vue/compiler-dom/@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@devframes/hub/nostics/oxc-parser/@oxc-parser/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@devframes/hub/nostics/oxc-parser/@oxc-parser/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], @@ -1308,16 +1112,8 @@ "devframe/nostics/oxc-parser/@oxc-parser/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "nostics/oxc-parser/@oxc-parser/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - - "vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - - "vitest/vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@devframes/hub/nostics/oxc-parser/@oxc-parser/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "devframe/nostics/oxc-parser/@oxc-parser/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - - "vitest/vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], } } diff --git a/frontend/package.json b/frontend/package.json index 9613f9113..e6cade836 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "test:ci": "vitest run --reporter verbose --update" }, "dependencies": { - "@unhead/vue": "^3.1.7", + "@unhead/vue": "^3.1.8", "@vueuse/core": "^14.3.0", "dequal": "^2.0.3", "eslint-import-resolver-oxc": "^0.15.0", @@ -34,7 +34,7 @@ "@mdi/js": "^7.4.47", "@pinia/testing": "^1.0.3", "@types/node": "^26.1.1", - "@unhead/bundler": "^3.1.7", + "@unhead/bundler": "^3.1.8", "@vitejs/plugin-vue": "^6.0.7", "@vue/test-utils": "^2.4.11", "@vue/typescript-plugin": "^3.3.7", diff --git a/frontend/src/api/v4/admin.js b/frontend/src/api/v4/admin.js index f8c8bb842..b23fa5a5d 100644 --- a/frontend/src/api/v4/admin.js +++ b/frontend/src/api/v4/admin.js @@ -212,6 +212,15 @@ export const updateEmailSettings = (data) => export const sendEmailTest = (data) => HTTP.post("/admin/email-settings/test", data); +export const getOidcSettings = () => + HTTP.get("/admin/oidc-settings", { params: { ts: Date.now() } }); + +export const updateOidcSettings = (data) => + HTTP.put("/admin/oidc-settings", data); + +export const testOidcConnection = (data) => + HTTP.post("/admin/oidc-settings/test", data); + export const getThrottleSettings = () => HTTP.get("/admin/throttle-settings", { params: { ts: Date.now() } }); diff --git a/frontend/src/components/admin/tabs/admin-section.scss b/frontend/src/components/admin/tabs/admin-section.scss index c26b48212..3d71893ea 100644 --- a/frontend/src/components/admin/tabs/admin-section.scss +++ b/frontend/src/components/admin/tabs/admin-section.scss @@ -34,6 +34,31 @@ font-size: d.$text-title; } +// Sub-section title (AdminSection's `sub` variant): a small overline so +// it reads unmistakably subordinate to the h3 section that contains it. +.adminGroupHeader > h4 { + margin: 0; + font-size: d.$text-meta; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgb(var(--v-theme-textSecondary)); +} + +// Nested sub-sections indent under a left rule so containment is visible +// even when scrolled past the parent header. +.adminSubGroup { + margin-top: d.$space-4; + padding-left: d.$space-4; + border-left: 2px solid rgba(var(--v-border-color), var(--v-border-opacity)); +} + +// Tighter rhythm between sibling sub-sections than between top-level +// sections (overrides the $space-6 .adminGroup sibling rule above). +.adminSubGroup + .adminSubGroup { + margin-top: d.$space-4; +} + .adminHint { margin-top: d.$space-1; font-size: d.$text-small; diff --git a/frontend/src/components/admin/tabs/admin-section.vue b/frontend/src/components/admin/tabs/admin-section.vue index 83d6f658b..032eaad1d 100644 --- a/frontend/src/components/admin/tabs/admin-section.vue +++ b/frontend/src/components/admin/tabs/admin-section.vue @@ -2,11 +2,15 @@ A titled admin block: header (h3 + optional right-aligned actions), an optional one-line hint, then its body. The unified replacement for the hand-written .adminGroup / .adminGroupHeader /

markup. See DESIGN.md §4. + + The `sub` variant nests inside another AdminSection: a smaller overline + h4 title and an indented left rule make the subordination visible (see + the Auth tab's OIDC block). --> + diff --git a/frontend/src/components/auth/sso-error.vue b/frontend/src/components/auth/sso-error.vue new file mode 100644 index 000000000..2ef5af4b7 --- /dev/null +++ b/frontend/src/components/auth/sso-error.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/frontend/src/components/auth/sso-login-button.vue b/frontend/src/components/auth/sso-login-button.vue new file mode 100644 index 000000000..2426779d1 --- /dev/null +++ b/frontend/src/components/auth/sso-login-button.vue @@ -0,0 +1,34 @@ + + + diff --git a/frontend/src/components/unauthorized.vue b/frontend/src/components/unauthorized.vue index eb37da187..2ededa773 100644 --- a/frontend/src/components/unauthorized.vue +++ b/frontend/src/components/unauthorized.vue @@ -10,6 +10,7 @@ > @@ -23,6 +24,7 @@ import { mapState } from "pinia"; import AdminBrowserLink from "@/components/admin/browser-link.vue"; import AuthMenu from "@/components/auth/auth-menu.vue"; +import SsoLoginButton from "@/components/auth/sso-login-button.vue"; import AppBanner from "@/components/banner.vue"; import EmptyState from "@/components/empty.vue"; import PlaceholderLoading from "@/components/placeholder-loading.vue"; @@ -34,6 +36,7 @@ export default { AppBanner, AdminBrowserLink, AuthMenu, + SsoLoginButton, PlaceholderLoading, EmptyState, }, @@ -83,6 +86,10 @@ export default { color: rgb(var(--v-theme-primary)); } +.ssoButton { + margin-bottom: 0.75em; +} + .login :deep(.v-list-item__prepend) { margin-right: 0.25em; } diff --git a/frontend/src/plugins/router.js b/frontend/src/plugins/router.js index 43dcc7c2f..a74e01821 100644 --- a/frontend/src/plugins/router.js +++ b/frontend/src/plugins/router.js @@ -19,8 +19,10 @@ const AdminRestoreTab = () => import("@/components/admin/tabs/restore-tab.vue"); const AdminStatsTab = () => import("@/components/admin/tabs/stats-tab.vue"); const AdminTaggingTab = () => import("@/components/admin/tabs/tagging-tab.vue"); const AdminEmailTab = () => import("@/components/admin/tabs/email-tab.vue"); +const AdminAuthTab = () => import("@/components/admin/tabs/auth-tab.vue"); const ResetPasswordConfirm = () => import("@/components/auth/reset-password-confirm.vue"); +const SsoError = () => import("@/components/auth/sso-error.vue"); // Both CODEX.LAST_ROUTE (server-injected) and the browser-defaults fallback // speak the v4 {collection, parentIds} route dialect now. @@ -78,6 +80,7 @@ const routes = [ component: AdminSettingsTab, }, { name: "admin-tagging", path: "tagging", component: AdminTaggingTab }, + { name: "admin-auth", path: "auth", component: AdminAuthTab }, { name: "admin-email", path: "email", component: AdminEmailTab }, { name: "admin-jobs", path: "jobs", component: AdminJobsTab }, { name: "admin-stats", path: "stats", component: AdminStatsTab }, @@ -89,6 +92,11 @@ const routes = [ path: "/auth/reset-password", component: ResetPasswordConfirm, }, + { + name: "sso-error", + path: "/auth/sso-error", + component: SsoError, + }, { name: "error", path: "/error/:code", component: HttpError, props: true }, { name: "404", path: "/:pathMatch(.*)*", redirect: "/error/404" }, ]; diff --git a/frontend/src/stores/admin.js b/frontend/src/stores/admin.js index b00ff9544..a3c8195df 100644 --- a/frontend/src/stores/admin.js +++ b/frontend/src/stores/admin.js @@ -28,6 +28,7 @@ const TABLE_TTL_MS = Object.freeze({ export const TABS = Object.freeze([ "Users", "Groups", + "Auth", "Email", "Libraries", "Tagging", @@ -61,6 +62,7 @@ export const useAdminStore = defineStore("admin", { stats: undefined, taggingDefaults: undefined, emailSettings: undefined, + oidcSettings: undefined, throttleSettings: undefined, apiKey: "", activeTab: "Libraries", @@ -444,6 +446,58 @@ export const useAdminStore = defineStore("admin", { return undefined; } }, + async loadOidcSettings({ force = false } = {}) { + if (this._requireAdmin()) return false; + if (!force) { + const ttl = DYNAMIC_TTL_MS; + const last = this.timestamps.OidcSettings || 0; + if (last && Date.now() - last < ttl) { + return true; + } + } + await API.getOidcSettings() + .then((response) => { + this.oidcSettings = response.data; + this.timestamps.OidcSettings = Date.now(); + return true; + }) + .catch(console.warn); + }, + async updateOidcSettings(data) { + if (this._requireAdmin()) return false; + const commonStore = useCommonStore(); + await API.updateOidcSettings(data) + .then(async (response) => { + this.oidcSettings = response.data; + this.timestamps.OidcSettings = Date.now(); + commonStore.clearErrors(); + // Keep the auth store's public OIDC flags — which drive the + // "Login with " button — in sync with the settings + // just saved, so toggling OIDC on/off is reflected without a + // manual page reload. OIDCSettings is a singleton (no + // admin.flags.changed websocket broadcast), so refresh here. + await useAuthStore().loadAdminFlags(); + return true; + }) + .catch(commonStore.setErrors); + }, + /* + * Probe the identity provider's discovery document, optionally with + * a not-yet-saved server URL override. Returns the endpoint report + * from the server; errors land on the common store too. + */ + async testOidcConnection(data) { + if (this._requireAdmin()) return undefined; + const commonStore = useCommonStore(); + try { + const response = await API.testOidcConnection(data); + commonStore.clearErrors(); + return response.data; + } catch (error) { + commonStore.setErrors(error); + return undefined; + } + }, async loadThrottleSettings({ force = false } = {}) { if (this._requireAdmin()) return false; if (!force) { diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 104290c78..199dbd71a 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -18,6 +18,10 @@ export const useAuthStore = defineStore("auth", { lazyImportMetadata: undefined, emailEnabled: undefined, remoteUserEnabled: undefined, + oidcEnabled: undefined, + oidcProviderName: undefined, + oidcLoginUrl: undefined, + oidcLogoutUrl: undefined, }, user: undefined, /* @@ -116,6 +120,18 @@ export const useAuthStore = defineStore("auth", { }) .catch(commonStore.setErrors); }, + /* + * Full-page navigation, not an XHR: the identity provider's login + * page must render in the browser, and the chain of redirects + * ends back at the SPA root with a fresh session cookie. The URL + * arrives prefix-qualified from the ``/session`` payload. + */ + loginSSO() { + const url = this.adminFlags.oidcLoginUrl; + if (url) { + globalThis.location.assign(url); + } + }, async logout() { /* * The user clicked "log out" — clear client-side state @@ -129,6 +145,12 @@ export const useAuthStore = defineStore("auth", { * and-forget, but the menu's UI feedback would benefit from * knowing when the call resolves). */ + /* + * Captured before the local clear: RP-initiated logout also ends + * the identity-provider session. The URL only exists on payloads + * for authenticated OIDC-configured sessions. + */ + const oidcLogoutUrl = this.adminFlags.oidcLogoutUrl; try { await API.logout(); } catch (error) { @@ -140,6 +162,19 @@ export const useAuthStore = defineStore("auth", { * signing in next doesn't see the previous user's stars. */ useFavoritesStore().clear(); + if (oidcLogoutUrl) { + // Full-page redirect to end the IdP session; the ensuing + // reload refreshes the flags on its own. + globalThis.location.assign(oidcLogoutUrl); + } else { + /* + * Refresh the public admin flags so the logged-out login + * screen reflects anything changed during the session — e.g. + * OIDC toggled off hides the "Login with " button + * without a manual page reload. + */ + await this.loadAdminFlags(); + } } }, async changePassword(credentials) { diff --git a/frontend/tests/unit/admin-oidc-store.test.js b/frontend/tests/unit/admin-oidc-store.test.js new file mode 100644 index 000000000..2949872ad --- /dev/null +++ b/frontend/tests/unit/admin-oidc-store.test.js @@ -0,0 +1,158 @@ +/* + * Unit tests for the admin store's OIDC settings actions: + * loadOidcSettings (TTL-gated), updateOidcSettings, testOidcConnection. + * HTTP layer mocked; admin gate driven through the auth store. + */ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock(import("@/api/v4/admin"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getOidcSettings: vi.fn(), + updateOidcSettings: vi.fn(), + testOidcConnection: vi.fn(), + }; +}); + +import { createTestingPinia } from "@pinia/testing"; +import { mount } from "@vue/test-utils"; + +import * as API from "@/api/v4/admin"; +import AuthTab, { canEnableOidc } from "@/components/admin/tabs/auth-tab.vue"; +import vuetify from "@/plugins/vuetify"; +import { useAdminStore } from "@/stores/admin"; +import { useAuthStore } from "@/stores/auth"; + +const SETTINGS = Object.freeze({ + enabled: true, + providerName: "Authentik", + serverUrl: "https://idp.example.com", + clientId: "codex", + clientSecretSet: true, +}); + +function adminStore() { + const authStore = useAuthStore(); + authStore.user = { id: 1, username: "admin", isStaff: true }; + return useAdminStore(); +} + +beforeEach(() => { + setActivePinia(createPinia()); + for (const fn of Object.values(API)) { + if (typeof fn?.mockReset === "function") { + fn.mockReset(); + } + } +}); + +describe("canEnableOidc — enable-switch gate", () => { + const complete = Object.freeze({ + providerName: "Authentik", + serverUrl: "https://idp.example.com", + clientId: "codex", + }); + + it("requires a provider name, a valid server URL, and a client ID", () => { + expect(canEnableOidc({})).toBe(false); + expect(canEnableOidc({ ...complete, providerName: "" })).toBe(false); + expect(canEnableOidc({ ...complete, serverUrl: "" })).toBe(false); + expect(canEnableOidc({ ...complete, clientId: "" })).toBe(false); + expect(canEnableOidc({ ...complete, serverUrl: "not a url" })).toBe(false); + expect(canEnableOidc(complete)).toBe(true); + }); +}); + +describe("AuthTab — OIDC disclosure", () => { + function mountTab(oidcSettings) { + const pinia = createTestingPinia({ + initialState: { + auth: { user: { id: 1, username: "admin", isStaff: true } }, + admin: { oidcSettings, flags: [] }, + }, + }); + return mount(AuthTab, { global: { plugins: [vuetify, pinia] } }); + } + + it("starts collapsed when OIDC is disabled", () => { + const wrapper = mountTab({ ...SETTINGS, enabled: false }); + expect(wrapper.find("form").exists()).toBe(false); + expect(wrapper.find(".adminExpandToggle").text()).toContain("Configure"); + expect(wrapper.text()).toContain("Not enabled"); + }); + + it("starts expanded when OIDC is enabled", () => { + const wrapper = mountTab(SETTINGS); + expect(wrapper.find("form").exists()).toBe(true); + expect(wrapper.find(".adminExpandToggle").text()).toContain("Hide"); + }); + + it("expands on toggle click", async () => { + const wrapper = mountTab({ ...SETTINGS, enabled: false }); + await wrapper.find(".adminExpandToggle").trigger("click"); + expect(wrapper.find("form").exists()).toBe(true); + }); +}); + +describe("useAdminStore — OIDC settings", () => { + it("loads settings into state", async () => { + API.getOidcSettings.mockResolvedValue({ data: SETTINGS }); + const store = adminStore(); + await store.loadOidcSettings(); + expect(store.oidcSettings).toEqual(SETTINGS); + }); + + it("respects the TTL and refetches with force", async () => { + API.getOidcSettings.mockResolvedValue({ data: SETTINGS }); + const store = adminStore(); + await store.loadOidcSettings(); + await store.loadOidcSettings(); + expect(API.getOidcSettings).toHaveBeenCalledTimes(1); + await store.loadOidcSettings({ force: true }); + expect(API.getOidcSettings).toHaveBeenCalledTimes(2); + }); + + it("denies non-admins", async () => { + useAuthStore().user = { id: 2, username: "reader" }; + const store = useAdminStore(); + await store.loadOidcSettings(); + expect(API.getOidcSettings).not.toHaveBeenCalled(); + }); + + it("update round-trips server state and refreshes auth public flags", async () => { + API.updateOidcSettings.mockResolvedValue({ + data: { ...SETTINGS, providerName: "Renamed" }, + }); + const store = adminStore(); + // Saving OIDC settings must resync the auth store's public flags so the + // login button reflects the change without a page reload. + const refresh = vi + .spyOn(useAuthStore(), "loadAdminFlags") + .mockResolvedValue(true); + await store.updateOidcSettings({ providerName: "Renamed" }); + expect(API.updateOidcSettings).toHaveBeenCalledWith({ + providerName: "Renamed", + }); + expect(store.oidcSettings.providerName).toBe("Renamed"); + expect(refresh).toHaveBeenCalled(); + }); + + it("test connection returns the endpoint report", async () => { + const report = { ok: true, issuer: "https://idp.example.com" }; + API.testOidcConnection.mockResolvedValue({ data: report }); + const store = adminStore(); + const result = await store.testOidcConnection({ + serverUrl: "https://idp.example.com", + }); + expect(result).toEqual(report); + }); + + it("test connection surfaces errors as undefined", async () => { + API.testOidcConnection.mockRejectedValue(new Error("boom")); + const store = adminStore(); + const result = await store.testOidcConnection({}); + expect(result).toBeUndefined(); + }); +}); diff --git a/frontend/tests/unit/sso-auth.test.js b/frontend/tests/unit/sso-auth.test.js new file mode 100644 index 000000000..d9be654ed --- /dev/null +++ b/frontend/tests/unit/sso-auth.test.js @@ -0,0 +1,164 @@ +/* + * Unit tests for the SSO (OIDC) auth-store actions and components. + * + * ``loginSSO`` and the RP-initiated-logout leg of ``logout`` perform + * full-page navigations via ``globalThis.location.assign`` — spied here, + * never executed. The SSO button and error page render against real + * Vuetify with a testing pinia. + */ +import { createTestingPinia } from "@pinia/testing"; +import { mount } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/api/v4/auth", () => ({ + getSession: vi.fn(), + register: vi.fn(), + login: vi.fn(), + getProfile: vi.fn(), + updateProfile: vi.fn(), + logout: vi.fn(), + updatePassword: vi.fn(), + sendResetPasswordLink: vi.fn(), + resetPassword: vi.fn(), + updateTimezone: vi.fn(), + getToken: vi.fn(), + updateToken: vi.fn(), +})); + +import * as API from "@/api/v4/auth"; +import SsoError from "@/components/auth/sso-error.vue"; +import SsoLoginButton from "@/components/auth/sso-login-button.vue"; +import vuetify from "@/plugins/vuetify"; +import { useAuthStore } from "@/stores/auth"; + +const LOGIN_URL = "/codex/api/v4/auth/oidc/login"; + +describe("useAuthStore — SSO actions", () => { + let assign; + + beforeEach(() => { + setActivePinia(createPinia()); + // spyOn returns the same spy on re-entry; clear its call history. + assign = vi + .spyOn(globalThis.location, "assign") + .mockImplementation(() => {}); + assign.mockClear(); + for (const fn of Object.values(API)) { + if (typeof fn?.mockReset === "function") { + fn.mockReset(); + } + } + }); + + it("loginSSO navigates to the prefix-qualified login URL", () => { + const store = useAuthStore(); + store.adminFlags.oidcLoginUrl = LOGIN_URL; + store.loginSSO(); + expect(assign).toHaveBeenCalledWith(LOGIN_URL); + }); + + it("loginSSO is a no-op when OIDC is not configured", () => { + const store = useAuthStore(); + store.loginSSO(); + expect(assign).not.toHaveBeenCalled(); + }); + + it("logout navigates to the RP-initiated logout URL when present", async () => { + API.logout.mockResolvedValue({}); + const store = useAuthStore(); + store.adminFlags.oidcLogoutUrl = + "https://idp.example.com/end-session/?client_id=codex"; + await store.logout(); + expect(store.user).toBeUndefined(); + expect(assign).toHaveBeenCalledWith( + "https://idp.example.com/end-session/?client_id=codex", + ); + }); + + it("logout stays local and refreshes public flags without an oidcLogoutUrl", async () => { + API.logout.mockResolvedValue({}); + // The logged-out /session reflects OIDC now disabled. + API.getSession.mockResolvedValue({ + data: { adminFlags: { oidcEnabled: false } }, + }); + const store = useAuthStore(); + store.adminFlags.oidcEnabled = true; // stale from the just-ended session + await store.logout(); + expect(assign).not.toHaveBeenCalled(); + expect(API.getSession).toHaveBeenCalled(); + expect(store.adminFlags.oidcEnabled).toBe(false); + }); + + it("logout skips the flag refresh when doing an RP-initiated redirect", async () => { + API.logout.mockResolvedValue({}); + const store = useAuthStore(); + store.adminFlags.oidcLogoutUrl = "https://idp.example.com/end-session/"; + await store.logout(); + expect(API.getSession).not.toHaveBeenCalled(); + }); +}); + +function mountWithFlags(component, adminFlags, mocks = {}) { + const pinia = createTestingPinia({ + initialState: { auth: { adminFlags } }, + }); + return mount(component, { + global: { + plugins: [vuetify, pinia], + mocks, + }, + }); +} + +describe("SsoLoginButton", () => { + it("renders the provider name when OIDC is enabled", () => { + const wrapper = mountWithFlags(SsoLoginButton, { + oidcEnabled: true, + oidcProviderName: "Authentik", + }); + const button = wrapper.find("#ssoLoginButton"); + expect(button.exists()).toBe(true); + expect(button.text()).toContain("Login with Authentik"); + }); + + it("renders nothing when OIDC is disabled", () => { + const wrapper = mountWithFlags(SsoLoginButton, { oidcEnabled: false }); + expect(wrapper.find("#ssoLoginButton").exists()).toBe(false); + }); +}); + +describe("SsoError", () => { + function mountError(error) { + return mountWithFlags( + SsoError, + { oidcEnabled: true, oidcProviderName: "SSO" }, + { + $route: { query: error ? { error } : {} }, + $router: { push: vi.fn() }, + }, + ); + } + + it("maps access_denied to a human message and offers retry", () => { + const wrapper = mountError("access_denied"); + expect(wrapper.text()).toContain("denied the login request"); + expect(wrapper.find("#ssoLoginButton").exists()).toBe(true); + }); + + it("hides retry for signup_disabled", () => { + const wrapper = mountError("signup_disabled"); + expect(wrapper.text()).toContain("Automatic account creation is disabled"); + expect(wrapper.find("#ssoLoginButton").exists()).toBe(false); + }); + + it("collapses unknown errors to the server_error message", () => { + const wrapper = mountError("bogus_code"); + expect(wrapper.text()).toContain("could not be reached"); + }); + + it("defaults to server_error with no query param", () => { + const wrapper = mountError(); + expect(wrapper.text()).toContain("could not be reached"); + }); +}); diff --git a/package.json b/package.json index b199bc52c..70ff5b4a6 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,9 @@ "preset-prettier" ] }, + "overrides": { + "typescript": "~6.0.3" + }, "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", "@eslint/js": "^10.0.1", @@ -102,6 +105,6 @@ "remark-preset-lint-markdown-style-guide": "^6.0.1", "remark-preset-lint-recommended": "^7.0.1", "remark-preset-prettier": "^2.0.2", - "svgo": "^4.0.1" + "svgo": "^4.0.2" } } diff --git a/pyproject.toml b/pyproject.toml index 1d4a6dd25..b9e461f71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "comicbox[pdf]~=4.1.0", "cryptography>=48.0.0", "dateparser~=1.2", + "django-allauth[socialaccount]~=65.13", "django-cachalot~=2.8", "django-cors-headers~=4.0", "django-rest-registration~=0.9", diff --git a/test-proxy/README.md b/test-proxy/README.md new file mode 100644 index 000000000..40aa8c794 --- /dev/null +++ b/test-proxy/README.md @@ -0,0 +1,194 @@ +# Codex SSO manual test harness + +Everything needed to manually test Codex authentication before a release: an +**authentik** OIDC identity provider, a **tinyauth** forward-auth gateway, and +an **nginx** reverse proxy — all as `docker compose` services. Only Codex itself +runs on the host (`make dev`). + +Every credential in this directory is public throwaway test data. + +> **Why authentik and not Authelia?** Authelia refuses to serve OIDC over plain +> HTTP, which would force self-signed-TLS trust into Codex's discovery/token +> fetches. authentik runs happily on and also supports +> RP-initiated logout, so it exercises every Codex OIDC feature. tinyauth is not +> an OIDC provider — it tests the Remote-User forward-auth path instead. + +## Pieces + +| Piece | Where | URL | +| ---------------- | -------------------------------------------------------------- | ----------------------------------- | +| authentik | `compose.yaml` | | +| tinyauth | `compose.yaml` | | +| nginx plain | `compose.yaml` + `server.conf` | | +| nginx gated | `compose.yaml` + `forwardauth.conf` | | +| authentik config | `authentik/blueprints/codex-test.yaml` (applied automatically) | + +`*.localtest.me` all resolve to `127.0.0.1` via public DNS — used for the +tinyauth path because tinyauth v5 refuses to run on a bare `localhost` app URL +(it sets its session cookie for a parent domain). Everything else stays on +`localhost`. + +The nginx service and `bin/run-test-proxy.sh` (native) share `server.conf` and +`forwardauth.conf`; only the backend addresses differ (`upstreams-docker.conf` +vs `upstreams-native.conf`). + +Test identities (all passwords `insecure-test-password` unless noted): + +| Account | Where | Groups | Notes | +| ----------- | --------- | --------------------- | ------------------------------ | +| `akadmin` | authentik | — | password `admin`; authentik UI | +| `testuser` | authentik | readers | ordinary OIDC login | +| `testadmin` | authentik | readers, codex-admins | for admin-group mapping | +| `test` | tinyauth | — | forward-auth login | + +## Start everything + +```sh +# 1. The whole harness — authentik, tinyauth, AND nginx (plain on 8080, +# tinyauth-gated on 8081). First start pulls images; authentik takes a +# minute or two to migrate + apply the blueprint. +docker compose -f test-proxy/compose.yaml up -d + +# 2. Codex, as usual (the harness assumes url_path_prefix = "/codex"). +# nginx reaches it on the host via host.docker.internal. +make dev +``` + +Sanity checks: + +```sh +curl -s http://localhost:9010/application/o/codex-test/.well-known/openid-configuration | head -c 200 +curl -s -o /dev/null -w '%{http_code}\n' http://tinyauth.localtest.me:3232/api/auth/nginx # 401 +curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/codex/ # Codex via nginx +``` + +> **Native nginx alternative.** If you'd rather run nginx on the host (e.g. to +> also exercise the 8443 TLS/HTTP3 listeners the container path omits), skip the +> `nginx` service and run `make dev-reverse-proxy` instead — it uses the same +> `server.conf` / `forwardauth.conf` with localhost backends. Either comment out +> the `nginx` service or +> `docker compose ... up -d authentik-server authentik-worker tinyauth postgresql redis` +> to avoid the 8080/8081 port clash. + +## Test 1 — native OIDC login (authentik) + +1. Browse , log in as your local admin, open + **Admin → Auth**, expand **OIDC Single Sign-On**, and enter: + - Provider Name: `Authentik` + - Server URL: `http://localhost:9010/application/o/codex-test/` + - Client ID: `codex-test` + - Client Secret: `insecure-codex-test-secret` + - (scope stays `openid profile email` — authentik ships groups in the + profile scope) +2. **Test Connection** → expect issuer + authorization/token/userinfo ✓ and + end-session ✓. +3. Enable OIDC, save, log out. +4. The lock screen / login dialog now shows **Login with Authentik**. Click it, + sign in as `testuser`, land back in Codex logged in as `testuser` (check + Admin → Users: the account was auto-created). +5. **Group sync:** create a Codex group named `readers` (Admin → Groups), turn + on _Sync Groups_ in the Auth tab, log in as `testuser` again → the user is in + `readers`. Groups that don't exist in Codex (`codex-admins` before you create + it) are ignored, never created. +6. **Admin group:** set _Admin Group_ to `codex-admins`, log in as `testadmin` → + account gets staff+superuser; remove `testadmin` from the group in authentik + (or change _Admin Group_) and log in again → revoked. +7. **RP-initiated logout:** enable _Also Log Out of the Identity Provider_, log + in via SSO, then log out of Codex → you land on authentik's end-session page + and the authentik session is gone. +8. **Linking:** in authentik create a user whose username matches an existing + local Codex user; SSO login links to (not duplicates) it — including the + admin account, per the documented trust model. +9. **Error page:** stop the compose stack and click the SSO button → + `/auth/sso-error` shows the human-readable failure, not a stack trace. +10. **Disabled = 404:** turn OIDC off, then + `curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/codex/sso/oidc/login/` + → `404`. + +## Test 2 — tinyauth forward-auth (Remote-User) + +1. Restart Codex with header auth enabled: + + ```sh + CODEX_AUTH_REMOTE_USER=1 make dev + ``` + +2. Browse → redirected to the tinyauth + login (`tinyauth.localtest.me:3232`) → sign in `test` / + `insecure-test-password` → back in Codex, logged in as `test` (auto-created). +3. **Gating:** confirm + and the WebSocket (`/codex/api/v4/ws`, watch devtools) also bounce to login + when logged out of tinyauth. +4. **Spoof-proofing (gated port):** + + ```sh + curl -s -o /dev/null -w '%{http_code}\n' \ + -H "Remote-User: admin" http://codex.localtest.me:8081/codex/api/v4/session + ``` + + → `302` to the tinyauth login: the gate overrides the client header. + +5. **The misconfiguration demo (ungated port):** the same curl against + `http://localhost:8080/...` **authenticates as admin** — port 8080 does not + strip `Remote-User`. This is exactly why the README warns to enable + `CODEX_AUTH_REMOTE_USER` only when every route to Codex sets the header + itself. Stop Codex and restart without the env var when done. +6. **Coexistence:** with both OIDC enabled and `CODEX_AUTH_REMOTE_USER=1`, both + login paths work at once (8081 header-auths you; 8080 still offers the SSO + button). + +## Troubleshooting + +**"Login with Authentik" fails with "could not be reached", authentik logs +"Invalid grant_type for provider".** authentik 2026.x added a `grant_types` +field that defaults to empty in blueprints, so the provider allows no grants. +The blueprint now sets `grant_types: [authorization_code, refresh_token]`. If +you brought the stack up before this fix, authentik still has the old provider — +re-apply the blueprint: + +```sh +docker compose -f test-proxy/compose.yaml restart authentik-worker +# then wait ~30s, or edit the blueprint file to retrigger the watcher. +``` + +Verify in the authentik UI (Applications → Providers → codex-test-provider → +Edit → Advanced protocol settings → the flow allows the authorization_code +grant). + +**nginx logs `connect() to [fd..::254]:9810 failed (Network unreachable)`.** +Harmless — nginx tries the IPv6 `host-gateway` (nothing listens there; Granian +is IPv4) and immediately retries IPv4, which succeeds. It only appears if the +`extra_hosts` line in the `nginx` service is uncommented on Docker Desktop; +leave it commented there (built-in `host.docker.internal` is IPv4). On Linux, +uncomment it — the name doesn't exist otherwise. + +**Vite HMR won't start / "port 5173".** The harness does not use 5173 — it +publishes only `9010`, `8080`, `8081`, and `3232` (check with +`docker compose -f test-proxy/compose.yaml config | grep published`). A blocked +Vite HMR is almost always a leftover `vite` from a previous `make dev` that +didn't exit cleanly: + +```sh +lsof -i :5173 # find whatever holds 5173 +pkill -f vite # or kill the specific PID, then re-run make dev +``` + +**"address already in use" on 8080/8081.** The compose `nginx` service and the +native `make dev-reverse-proxy` both bind 8080/8081 — run one or the other, not +both. + +**tinyauth restart loop: "invalid app url, must be at least second level +domain".** That's tinyauth v5 rejecting a bare `localhost` app URL. The current +`compose.yaml` uses `tinyauth.localtest.me` to satisfy it; if you still see +this, you're on an older copy — `git pull` / re-read `compose.yaml` and +`docker compose ... up -d --force-recreate tinyauth`. + +## Reset + +```sh +docker compose -f test-proxy/compose.yaml down -v # wipe IdP state +``` + +Codex-side cleanup between runs: delete the auto-created users (`testuser`, +`testadmin`, `test`) in Admin → Users, and disable OIDC in the Auth tab (or +restore your config dir from backup). diff --git a/test-proxy/authentik/blueprints/codex-test.yaml b/test-proxy/authentik/blueprints/codex-test.yaml new file mode 100644 index 000000000..f3cca635c --- /dev/null +++ b/test-proxy/authentik/blueprints/codex-test.yaml @@ -0,0 +1,114 @@ +# authentik blueprint: everything the Codex OIDC manual test needs. +# +# Mounted into the authentik containers at /blueprints/custom/ (see +# ../../compose.yaml); authentik discovers and applies it automatically at +# startup and re-applies on file change (and every 60 minutes). +# +# Creates: +# groups codex-admins, readers +# users testuser / insecure-test-password (readers) +# testadmin / insecure-test-password (readers, codex-admins) +# OIDC client client id codex-test +# secret insecure-codex-test-secret +# issuer http://localhost:9010/application/o/codex-test/ +# +# Redirect URIs cover Codex behind the test-proxy nginx (8080) and direct +# (9810), each with and without a /codex url_path_prefix. +# +# All credentials here are public throwaway test data for this local +# harness only. +version: 1 +metadata: + name: codex-test + labels: + blueprints.goauthentik.io/description: Codex SSO manual-test fixtures +entries: + - model: authentik_core.group + id: group-codex-admins + identifiers: + name: codex-admins + state: present + - model: authentik_core.group + id: group-readers + identifiers: + name: readers + state: present + + - model: authentik_core.user + identifiers: + username: testuser + state: present + attrs: + name: Test User + email: testuser@example.com + password: insecure-test-password + is_active: true + groups: + - !KeyOf group-readers + + - model: authentik_core.user + identifiers: + username: testadmin + state: present + attrs: + name: Test Admin + email: testadmin@example.com + password: insecure-test-password + is_active: true + groups: + - !KeyOf group-readers + - !KeyOf group-codex-admins + + - model: authentik_providers_oauth2.oauth2provider + id: codex-test-provider + identifiers: + name: codex-test-provider + state: present + attrs: + name: codex-test-provider + client_type: confidential + client_id: codex-test + client_secret: insecure-codex-test-secret + # authentik 2026.x added an explicit grant_types field that defaults + # to an EMPTY list in blueprints — without this the authorize step + # fails with "Invalid grant_type for provider" and the callback comes + # back as error=invalid_request. authorization_code is what Codex + # uses; refresh_token matches the authentik UI default. + grant_types: + - authorization_code + - refresh_token + authorization_flow: + !Find [ + authentik_flows.flow, + [slug, default-provider-authorization-implicit-consent], + ] + invalidation_flow: + !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + signing_key: + !Find [ + authentik_crypto.certificatekeypair, + [name, authentik Self-signed Certificate], + ] + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]] + redirect_uris: + - matching_mode: strict + url: http://localhost:8080/codex/sso/oidc/login/callback/ + - matching_mode: strict + url: http://localhost:8080/sso/oidc/login/callback/ + - matching_mode: strict + url: http://localhost:9810/codex/sso/oidc/login/callback/ + - matching_mode: strict + url: http://localhost:9810/sso/oidc/login/callback/ + + - model: authentik_core.application + identifiers: + slug: codex-test + state: present + attrs: + name: Codex Test + slug: codex-test + provider: !KeyOf codex-test-provider + policy_engine_mode: any diff --git a/test-proxy/compose.yaml b/test-proxy/compose.yaml new file mode 100644 index 000000000..1d8a6d559 --- /dev/null +++ b/test-proxy/compose.yaml @@ -0,0 +1,160 @@ +# Identity-provider stack for manually testing Codex SSO before release. +# +# docker compose -f test-proxy/compose.yaml up -d +# +# Services (everything binds to 127.0.0.1 only): +# authentik http://localhost:9010 OIDC identity provider +# admin UI login: akadmin / admin +# test users (from authentik/blueprints/codex-test.yaml): +# testuser / insecure-test-password groups: readers +# testadmin / insecure-test-password groups: readers, codex-admins +# OIDC client for the Codex Auth tab: +# server url http://localhost:9010/application/o/codex-test/ +# client id codex-test +# client secret insecure-codex-test-secret +# tinyauth http://localhost:3232 forward-auth gateway (nginx auth_request) +# login: test / insecure-test-password +# +# Every credential in this file is intentionally public throwaway test data. +# See test-proxy/README.md for the full manual test matrix. + +services: + postgresql: + image: docker.io/library/postgres:16-alpine + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -d authentik -U authentik"] + start_period: 20s + interval: 30s + retries: 5 + timeout: 5s + environment: + POSTGRES_PASSWORD: insecure-test-pg-password + POSTGRES_USER: authentik + POSTGRES_DB: authentik + volumes: + - authentik_pg:/var/lib/postgresql/data + + redis: + image: docker.io/library/redis:alpine + command: --save 60 1 --loglevel warning + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + start_period: 20s + interval: 30s + retries: 5 + timeout: 3s + volumes: + - authentik_redis:/data + + authentik-server: + image: ghcr.io/goauthentik/server:2026.5 + restart: unless-stopped + command: server + environment: &authentik_env + AUTHENTIK_SECRET_KEY: insecure-test-secret-key-for-local-harness-only + AUTHENTIK_ERROR_REPORTING__ENABLED: "false" + AUTHENTIK_DISABLE_UPDATE_CHECK: "true" + AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true" + AUTHENTIK_REDIS__HOST: redis + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__USER: authentik + AUTHENTIK_POSTGRESQL__NAME: authentik + AUTHENTIK_POSTGRESQL__PASSWORD: insecure-test-pg-password + # Applied on first start only: makes akadmin usable immediately. + AUTHENTIK_BOOTSTRAP_PASSWORD: admin + AUTHENTIK_BOOTSTRAP_EMAIL: akadmin@example.com + ports: + - "127.0.0.1:9010:9000" + volumes: + - ./authentik/blueprints:/blueprints/custom:ro + depends_on: + postgresql: + condition: service_healthy + redis: + condition: service_healthy + + authentik-worker: + image: ghcr.io/goauthentik/server:2026.5 + restart: unless-stopped + command: worker + environment: *authentik_env + volumes: + - ./authentik/blueprints:/blueprints/custom:ro + depends_on: + postgresql: + condition: service_healthy + redis: + condition: service_healthy + + tinyauth: + image: ghcr.io/steveiliop56/tinyauth:v5 + restart: unless-stopped + environment: + # tinyauth v5 derives a cookie domain from this URL and rejects + # single-label hosts ("localhost") and IPs — it must be a dotted + # name. localtest.me and all its subdomains resolve to 127.0.0.1 via + # public DNS (works in every browser, no /etc/hosts), so the session + # cookie set for `.localtest.me` is shared with the gated Codex host + # (codex.localtest.me:8081). See forwardauth.conf and README Test 2. + TINYAUTH_APPURL: http://tinyauth.localtest.me:3232 + # test / insecure-test-password (bcrypt). Compose interpolates $, so + # every $ in the hash is doubled. Regenerate with: + # docker run -i -t --rm ghcr.io/steveiliop56/tinyauth:v5 \ + # user create --interactive + TINYAUTH_AUTH_USERS: "test:$$2a$$05$$3iNEElgG4O.dR2RbvTmEQONRN7Zl3DcMYnz1cf9esxnCdeTWB/b2u" + # The image runs as a non-root user with WORKDIR /tinyauth (root-owned) + # and declares VOLUME /data — so the sqlite DB must live under /data, + # not the default ./tinyauth.db in the unwritable workdir. + TINYAUTH_DATABASE_PATH: /data/tinyauth.db + # The harness runs over plain http. + TINYAUTH_AUTH_SECURECOOKIE: "false" + ports: + - "127.0.0.1:3232:3000" + volumes: + - tinyauth_data:/data + + # The reverse proxy in front of Codex — the same nginx that + # bin/run-test-proxy.sh runs natively, containerized so nothing has to run + # on the host but Codex itself. Serves the plain proxy on :8080 and the + # tinyauth-gated proxy on :8081 (plain HTTP; the SSL/QUIC listeners are + # native-only). Reuses the shared server.conf / forwardauth.conf; only the + # backend upstreams differ (upstreams-docker.conf). + nginx: + image: docker.io/library/nginx:stable-alpine + restart: unless-stopped + # Codex runs natively on the host via `make dev`; nginx reaches it at + # host.docker.internal:9810 (upstreams-docker.conf). + # + # Docker Desktop (macOS/Windows) provides host.docker.internal built-in + # as an IPv4 address — leave the extra_hosts below commented there. + # Uncommenting it maps the name to the `host-gateway`, which on + # IPv6-enabled Docker Desktop is a ULA (fdXX::254) that Granian doesn't + # listen on, so nginx logs "connect()... Network unreachable" and only + # then falls back to IPv4. On LINUX (no built-in), UNCOMMENT it: + # extra_hosts: + # - "host.docker.internal:host-gateway" + ports: + - "127.0.0.1:8080:8080" + - "127.0.0.1:8081:8081" + volumes: + # Numeric prefixes fix include order under the stock image's + # `include /etc/nginx/conf.d/*.conf` (inside its http{} block). + - ./upstreams-docker.conf:/etc/nginx/conf.d/00-upstreams.conf:ro + - ./connection-upgrade.conf:/etc/nginx/conf.d/01-connection-upgrade.conf:ro + - ./server.conf:/etc/nginx/conf.d/10-server.conf:ro + - ./forwardauth.conf:/etc/nginx/conf.d/20-forwardauth.conf:ro + # server.conf's `include ssl-listen.conf` resolves against the /etc/nginx + # prefix here; the empty override keeps this path plain-HTTP. + - ./ssl-listen-none.conf:/etc/nginx/ssl-listen.conf:ro + depends_on: + # nginx resolves the `tinyauth` upstream hostname at startup, so the + # container must already exist. + tinyauth: + condition: service_started + +volumes: + authentik_pg: + authentik_redis: + tinyauth_data: diff --git a/test-proxy/connection-upgrade.conf b/test-proxy/connection-upgrade.conf new file mode 100644 index 000000000..39bd224c2 --- /dev/null +++ b/test-proxy/connection-upgrade.conf @@ -0,0 +1,10 @@ +# WebSocket upgrade map, shared by the native and container nginx paths. +# Connection is "upgrade" only when the client sent an Upgrade header, else +# "close" — so normal requests aren't proxied as malformed upgrades. +# server.conf and forwardauth.conf reference $connection_upgrade. +# http{} context (native: included from nginx.conf; container: mounted into +# conf.d, which the stock image includes inside http{}). +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} diff --git a/test-proxy/forwardauth.conf b/test-proxy/forwardauth.conf new file mode 100644 index 000000000..8302ad18e --- /dev/null +++ b/test-proxy/forwardauth.conf @@ -0,0 +1,78 @@ +# nginx server block: Codex behind tinyauth forward-auth (Remote-User SSO). +# Shared by the native (bin/run-test-proxy.sh) and container (compose.yaml +# `nginx` service) run paths; the backends differ only in the +# codex_backend / tinyauth_backend upstreams (upstreams-*.conf). +# +# Listens on 8081. Every request is authorized against tinyauth via +# auth_request; unauthenticated browsers are redirected to the tinyauth +# login page and come back with a session cookie. +# +# Hostnames matter here: tinyauth v5 sets its session cookie for a parent +# DOMAIN (it won't run on bare "localhost"). So the gated Codex must live +# on a sibling subdomain of tinyauth under a shared parent. We use +# localtest.me (all subdomains resolve to 127.0.0.1 via public DNS): +# gated Codex http://codex.localtest.me:8081/codex/ +# tinyauth login http://tinyauth.localtest.me:3232/ +# shared cookie .localtest.me +# +# Run Codex with CODEX_AUTH_REMOTE_USER=1 to accept the injected header, +# then browse http://codex.localtest.me:8081/codex/ +# +# Port 8080 (server.conf) stays ungated on purpose: with remote_user +# enabled it demonstrates exactly the spoofable misconfiguration the +# README warns about — see test-proxy/README.md. + +server { + listen 8081; + listen [::]:8081; + + server_name _; + + access_log /dev/stdout; + error_log /dev/stderr; + + # The internal authorization subrequest nginx makes for every request. + location = /tinyauth-auth { + internal; + proxy_pass http://tinyauth_backend/api/auth/nginx; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header x-forwarded-proto $scheme; + proxy_set_header x-forwarded-host $http_host; + proxy_set_header x-forwarded-uri $request_uri; + } + + # Browsers that fail the subrequest get bounced to the login page. + # tinyauth.localtest.me:3232 is the published tinyauth port; the browser + # resolves it (and $http_host = codex.localtest.me:8081) to 127.0.0.1. + location @tinyauth_login { + return 302 http://tinyauth.localtest.me:3232/login?redirect_uri=$scheme://$http_host$request_uri; + } + + location / { + auth_request /tinyauth-auth; + error_page 401 = @tinyauth_login; + auth_request_set $tinyauth_user $upstream_http_remote_user; + + # NOTE: nginx drops server-level proxy_set_header inheritance the + # moment a location defines its own, so EVERY header lives here. + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # Include the port (see server.conf) so Django builds correct + # absolute URLs behind this non-standard-port proxy. + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + # The authenticated identity — OVERRIDES anything the client sent, + # which is what makes header auth safe behind this gate. + proxy_set_header Remote-User $tinyauth_user; + # WebSockets (Codex live updates); the auth subrequest still runs. + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_pass http://codex_backend; + } +} diff --git a/test-proxy/nginx.conf b/test-proxy/nginx.conf index 2bb546dcc..132a3cc5b 100644 --- a/test-proxy/nginx.conf +++ b/test-proxy/nginx.conf @@ -8,17 +8,18 @@ events { worker_connections 1024; } http { default_type application/octet-stream; access_log /dev/stdout; - # WebSocket upgrade: Connection is "upgrade" only when the client sent an - # Upgrade header, else "close" — so normal requests aren't proxied as - # malformed upgrades. server.conf's proxy_set_header references this. - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } + # WebSocket upgrade map, shared with the container path. + include connection-upgrade.conf; + # Native backends (localhost); the container path defines these same + # upstream names differently in upstreams-docker.conf. + include upstreams-native.conf; client_body_temp_path tmp/client; proxy_temp_path tmp/proxy; fastcgi_temp_path tmp/fastcgi; uwsgi_temp_path tmp/uwsgi; scgi_temp_path tmp/scgi; include server.conf; + # Codex gated behind tinyauth forward-auth on :8081 (SSO testing; + # needs the compose stack from compose.yaml running). + include forwardauth.conf; } diff --git a/test-proxy/server.conf b/test-proxy/server.conf index 9730c6874..266e65d6c 100644 --- a/test-proxy/server.conf +++ b/test-proxy/server.conf @@ -1,30 +1,33 @@ # nginx server block for local reverse-proxy testing of a url_path_prefix -# (subpath) deployment. Run via bin/run-test-proxy.sh. Edit ports, proxy_pass, -# or routing here. Adapted from the linuxserver.io nginx default.conf sample; -# ports are remapped to 8080/8443 to run unprivileged and the SSL include -# points at this dir's ssl.conf instead of the container's /config path. +# (subpath) deployment. Shared by BOTH run paths: +# - native: bin/run-test-proxy.sh (nginx -p test-proxy) +# - container: the `nginx` service in compose.yaml +# The only native-vs-container difference — where the Codex backend lives — +# is abstracted into the `codex_backend` upstream, defined in +# upstreams-native.conf / upstreams-docker.conf. Edit ports or routing here +# and both paths pick it up. Adapted from the linuxserver.io nginx sample; +# ports are remapped to 8080/8443 to run unprivileged. server { listen 8080 default_server; listen [::]:8080 default_server; - listen 8443 ssl default_server; - listen [::]:8443 ssl default_server; - listen 8443 quic reuseport default_server; - listen [::]:8443 quic reuseport default_server; + # SSL/QUIC listeners: present natively (certs on disk), empty in the + # container (the SSO harness is plain-HTTP only). See ssl-listen.conf. + include ssl-listen.conf; server_name _; - include ssl.conf; - access_log /dev/stdout; error_log /dev/stderr; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # $host (the requested host), not $server_name — server_name is the "_" - # catch-all here, and codex has USE_X_FORWARDED_HOST=True, so forwarding - # "_" makes Django reject every dynamic request with DisallowedHost. - proxy_set_header X-Forwarded-Host $host; + # $http_host (the requested host INCLUDING port), not $server_name (the + # "_" catch-all → DisallowedHost) and not $host (which strips the port). + # codex has USE_X_FORWARDED_HOST=True, so this is what Django's + # build_absolute_uri uses — the port must be present or the OIDC + # redirect_uri comes out as http://localhost/... and fails to match. + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Proto $scheme; # proxy_set_header X-Forwarded-Ssl on; @@ -46,6 +49,6 @@ server { # nginx drop every proxy_set_header inherited from the server block above # (incl. the WebSocket Upgrade/Connection headers), breaking ws proxying. location / { - proxy_pass http://127.0.0.1:9810; + proxy_pass http://codex_backend; } } diff --git a/test-proxy/ssl-listen-none.conf b/test-proxy/ssl-listen-none.conf new file mode 100644 index 000000000..97536e9ec --- /dev/null +++ b/test-proxy/ssl-listen-none.conf @@ -0,0 +1,4 @@ +# Intentionally empty. Mounted over server.conf's `include ssl-listen.conf` +# in the container path (compose.yaml `nginx` service) so it serves plain +# HTTP only. The native path uses ssl-listen.conf (with the 8443 SSL/QUIC +# listeners) instead. diff --git a/test-proxy/ssl-listen.conf b/test-proxy/ssl-listen.conf new file mode 100644 index 000000000..6d9103548 --- /dev/null +++ b/test-proxy/ssl-listen.conf @@ -0,0 +1,10 @@ +# SSL + HTTP/3 listeners for the NATIVE run path (bin/run-test-proxy.sh), +# included from server.conf's server{} block. Kept for the original subpath +# TLS/HTTP3 testing this directory was built for. The container path mounts +# ssl-listen-none.conf over this include instead — the SSO harness needs no +# TLS, and QUIC/cert wiring inside a container is avoidable complexity. +listen 8443 ssl default_server; +listen [::]:8443 ssl default_server; +listen 8443 quic reuseport default_server; +listen [::]:8443 quic reuseport default_server; +include ssl.conf; diff --git a/test-proxy/upstreams-docker.conf b/test-proxy/upstreams-docker.conf new file mode 100644 index 000000000..6ac7ae517 --- /dev/null +++ b/test-proxy/upstreams-docker.conf @@ -0,0 +1,14 @@ +# Backends for the CONTAINER run path (the `nginx` service in compose.yaml). +# From inside the compose network: +# - Codex runs natively on the host -> host.docker.internal (needs the +# extra_hosts host-gateway mapping the compose service sets for Linux; +# automatic on Docker Desktop). +# - tinyauth is a sibling service -> its service name + INTERNAL port +# 3000 (not the published 3232). +# The native path uses upstreams-native.conf instead. +upstream codex_backend { + server host.docker.internal:9810; +} +upstream tinyauth_backend { + server tinyauth:3000; +} diff --git a/test-proxy/upstreams-native.conf b/test-proxy/upstreams-native.conf new file mode 100644 index 000000000..610663376 --- /dev/null +++ b/test-proxy/upstreams-native.conf @@ -0,0 +1,11 @@ +# Backends for the NATIVE run path (bin/run-test-proxy.sh). nginx runs on +# the host, so both backends are reachable on localhost: Codex from +# `make dev`, tinyauth on its published compose port. The container path +# uses upstreams-docker.conf instead — that is the only difference between +# the two. +upstream codex_backend { + server 127.0.0.1:9810; +} +upstream tinyauth_backend { + server 127.0.0.1:3232; +} diff --git a/tests/test_admin_oidc_settings.py b/tests/test_admin_oidc_settings.py new file mode 100644 index 000000000..286e73334 --- /dev/null +++ b/tests/test_admin_oidc_settings.py @@ -0,0 +1,200 @@ +"""Tests for the admin OIDC settings endpoints.""" + +from typing import Final, override +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.core.cache import cache +from django.test import TestCase + +from codex.models.admin import OIDCSettings +from codex.oidc import DISCOVERY_CACHE_KEY + +_URL: Final = "/api/v4/admin/oidc-settings" +_TEST_URL: Final = "/api/v4/admin/oidc-settings/test" +_HTTP_OK: Final = 200 +_HTTP_BAD_REQUEST: Final = 400 +_HTTP_FORBIDDEN: Final = 403 +_SECRET: Final = "hush-test-secret" # noqa: S105 + + +def _data(response) -> dict: + return response.json()["data"] + + +class AdminOIDCPermissionTests(TestCase): + """Only admins may read or write OIDC settings.""" + + def test_anonymous_denied(self) -> None: + assert self.client.get(_URL).status_code == _HTTP_FORBIDDEN + assert ( + self.client.put(_URL, {}, content_type="application/json").status_code + == _HTTP_FORBIDDEN + ) + assert self.client.post(_TEST_URL, {}).status_code == _HTTP_FORBIDDEN + + def test_non_staff_denied(self) -> None: + user = User.objects.create_user("reader") + self.client.force_login(user) + assert self.client.get(_URL).status_code == _HTTP_FORBIDDEN + + +class AdminOIDCSettingsViewTests(TestCase): + """GET/PUT round trip with a write-only secret.""" + + @override + def setUp(self) -> None: + admin = User.objects.create_superuser("admin", "", "admin") + self.client.force_login(admin) + + def test_get_returns_settings_without_secret(self) -> None: + OIDCSettings.objects.filter(pk=1).update(client_secret="") + data = _data(self.client.get(_URL)) + assert data["enabled"] is False + assert data["providerName"] == "SSO" + assert data["clientSecretSet"] is False + assert "clientSecret" not in data + + def test_put_round_trip_and_secret_mirror(self) -> None: + payload = { + "enabled": True, + "providerName": "Authentik", + "serverUrl": "https://auth.example.com/application/o/codex/", + "clientId": "codex", + "clientSecret": _SECRET, + "scope": "openid profile email groups", + "syncGroups": True, + } + data = _data(self.client.put(_URL, payload, content_type="application/json")) + assert data["enabled"] is True + assert data["providerName"] == "Authentik" + assert data["clientSecretSet"] is True + assert "clientSecret" not in data + row = OIDCSettings.objects.get(pk=1) + assert row.client_secret == _SECRET + assert row.sync_groups is True + + def test_put_without_secret_keeps_existing_secret(self) -> None: + OIDCSettings.objects.filter(pk=1).update(client_secret=_SECRET) + self.client.put( + _URL, {"providerName": "Renamed"}, content_type="application/json" + ) + row = OIDCSettings.objects.get(pk=1) + assert row.client_secret == _SECRET + assert row.provider_name == "Renamed" + + def test_put_blank_secret_clears_it(self) -> None: + OIDCSettings.objects.filter(pk=1).update(client_secret=_SECRET) + data = _data( + self.client.put(_URL, {"clientSecret": ""}, content_type="application/json") + ) + assert data["clientSecretSet"] is False + assert OIDCSettings.objects.get(pk=1).client_secret == "" + + def test_put_enabled_without_prerequisites_is_rejected(self) -> None: + """enabled=true requires a server URL and client ID (UI mirror).""" + response = self.client.put( + _URL, {"enabled": True}, content_type="application/json" + ) + assert response.status_code == _HTTP_BAD_REQUEST + assert OIDCSettings.objects.get(pk=1).enabled is False + + def test_put_blanking_prerequisite_while_enabled_is_rejected(self) -> None: + OIDCSettings.objects.filter(pk=1).update( + enabled=True, server_url="https://idp.example.com", client_id="codex" + ) + response = self.client.put( + _URL, {"serverUrl": ""}, content_type="application/json" + ) + assert response.status_code == _HTTP_BAD_REQUEST + assert OIDCSettings.objects.get(pk=1).server_url == "https://idp.example.com" + + def test_put_blanking_provider_name_while_enabled_is_rejected(self) -> None: + OIDCSettings.objects.filter(pk=1).update( + enabled=True, server_url="https://idp.example.com", client_id="codex" + ) + response = self.client.put( + _URL, {"providerName": ""}, content_type="application/json" + ) + assert response.status_code == _HTTP_BAD_REQUEST + assert OIDCSettings.objects.get(pk=1).provider_name == "SSO" + + def test_put_disable_and_blank_together_is_allowed(self) -> None: + OIDCSettings.objects.filter(pk=1).update( + enabled=True, server_url="https://idp.example.com", client_id="codex" + ) + response = self.client.put( + _URL, + {"enabled": False, "serverUrl": "", "clientId": ""}, + content_type="application/json", + ) + assert response.status_code == _HTTP_OK + row = OIDCSettings.objects.get(pk=1) + assert row.enabled is False + assert row.server_url == "" + + def test_put_invalidates_discovery_cache(self) -> None: + cache.set(DISCOVERY_CACHE_KEY, {"issuer": "https://stale.example.com"}) + self.client.put( + _URL, + {"serverUrl": "https://new.example.com"}, + content_type="application/json", + ) + assert cache.get(DISCOVERY_CACHE_KEY) is None + + +class AdminOIDCTestViewTests(TestCase): + """The Test Connection endpoint probes the discovery document.""" + + @override + def setUp(self) -> None: + admin = User.objects.create_superuser("admin", "", "admin") + self.client.force_login(admin) + + def _post(self, payload: dict) -> dict: + return _data( + self.client.post(_TEST_URL, payload, content_type="application/json") + ) + + def test_reports_discovered_endpoints(self) -> None: + doc = { + "issuer": "https://idp.example.com", + "authorization_endpoint": "https://idp.example.com/authorize", + "token_endpoint": "https://idp.example.com/token", + "userinfo_endpoint": "https://idp.example.com/userinfo", + } + with patch("codex.views.admin.oidc.fetch_discovery_document", return_value=doc): + data = self._post({"serverUrl": "https://idp.example.com"}) + assert data["ok"] is True + assert data["issuer"] == "https://idp.example.com" + assert data["authorizationEndpoint"] is True + assert data["tokenEndpoint"] is True + assert data["userinfoEndpoint"] is True + # Authelia case: no end_session advertised. + assert data["endSessionEndpoint"] is False + + def test_reports_fetch_error(self) -> None: + import requests + + with patch( + "codex.views.admin.oidc.fetch_discovery_document", + side_effect=requests.ConnectionError("boom"), + ): + data = self._post({"serverUrl": "https://unreachable.example.com"}) + assert data["ok"] is False + assert "boom" in data["error"] + + def test_falls_back_to_saved_server_url(self) -> None: + OIDCSettings.objects.filter(pk=1).update(server_url="https://saved.example.com") + with patch( + "codex.views.admin.oidc.fetch_discovery_document", return_value={} + ) as mock_fetch: + data = self._post({}) + mock_fetch.assert_called_once_with("https://saved.example.com") + assert data["ok"] is False + + def test_no_server_url_anywhere(self) -> None: + OIDCSettings.objects.filter(pk=1).update(server_url="") + data = self._post({}) + assert data["ok"] is False + assert "No server URL" in data["error"] diff --git a/tests/test_oidc_login.py b/tests/test_oidc_login.py new file mode 100644 index 000000000..d963f7170 --- /dev/null +++ b/tests/test_oidc_login.py @@ -0,0 +1,422 @@ +""" +Tests for native OIDC login: gating, adapter linking, and claim sync. + +The OAuth2 handshake itself is allauth's tested territory; these tests +drive ``complete_social_login`` with prebuilt ``SocialLogin`` objects +(what the callback view produces after the token exchange) so every +Codex policy branch is exercised against the real signup/login flows. +""" + +from typing import Final, override +from unittest.mock import patch + +from allauth.core import context +from allauth.core.exceptions import ImmediateHttpResponse +from allauth.socialaccount.helpers import complete_social_login +from allauth.socialaccount.models import SocialAccount, SocialLogin +from django.contrib.auth.models import AnonymousUser, Group, User +from django.contrib.messages.middleware import MessageMiddleware +from django.contrib.sessions.middleware import SessionMiddleware +from django.core.cache import cache +from django.test import RequestFactory, TestCase +from django.urls import resolve + +from codex.models.admin import OIDCSettings +from codex.oidc import ( + DISCOVERY_CACHE_KEY, + CodexSocialAccountAdapter, + merged_claims, + oidc_logout_url, +) +from codex.serializers.auth import ProfileUpdateSerializer +from codex.settings import GRANIAN_URL_PATH_PREFIX + +# The test environment configures a url_path_prefix, which conveniently +# proves the error redirects are prefix-qualified. +_SSO_ERROR: Final = f"{GRANIAN_URL_PATH_PREFIX}/auth/sso-error" +_SUB: Final = "sub-1234" +_HTTP_OK: Final = 200 +_HTTP_NOT_FOUND: Final = 404 +_HTTP_FOUND: Final = 302 + + +def _seed_oidc(**overrides) -> OIDCSettings: + """Seed the OIDCSettings singleton as a working test deployment.""" + defaults = { + "enabled": True, + "provider_name": "Test SSO", + "server_url": "https://idp.example.com", + "client_id": "codex-test", + "client_secret": "test-secret-hush", + **overrides, + } + row, _ = OIDCSettings.objects.update_or_create(pk=1, defaults=defaults) + return row + + +def _sociallogin(id_token=None, userinfo=None, sub=_SUB) -> SocialLogin: + """Build the SocialLogin the callback produces after token exchange.""" + extra_data = {} + if id_token is not None: + extra_data["id_token"] = id_token + if userinfo is not None: + extra_data["userinfo"] = userinfo + account = SocialAccount(provider="oidc", uid=sub, extra_data=extra_data) + sociallogin = SocialLogin(user=User(), account=account) + data = merged_claims(sociallogin) + adapter = CodexSocialAccountAdapter() + request = RequestFactory().get("/sso/oidc/login/callback/") + adapter.populate_user(request, sociallogin, {"email": data.get("email")}) + return sociallogin + + +def _request(rf: RequestFactory): + """Build a request with working session and messages, like the callback's.""" + request = rf.get("/sso/oidc/login/callback/") + SessionMiddleware(lambda _: None).process_request(request) # pyright: ignore[reportArgumentType], # ty: ignore[invalid-argument-type] + request.session.save() + MessageMiddleware(lambda _: None).process_request(request) # pyright: ignore[reportArgumentType], # ty: ignore[invalid-argument-type] + request.user = AnonymousUser() + return request + + +def _complete_social_login(request, sociallogin: SocialLogin): + """Run the real login/signup flow inside allauth's request context.""" + with context.request_context(request): + response = complete_social_login(request, sociallogin) + # Every Codex path ends in a redirect; narrows the Optional for + # the type checkers as a bonus. + assert response is not None + return response + + +class OIDCDisabledTests(TestCase): + """Every OIDC path must 404 when the feature is off (the default).""" + + def test_init_endpoint_404(self) -> None: + response = self.client.get("/api/v4/auth/oidc/login") + assert response.status_code == _HTTP_NOT_FOUND + + def test_allauth_login_path_404(self) -> None: + """Allauth's own view 404s: no SocialApp exists when APPS is empty.""" + response = self.client.get("/sso/oidc/login/") + assert response.status_code == _HTTP_NOT_FOUND + + def test_allauth_callback_path_404(self) -> None: + response = self.client.get("/sso/oidc/login/callback/") + assert response.status_code == _HTTP_NOT_FOUND + + +class OIDCInitTests(TestCase): + """The branded init endpoint redirects into allauth when enabled.""" + + def test_init_redirects_to_allauth_login(self) -> None: + _seed_oidc() + response = self.client.get("/api/v4/auth/oidc/login") + assert response.status_code == _HTTP_FOUND + assert response["Location"] == "/sso/oidc/login/" + + +class OIDCProvisionTests(TestCase): + """User provisioning and username mapping through the real signup flow.""" + + @override + def setUp(self) -> None: + _seed_oidc() + self.rf = RequestFactory() # pyright: ignore[reportUninitializedInstanceVariable] + + def _complete(self, sociallogin: SocialLogin): + return _complete_social_login(_request(self.rf), sociallogin) + + def test_creates_user_from_preferred_username(self) -> None: + sociallogin = _sociallogin( + id_token={"sub": _SUB}, + userinfo={"preferred_username": "aj", "email": "aj@example.com"}, + ) + response = self._complete(sociallogin) + assert response.status_code == _HTTP_FOUND + user = User.objects.get(username="aj") + assert user.email == "aj@example.com" + assert not user.has_usable_password() + assert SocialAccount.objects.get(user=user).uid == _SUB + + def test_username_falls_back_to_email_then_sub(self) -> None: + sociallogin = _sociallogin( + id_token={"sub": _SUB}, userinfo={"email": "fallback@example.com"} + ) + self._complete(sociallogin) + assert User.objects.filter(username="fallback@example.com").exists() + + sociallogin = _sociallogin(id_token={"sub": "sub-bare"}, sub="sub-bare") + self._complete(sociallogin) + assert User.objects.filter(username="sub-bare").exists() + + def test_claims_merged_id_token_and_userinfo(self) -> None: + """A claim present only in the ID token (Authentik groups) is seen.""" + sociallogin = _sociallogin( + id_token={"sub": _SUB, "groups": ["readers"]}, + userinfo={"preferred_username": "aj"}, + ) + claims = merged_claims(sociallogin) + assert claims["groups"] == ["readers"] + assert claims["preferred_username"] == "aj" + + def test_create_users_off_redirects_to_error(self) -> None: + _seed_oidc(create_users=False) + sociallogin = _sociallogin( + id_token={"sub": _SUB}, userinfo={"preferred_username": "nobody"} + ) + response = self._complete(sociallogin) + assert response.status_code == _HTTP_FOUND + assert response["Location"] == f"{_SSO_ERROR}?error=signup_disabled" + assert not User.objects.filter(username="nobody").exists() + + +class OIDCLinkingTests(TestCase): + """Account-linking policy: username always, superusers included.""" + + @override + def setUp(self) -> None: + _seed_oidc() + self.rf = RequestFactory() # pyright: ignore[reportUninitializedInstanceVariable] + + def _complete(self, sociallogin: SocialLogin): + return _complete_social_login(_request(self.rf), sociallogin) + + def test_links_existing_user_by_username(self) -> None: + local = User.objects.create_user("aj", "old@example.com") + sociallogin = _sociallogin( + id_token={"sub": _SUB}, userinfo={"preferred_username": "AJ"} + ) + response = self._complete(sociallogin) + assert response.status_code == _HTTP_FOUND + account = SocialAccount.objects.get(uid=_SUB) + assert account.user == local + assert User.objects.count() == 1 + + def test_links_superuser_by_username(self) -> None: + """Chosen behavior, not an accident: superusers auto-link too.""" + admin = User.objects.create_superuser("admin", "", "admin") + sociallogin = _sociallogin( + id_token={"sub": _SUB}, userinfo={"preferred_username": "admin"} + ) + self._complete(sociallogin) + assert SocialAccount.objects.get(uid=_SUB).user == admin + + def test_different_sub_does_not_hijack_linked_user(self) -> None: + """Same preferred_username, different sub: new user, no hijack.""" + local = User.objects.create_user("aj") + SocialAccount.objects.create(user=local, provider="oidc", uid=_SUB) + sociallogin = _sociallogin( + id_token={"sub": "sub-other"}, + userinfo={"preferred_username": "aj"}, + sub="sub-other", + ) + self._complete(sociallogin) + other = SocialAccount.objects.get(uid="sub-other").user + assert other != local + # Collision resolved with a sub-hash suffix, keeping the identity. + assert other.username.startswith("aj-") + + def test_email_conflict_redirects_when_link_by_email_off(self) -> None: + User.objects.create_user("existing", "aj@example.com") + sociallogin = _sociallogin( + id_token={"sub": _SUB}, + userinfo={"preferred_username": "newname", "email": "aj@example.com"}, + ) + response = self._complete(sociallogin) + assert response.status_code == _HTTP_FOUND + assert response["Location"] == f"{_SSO_ERROR}?error=email_exists" + assert not User.objects.filter(username="newname").exists() + + def test_links_by_email_when_enabled(self) -> None: + local = User.objects.create_user("existing", "aj@example.com") + _seed_oidc(link_by_email=True) + sociallogin = _sociallogin( + id_token={"sub": _SUB}, + userinfo={"preferred_username": "newname", "email": "AJ@example.com"}, + ) + response = self._complete(sociallogin) + assert response.status_code == _HTTP_FOUND + assert SocialAccount.objects.get(uid=_SUB).user == local + + +class OIDCGroupSyncTests(TestCase): + """Group and admin mapping from the groups claim.""" + + @override + def setUp(self) -> None: + _seed_oidc(sync_groups=True) + self.rf = RequestFactory() # pyright: ignore[reportUninitializedInstanceVariable] + self.readers = Group.objects.create(name="readers") # pyright: ignore[reportUninitializedInstanceVariable] + self.user = User.objects.create_user("aj") # pyright: ignore[reportUninitializedInstanceVariable] + SocialAccount.objects.create(user=self.user, provider="oidc", uid=_SUB) + + def _login_with_groups(self, groups) -> None: + sociallogin = _sociallogin( + id_token={"sub": _SUB, "groups": groups}, + userinfo={"preferred_username": "aj"}, + ) + _complete_social_login(_request(self.rf), sociallogin) + self.user.refresh_from_db() + + def test_sync_sets_existing_groups_only(self) -> None: + self._login_with_groups(["readers", "not-a-codex-group"]) + assert list(self.user.groups.all()) == [self.readers] + assert not Group.objects.filter(name="not-a-codex-group").exists() + + def test_sync_removes_dropped_groups(self) -> None: + self.user.groups.add(self.readers) + self._login_with_groups([]) + assert not self.user.groups.exists() + + def test_absent_groups_claim_is_noop(self) -> None: + self.user.groups.add(self.readers) + sociallogin = _sociallogin( + id_token={"sub": _SUB}, userinfo={"preferred_username": "aj"} + ) + _complete_social_login(_request(self.rf), sociallogin) + self.user.refresh_from_db() + assert list(self.user.groups.all()) == [self.readers] + + def test_admin_group_grants_and_revokes(self) -> None: + _seed_oidc(sync_groups=True, admin_group="codex-admins") + self._login_with_groups(["codex-admins"]) + assert self.user.is_staff + assert self.user.is_superuser + self._login_with_groups(["readers"]) + assert not self.user.is_staff + assert not self.user.is_superuser + + +class OIDCSessionFlagTests(TestCase): + """The /session payload advertises OIDC state to the SPA.""" + + @override + def setUp(self) -> None: + """Seed the AdminFlag + Timestamp rows /session requires.""" + from codex.startup import init_admin_flags, init_timestamps + + init_admin_flags() + init_timestamps() + + def test_sso_error_route_resolves_to_spa_index(self) -> None: + """ + ``/auth/sso-error`` serves the SPA, not the catch-all redirect. + + The adapter's error redirects land here; without the explicit app + urlconf entry the catch-all would 302 the error page to home. + """ + match = resolve("/auth/sso-error") + assert match.view_name == "app:sso-error" + + def test_disabled_flags_logged_out(self) -> None: + data = self.client.get("/api/v4/session").json()["data"] + flags = data["adminFlags"] + assert flags["oidcEnabled"] is False + assert "oidcProviderName" not in flags + assert "oidcLoginUrl" not in flags + assert "oidcLogoutUrl" not in flags + + def test_enabled_flags_are_public_and_prefixed(self) -> None: + _seed_oidc(provider_name="Authentik") + data = self.client.get("/api/v4/session").json()["data"] + flags = data["adminFlags"] + assert flags["oidcEnabled"] is True + assert flags["oidcProviderName"] == "Authentik" + assert flags["oidcLoginUrl"] == ( + f"{GRANIAN_URL_PATH_PREFIX}/api/v4/auth/oidc/login" + ) + # Logged out: no logout URL even when rp logout is configured. + assert "oidcLogoutUrl" not in flags + + +class OIDCLogoutURLTests(TestCase): + """RP-initiated logout URL construction from the discovery document.""" + + @override + def setUp(self) -> None: + cache.delete(DISCOVERY_CACHE_KEY) + self.rf = RequestFactory() # pyright: ignore[reportUninitializedInstanceVariable] + + def _logout_url(self, discovery: dict) -> str: + _seed_oidc(rp_initiated_logout=True) + request = self.rf.get("/api/v4/session") + with patch("codex.oidc._discovery_document", return_value=discovery): + return oidc_logout_url(request) + + def test_builds_end_session_url(self) -> None: + url = self._logout_url( + {"end_session_endpoint": "https://idp.example.com/end-session/"} + ) + assert url.startswith("https://idp.example.com/end-session/?") + assert "client_id=codex-test" in url + assert "post_logout_redirect_uri=" in url + + def test_empty_when_provider_has_no_end_session(self) -> None: + """Authelia's discovery document omits end_session_endpoint.""" + assert self._logout_url({}) == "" + + def test_empty_when_disabled(self) -> None: + request = self.rf.get("/api/v4/session") + assert oidc_logout_url(request) == "" + + +class OIDCUsernameLockTests(TestCase): + """Linked accounts get a read-only username in profile serializers.""" + + def _username_read_only(self, user) -> bool: + request = RequestFactory().get("/api/v4/auth/profile") + request.user = user + serializer = ProfileUpdateSerializer(context={"request": request}) + return serializer.fields["username"].read_only + + def test_locked_when_oidc_linked(self) -> None: + user = User.objects.create_user("aj") + SocialAccount.objects.create(user=user, provider="oidc", uid=_SUB) + assert self._username_read_only(user) is True + + def test_unlocked_for_local_user(self) -> None: + user = User.objects.create_user("aj") + assert self._username_read_only(user) is False + + +class OIDCSchemaTests(TestCase): + """allauth's plain Django views must not leak into the API schema.""" + + def test_allauth_views_absent_from_schema(self) -> None: + admin = User.objects.create_superuser("admin", "", "admin") + self.client.force_login(admin) + response = self.client.get("/api/v4/schema") + assert response.status_code == _HTTP_OK + paths = response.data["paths"] # pyright: ignore[reportAttributeAccessIssue], # ty: ignore[unresolved-attribute] + sso_paths = [path for path in paths if "/sso/" in path] + assert sso_paths == [] + # The DRF init endpoint is a documented part of the API. + assert any(path.endswith("/auth/oidc/login") for path in paths) + + +class OIDCErrorTests(TestCase): + """Authentication errors land on the SPA error page, never a template.""" + + def _error_location(self, error) -> str: + adapter = CodexSocialAccountAdapter() + request = RequestFactory().get("/sso/oidc/login/callback/") + try: + adapter.on_authentication_error(request, provider=None, error=error) + except ImmediateHttpResponse as exc: + return exc.response["Location"] + msg = "on_authentication_error did not raise" + raise AssertionError(msg) + + def test_known_error_passes_through(self) -> None: + assert self._error_location("access_denied") == ( + f"{_SSO_ERROR}?error=access_denied" + ) + + def test_unknown_error_collapses_to_server_error(self) -> None: + """Never reflect arbitrary provider input into the redirect.""" + assert self._error_location(" diff --git a/frontend/tests/unit/stats-tab.test.js b/frontend/tests/unit/stats-tab.test.js new file mode 100644 index 000000000..79f2434f8 --- /dev/null +++ b/frontend/tests/unit/stats-tab.test.js @@ -0,0 +1,178 @@ +/* + * Tests for the Admin Stats tab. + * + * The tab is what an administrator sees of the anonymous stats report, so + * behavior locked in here: + * - Every section the API returns is rendered under a titled table. + * - Toggle booleans read as Yes/No, and "have you configured this" + * booleans read as Set/Not set, so nobody mistakes one for the other. + * - The API key is never rendered, even though the payload carries it. + */ +import { createTestingPinia } from "@pinia/testing"; +import { mount } from "@vue/test-utils"; +import { describe, expect, test } from "vitest"; + +import StatsTab from "@/components/admin/tabs/stats-tab.vue"; +import vuetify from "@/plugins/vuetify"; + +const STATS = { + platform: { + docker: false, + machine: "arm64", + cores: 10, + system: { name: "Darwin", release: "25.5.0" }, + pythonVersion: "3.14.4", + codexVersion: "2.2.3", + }, + config: { + libraryCount: 2, + sessionCount: 1, + userAnonymousCount: 0, + userRegisteredCount: 2, + authGroupCount: 0, + libraryReadOnlyCount: 1, + libraryPollCount: 2, + libraryEventsCount: 2, + libraryGroupAclCount: 0, + customCoverCount: 3, + customCoverUploadedCount: 2, + customCoverDirCount: 1, + failedImportCount: 0, + apiKey: "s3cr3t-api-key", + }, + sessions: { + topCollection: { publishers: 2 }, + orderBy: { sort_name: 2 }, + dynamicCovers: { true: 2 }, + finishOnLastPage: { true: 1 }, + fitTo: { W: 1 }, + readingDirection: { ltr: 1 }, + viewMode: { cover: 2, table: 1 }, + tableCoverSize: { sm: 1 }, + customCovers: { true: 2 }, + multiSortCount: 0, + }, + collections: { + publisherCount: 17, + imprintCount: 20, + seriesCount: 56, + volumeCount: 60, + issueCount: 154, + folderCount: 70, + storyArcCount: 23, + }, + fileTypes: { cbz: 100, cbr: 50, cb7: 2, pdf: 3, unknown: 1 }, + metadata: { characterCount: 609, storyCount: 151 }, + usage: { + bookmarkCount: 1, + favoriteCount: 4, + favoriteUserCount: 1, + favorites: { comics: 4 }, + }, + identifiers: { "metron:comic": 85, "comicvine:comic": 126 }, + adminFlags: { + autoUpdate: false, + folderView: true, + sendTelemetry: true, + apiKeySet: true, + bannerTextSet: false, + browserDefaultCollection: "publishers", + browserMaxObjPerPage: 100, + ageRatingDefault: "Everyone", + }, + tagging: { + defaultMatchMode: "auto", + mergeAllSources: false, + defaultSources: { metron: 1, comicvine: 1 }, + hasMetronCredentials: true, + hasComicvineCredentials: false, + metronUrlSet: false, + }, + auth: { + oidcEnabled: false, + oidcPkce: true, + oidcScopeCustom: false, + oidcTokenAuthMethod: "", + userAgeCeilingCount: 0, + }, + email: { smtpConfigured: false, smtpUseTls: true, smtpUseSsl: false }, + throttle: { throttleAnon: 0, throttleResetPassword: 5 }, + deployment: { + remoteUserAuth: false, + failedLoginLog: true, + urlPathPrefixSet: true, + }, +}; + +const SECTION_TITLES = [ + "Platform", + "Config", + "File Types", + "User Settings", + "Browser Collections", + "Tags", + "Reading", + "Identifiers", + "Admin Flags", + "Online Tagging", + "Authentication", + "Email", + "Rate Limits", + "Deployment", +]; + +function mountTab(stats = STATS) { + const pinia = createTestingPinia({ initialState: { admin: { stats } } }); + return mount(StatsTab, { + global: { plugins: [pinia, vuetify] }, + }); +} + +describe("AdminStatsTab", () => { + test("renders a table for every stats section", () => { + const text = mountTab().text(); + for (const title of SECTION_TITLES) { + expect(text).toContain(title); + } + }); + + test("never renders the api key", () => { + const text = mountTab().text(); + expect(text).not.toContain("s3cr3t-api-key"); + // Its presence is still reported, without the value. + expect(text).toContain("API Key"); + }); + + test("toggles read as Yes/No", () => { + const text = mountTab().text(); + expect(text).toContain("Folder View"); + expect(text).toContain("Yes"); + expect(text).toContain("No"); + }); + + test("configured-or-not booleans read as Set/Not set", () => { + const text = mountTab().text(); + expect(text).toContain("Set"); + expect(text).toContain("Not set"); + }); + + test("renders new v2 sections with readable labels", () => { + const text = mountTab().text(); + expect(text).toContain("Read Only"); + expect(text).toContain("Bookmarks"); + expect(text).toContain("Single Sign On"); + expect(text).toContain("Reverse Proxy Subpath"); + expect(text).toContain("Metron Credentials"); + }); + + test("renders identifier buckets as source and type", () => { + expect(mountTab().text()).toContain("Metron: Comic"); + }); + + test("survives a params-filtered response with sections missing", () => { + // /admin/stats?platform=... returns only the requested sections. + const wrapper = mountTab({ platform: STATS.platform }); + expect(wrapper.text()).toContain("arm64"); + expect(wrapper.text()).not.toContain("Bookmarks"); + }); +}); diff --git a/pyproject.toml b/pyproject.toml index eb3ed0f5a..48329a513 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "adrf~=0.1.12", "bidict~=0.23", "channels~=4.2", - "comicbox[pdf]~=4.5.1", + "comicbox[pdf]~=4.6.0", "cryptography>=48.0.0", "dateparser~=1.2", "django-allauth[socialaccount]~=65.13", @@ -49,7 +49,7 @@ readme = "README.md" requires-python = ">=3.12" license = "GPL-3.0-only" name = "codex" -version = "2.2.2" +version = "2.2.3" [[project.authors]] name = "AJ Slater" email = "aj@slater.net" diff --git a/tests/test_telemeter.py b/tests/test_telemeter.py new file mode 100644 index 000000000..76a7f0beb --- /dev/null +++ b/tests/test_telemeter.py @@ -0,0 +1,173 @@ +"""Tests for the anonymous stats payload and how it is transmitted.""" + +import json +from email.message import Message +from lzma import decompress +from typing import override +from unittest.mock import patch +from urllib.error import HTTPError +from urllib.parse import urlsplit + +import pytest +from django.http import QueryDict +from django.test import TestCase + +from codex.librarian.telemeter import telemeter +from codex.librarian.telemeter.stats import CodexStats +from codex.serializers.admin.stats import ( + AdminStatsRequestSerializer, + StatsSerializer, +) + +EXPECTED_SECTIONS = ( + "platform", + "config", + "sessions", + "collections", + "file_types", + "metadata", + "usage", + "identifiers", + "admin_flags", + "tagging", + "auth", + "email", + "throttle", + "deployment", +) + + +class _FakeResponse: + """Stand in for http.client.HTTPResponse.""" + + def __init__(self, status: int = 200) -> None: + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *_args) -> bool: + return False + + +class TelemeterTransportTestCase(TestCase): + """The url and headers the telemeter actually puts on the wire.""" + + def test_posts_to_wire_version_two(self) -> None: + """Codex 2.2.3 and later report the expanded payload as version 2.""" + assert telemeter._VERSION == "2" # noqa: SLF001 + assert telemeter._POST.endswith("/stats/codex/2") # noqa: SLF001 + + def test_credentials_are_not_in_the_url(self) -> None: + """ + Userinfo in the url makes http.client read the password as a port. + + That raised InvalidURL on every send, silently, for months. + """ + netloc = urlsplit(telemeter._POST).netloc # noqa: SLF001 + assert "@" not in netloc + assert netloc + + def test_credentials_move_to_an_authorization_header(self) -> None: + """The credentials taken out of the url are still sent.""" + headers = telemeter._new_headers() # noqa: SLF001 + assert headers["Content-Type"] == "application/xz" + assert headers.get("Authorization", "").startswith("Basic ") + + def test_posts_compressed_json(self) -> None: + """The body is the lzma-compressed payload, and the url is reachable.""" + payload = {"uuid": "abc", "stats": {"platform": {"codex_version": "2.2.3"}}} + with patch.object(telemeter, "urlopen") as mock_urlopen: + mock_urlopen.return_value = _FakeResponse() + telemeter._post_stats(payload) # noqa: SLF001 + + request = mock_urlopen.call_args.args[0] + assert request.get_method() == "POST" + assert request.host == urlsplit(telemeter._POST).netloc # noqa: SLF001 + assert json.loads(decompress(request.data).decode()) == payload + + def test_http_error_reaches_the_caller(self) -> None: + """A rejected post must not look like a successful one.""" + error = HTTPError(telemeter._POST, 500, "boom", Message(), None) # noqa: SLF001 + with ( + patch.object(telemeter, "urlopen", side_effect=error), + pytest.raises(HTTPError), + ): + telemeter._post_stats({"uuid": "abc"}) # noqa: SLF001 + + def test_send_telemetry_survives_a_failed_post(self) -> None: + """A dead stats server must never break the install.""" + logged = [] + + class _Log: + @staticmethod + def warning(msg) -> None: + logged.append(msg) + + error = HTTPError(telemeter._POST, 500, "boom", Message(), None) # noqa: SLF001 + with patch.object(telemeter, "urlopen", side_effect=error): + telemeter.send_telemetry(_Log()) + assert logged + + +class TelemeterStatsTestCase(TestCase): + """The shape of the payload the telemeter builds.""" + + @override + def setUp(self) -> None: + from codex.startup import init_admin_flags, init_timestamps + + init_admin_flags() + init_timestamps() + + def test_every_section_present(self) -> None: + """A full payload carries every section, in payload order.""" + stats = CodexStats().get() + assert tuple(stats) == EXPECTED_SECTIONS + + def test_serializer_reports_every_payload_field(self) -> None: + """ + The admin Stats tab is the visible contract for what is collected. + + Anything the telemeter sends must also be rendered there, or the page + understates what leaves the install. + """ + stats = CodexStats().get() + data = StatsSerializer(stats).data + assert set(stats) == set(data) + for section, values in stats.items(): + if not isinstance(values, dict) or not isinstance(data[section], dict): + continue + assert not set(values) - set(data[section]), ( + f"{section} fields missing from StatsSerializer" + ) + + def test_params_select_sections(self) -> None: + """The admin endpoint can ask for a subset.""" + stats = CodexStats({"platform": {}, "throttle": {}}).get() + assert set(stats) == {"platform", "throttle"} + + def test_default_admin_request_returns_every_section(self) -> None: + """ + A section missing from the request serializer vanishes from the tab. + + AdminStatsRequestSerializer fills in every declared section, so params + is always truthy and the section gate drops anything undeclared. Adding + a section to StatsSerializer without adding it here renders an empty + table with no error, which is how the identifiers section was lost. + """ + request_serializer = AdminStatsRequestSerializer(data=QueryDict("ts=1")) + assert request_serializer.is_valid(), request_serializer.errors + params = dict(request_serializer.validated_data) + assert tuple(CodexStats(params).get()) == EXPECTED_SECTIONS + + def test_new_sections_have_content(self) -> None: + """The seeded singletons and flags produce real values.""" + stats = CodexStats().get() + assert stats["admin_flags"]["send_telemetry"] in (True, False) + assert "url_path_prefix_set" in stats["deployment"] + assert "throttle_anon" in stats["throttle"] + assert "bookmark_count" in stats["usage"] + assert "library_read_only_count" in stats["config"] + assert "multi_sort_count" in stats["sessions"] + assert "comic_community_rating_count" in stats["metadata"] diff --git a/tests/test_telemeter_privacy.py b/tests/test_telemeter_privacy.py new file mode 100644 index 000000000..6a775b696 --- /dev/null +++ b/tests/test_telemeter_privacy.py @@ -0,0 +1,281 @@ +""" +The anonymous stats payload must never carry private information. + +Seeds a sentinel into every admin-authored string codex stores -- credentials, +urls, claim names, group names, banner text, api key, library paths -- and +asserts none of them reach the wire. Then walks the payload and requires every +leaf to be a number, a boolean, or a string drawn from a closed vocabulary. + +If a new stat legitimately needs a new kind of string, add its vocabulary to +``_vocabularies`` deliberately. Do not widen the walk. +""" + +import json +from typing import Final, override +from uuid import uuid4 + +from django.test import TestCase + +from codex.choices.admin import AdminFlagChoices +from codex.collection import Collection +from codex.librarian.telemeter.stats import CodexStats +from codex.models.admin import ( + AdminFlag, + ComicboxTaggingDefaults, + EmailSettings, + OIDCSettings, +) +from codex.models.identifier import Identifier, IdentifierSource, IdentifierType +from codex.models.library import Library + +# Anything containing this must never appear in the payload. +SENTINEL: Final = "XXSENTINELXX" + +# Keys whose values are free-form by nature and safe: version strings and the +# platform description, which carry no user or install identity. +_PLATFORM_KEYS: Final = frozenset( + {"machine", "name", "release", "python_version", "codex_version"} +) + +# The only payload dicts whose *keys* come from data rather than from code. +# Everywhere else a key is a field name chosen in codex's source, so checking +# it proves nothing. These are the ones that must stay closed-vocabulary. +_BUCKET_PATHS: Final = frozenset( + { + "stats.file_types", + "stats.identifiers", + "stats.usage.favorites", + "stats.tagging.default_sources", + "stats.sessions.top_collection", + "stats.sessions.order_by", + "stats.sessions.dynamic_covers", + "stats.sessions.view_mode", + "stats.sessions.table_cover_size", + "stats.sessions.custom_covers", + "stats.sessions.finish_on_last_page", + "stats.sessions.fit_to", + "stats.sessions.reading_direction", + } +) + + +def _vocabularies() -> frozenset[str]: + """Every closed vocabulary a payload string may be drawn from.""" + from codex.choices.browser import ( + BROWSER_ORDER_BY_CHOICES, + BROWSER_TABLE_COVER_SIZE_CHOICES, + BROWSER_VIEW_MODE_CHOICES, + ) + from codex.choices.reader import READER_CHOICES + from codex.models.age_rating import AgeRatingMetron + from codex.models.choices import FileTypeChoices + + values: set[str] = {"", "other", "true", "false", "unknown"} + values |= {member.value for member in Collection} + values |= {member.value.lower() for member in FileTypeChoices} + values |= {member.value for member in IdentifierType} + values |= set(BROWSER_ORDER_BY_CHOICES) + values |= set(BROWSER_VIEW_MODE_CHOICES) + values |= set(BROWSER_TABLE_COVER_SIZE_CHOICES) + for reader_choices in READER_CHOICES.values(): + values |= {value.lower() for value in reader_choices} + values |= {mode.value for mode in ComicboxTaggingDefaults.MatchModeChoices} + values |= {mode.value for mode in ComicboxTaggingDefaults.PromptsModeChoices} + values |= set(AgeRatingMetron.objects.values_list("name", flat=True)) + # Identifier buckets are ":", both closed vocabularies. + from comicbox.enums.maps.identifiers import ID_SOURCE_NAME_MAP + + sources = {source.value for source in ID_SOURCE_NAME_MAP} | {"other"} + kinds = {member.value for member in IdentifierType} | {"other"} + values |= {f"{source}:{kind}" for source in sources for kind in kinds} + # comicbox online source names, used as tagging bucket keys. + from comicbox.formats.base.online import SOURCE_NAMES + + values |= set(SOURCE_NAMES) + # OIDC client authentication methods. + values |= { + "client_secret_basic", + "client_secret_post", + "client_secret_jwt", + "private_key_jwt", + "none", + } + return frozenset(values) + + +class TelemeterPrivacyTestCase(TestCase): + """Assert the stats payload leaks nothing an administrator typed.""" + + @override + def setUp(self) -> None: + from codex.startup import init_admin_flags, init_timestamps + + init_admin_flags() + init_timestamps() + self._seed_secrets() + + @staticmethod + def _set_singleton(model, **fields) -> None: + """Update the singleton row, which migrations may already have made.""" + row = model.objects.first() or model() + for name, value in fields.items(): + setattr(row, name, value) + row.save() + + @classmethod + def _seed_secrets(cls) -> None: + """Put a sentinel in every admin-authored string codex stores.""" + cls._set_singleton( + ComicboxTaggingDefaults, + metron_user=f"{SENTINEL}-metron-user", + metron_password=f"{SENTINEL}-metron-password", + metron_url=f"https://{SENTINEL}-metron.example.com", + comicvine_key=f"{SENTINEL}-comicvine-key", + comicvine_url=f"https://{SENTINEL}-comicvine.example.com", + default_sources=["metron", f"{SENTINEL}-source"], + ) + cls._set_singleton( + OIDCSettings, + enabled=True, + provider_name=f"{SENTINEL}-provider", + server_url=f"https://{SENTINEL}-idp.example.com", + client_id=f"{SENTINEL}-client-id", + client_secret=f"{SENTINEL}-client-secret", + scope=f"openid {SENTINEL}-scope", + username_claim=f"{SENTINEL}-claim", + groups_claim=f"{SENTINEL}-groups", + admin_group=f"{SENTINEL}-admins", + token_auth_method=f"{SENTINEL}-auth-method", + ) + cls._set_singleton( + EmailSettings, + host=f"smtp.{SENTINEL}.example.com", + user=f"{SENTINEL}-smtp-user", + password=f"{SENTINEL}-smtp-password", + from_address=f"{SENTINEL}@example.com", + subject_prefix=f"[{SENTINEL}] ", + ) + AdminFlag.objects.filter(key=AdminFlagChoices.BANNER_TEXT.value).update( + value=f"{SENTINEL}-banner" + ) + AdminFlag.objects.filter(key=AdminFlagChoices.API_KEY.value).update( + value=f"{SENTINEL}-api-key" + ) + Library.objects.create(path=f"/{SENTINEL}-library", read_only=True) + source = IdentifierSource.objects.create(name=f"{SENTINEL}-source") + Identifier.objects.create( + source=source, + id_type=IdentifierType.ISSUE.value, + key=f"{SENTINEL}-id", + url=f"https://{SENTINEL}.example.com/1", + ) + + @staticmethod + def _walk(node, path: str, leaves: list[tuple[str, object]]) -> None: + """Collect every value, plus the keys of data-derived count buckets.""" + if isinstance(node, dict): + in_bucket = path in _BUCKET_PATHS + for key, value in node.items(): + if in_bucket: + leaves.append((f"{path}[key]", key)) + TelemeterPrivacyTestCase._walk(value, f"{path}.{key}", leaves) + elif isinstance(node, list): + for index, value in enumerate(node): + TelemeterPrivacyTestCase._walk(value, f"{path}[{index}]", leaves) + else: + leaves.append((path, node)) + + def test_no_sentinel_in_payload(self) -> None: + """No admin-authored string reaches the serialized payload.""" + payload = json.dumps( + {"stats": CodexStats().get(), "uuid": str(uuid4())}, default=str + ) + assert SENTINEL not in payload + + def test_unknown_identifier_source_collapsed(self) -> None: + """A source name from a comic file is replaced by "other".""" + identifiers = CodexStats().get()["identifiers"] + assert identifiers + for key in identifiers: + assert SENTINEL not in key + assert f"other:{IdentifierType.ISSUE.value}" in identifiers + + def test_every_leaf_is_a_number_bool_or_closed_vocabulary(self) -> None: + """Walk the payload; no value may be an open-ended string.""" + vocabularies = {value.lower() for value in _vocabularies()} + leaves: list[tuple[str, object]] = [] + self._walk(CodexStats().get(), "stats", leaves) + assert leaves + + unexpected = [] + for path, value in leaves: + if isinstance(value, bool | int | float) or value is None: + continue + if not isinstance(value, str): + unexpected.append((path, value)) + continue + if path.rsplit(".", 1)[-1] in _PLATFORM_KEYS: + continue + if value.lower() not in vocabularies: + unexpected.append((path, value)) + assert not unexpected, f"open-ended strings in payload: {unexpected}" + + @staticmethod + def _declared_field_names() -> frozenset[str]: + """Every key name StatsSerializer declares, at any nesting depth.""" + from rest_framework.serializers import Serializer + + from codex.serializers.admin.stats import StatsSerializer + + names: set[str] = set() + + def walk(serializer) -> None: + for name, field in serializer.get_fields().items(): + names.add(name) + if isinstance(field, Serializer): + walk(field) + + walk(StatsSerializer()) + return frozenset(names) + + @classmethod + def _undeclared_keys(cls, node, path: str, declared, found: list) -> None: + """Collect dict keys that are neither declared fields nor bucket keys.""" + if not isinstance(node, dict): + return + if path in _BUCKET_PATHS: + # Registered bucket: its keys are data, and the vocabulary walk + # checks them. + return + for key, value in node.items(): + if key not in declared: + found.append(f"{path}.{key}") + cls._undeclared_keys(value, f"{path}.{key}", declared, found) + + def test_no_unregistered_data_keyed_bucket(self) -> None: + """ + A count bucket missing from _BUCKET_PATHS is checked by nothing. + + Its keys never become leaf values, so the vocabulary walk skips them, + and the sentinel test only seeds admin config -- not publisher, series + or user rows. So instead of trusting _BUCKET_PATHS to describe itself, + require every key in the payload to be either a field name declared in + StatsSerializer or a key inside a registered bucket. A new bucket of + library-sourced names satisfies neither. + """ + declared = self._declared_field_names() + found: list[str] = [] + self._undeclared_keys(CodexStats().get(), "stats", declared, found) + assert not found, ( + "payload keys that are neither declared serializer fields nor " + f"registered bucket keys: {sorted(found)}" + ) + + def test_the_bucket_check_catches_an_unregistered_bucket(self) -> None: + """Prove the check above can actually fail.""" + payload = CodexStats().get() + payload["usage"]["top_publishers"] = {"Marvel Comics": 10} + found: list[str] = [] + self._undeclared_keys(payload, "stats", self._declared_field_names(), found) + assert "stats.usage.top_publishers" in found + assert "stats.usage.top_publishers.Marvel Comics" in found diff --git a/uv.lock b/uv.lock index cda057d5c..310dfda23 100644 --- a/uv.lock +++ b/uv.lock @@ -567,7 +567,7 @@ wheels = [ [[package]] name = "codex" -version = "2.2.2" +version = "2.2.3" source = { editable = "." } dependencies = [ { name = "adrf" }, @@ -653,7 +653,7 @@ requires-dist = [ { name = "adrf", specifier = "~=0.1.12" }, { name = "bidict", specifier = "~=0.23" }, { name = "channels", specifier = "~=4.2" }, - { name = "comicbox", extras = ["pdf"], specifier = "~=4.5.1" }, + { name = "comicbox", extras = ["pdf"], specifier = "~=4.6.0" }, { name = "cryptography", specifier = ">=48.0.0" }, { name = "dateparser", specifier = "~=1.2" }, { name = "django", specifier = "~=6.0" }, @@ -739,7 +739,7 @@ wheels = [ [[package]] name = "comicbox" -version = "4.5.1" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bidict" }, @@ -776,9 +776,9 @@ dependencies = [ { name = "xmltodict" }, { name = "zipremove" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/55/0a69c9e807b7c18b39cf4aa3da8280ef9dddb5b64377381999bca6490a1e/comicbox-4.5.1.tar.gz", hash = "sha256:e90c58dfbe9e234fe89c8ad2684ff5414f5045439eca2bcbe771e6b80e9770ff", size = 1009771, upload-time = "2026-07-24T20:15:28.57Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/99/df7bde0961d78e5ca8f5a5a06442cf11c44de180e839c799aa93478aa74a/comicbox-4.6.0.tar.gz", hash = "sha256:deceb8baaa382cb736dd9501aa2e6fec53024fa1c5b4a973e6d401a4e1ff65a2", size = 1013136, upload-time = "2026-07-26T00:41:14.701Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/d0/e14bf8b03fbc139c75855996fa9e7f28b9291beb4ed79f58bba40079e7bd/comicbox-4.5.1-py3-none-any.whl", hash = "sha256:e7d188e0520330eae76298b3f3cfa1a43fa164dd22ea9fb771e1597dd3f6503c", size = 340303, upload-time = "2026-07-24T20:15:26.84Z" }, + { url = "https://files.pythonhosted.org/packages/43/d8/199aa718d77f0eaaf71f7ef94c38eccfbc34cde420e1bb5a4064b507fb4b/comicbox-4.6.0-py3-none-any.whl", hash = "sha256:2298e6ff2fd39b5e814825405e80edd4ccff9f461bfcf4844f20f201f9c34c60", size = 341933, upload-time = "2026-07-26T00:41:13.182Z" }, ] [package.optional-dependencies] From 9b102cda39ea1f486d0e6ffa3a81b2863d6353f0 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sat, 25 Jul 2026 18:01:02 -0700 Subject: [PATCH 44/66] update deps --- frontend/bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/bun.lock b/frontend/bun.lock index d439d65ed..cc4b7b983 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -29,7 +29,7 @@ "@vue/typescript-plugin": "^3.3.8", "happy-dom": "^20.11.1", "rollup-plugin-visualizer": "^7.0.1", - "sass": "^1.101.7", + "sass": "^1.102.0", "sass-loader": "^17.0.0", "toml": "^5.0.0", "typeface-roboto": "^1.1.13", From a92236d7a5fda94d3a757eeb43b2d26464f15714 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sun, 26 Jul 2026 01:08:44 -0700 Subject: [PATCH 45/66] Support comicbox 4.6.0 series alternative names comicbox 4.6.0 records a series' localized and variant titles as reprints: from MetronInfo AlternativeNames and Reprints tags, and now from Metron and Comic Vine online tagging. Codex dropped the field entirely -- reprints sat outside USED_COMICBOX_FIELDS, so the read config told comicbox to skip parsing it and the aggregator popped it as a backstop. Import them into a new denormalized Reprint model keyed on series name, volume number, issue and language. Series and Volume are browse collections, so reusing them would have hung phantom rows off the browser for every alternate title. A reprint may carry only a series sort_name, which stands in when the name is absent; one with neither is dropped. Alternate names reach the metadata panel, the tag editor, a sortable browser column and filter, and a distinct alternate_series FTS column, so a comic filed under a localized title is findable by it. The column is distinct rather than folded into series to keep the series: token exact; unqualified searches match either. Online matching now recognizes Comic Vine volume aliases, which codex inherits by delegating matching to comicbox. The match prompt shows each candidate's alternative names, since a comic filed under a localized title matching its canonical volume otherwise reads as a wrong match, and passes the chosen candidate's volume id, which was plumbed end to end but always null. Drop the auto_threshold setting. It was parsed, serialized and round-tripped through resume, but never reached OnlineSession, which accepts no threshold. Reconcile sidecar columns on open. schema.sql is all CREATE TABLE IF NOT EXISTS, so a sidecar predating a release never gained its new settings_filters columns and the next dump failed with "no such column". The wanted columns derive from the schema file rather than a list that would rot the same way. This also repairs the column the community_rating rename added. Already-imported comics pick alternate names up on a Force Update Tags; adding a field to the whitelist does not invalidate metadata_mtime. Co-Authored-By: Claude Fable 5 --- NEWS.md | 16 ++ codex/choices/browser.py | 15 +- codex/choices/search.py | 3 + codex/choices/tagging.py | 19 +- codex/librarian/onlinetag/session_manager.py | 1 - codex/librarian/onlinetag/session_state.py | 6 + codex/librarian/onlinetag/tasks.py | 1 - codex/librarian/scribe/importer/const.py | 29 ++- .../librarian/scribe/importer/create/const.py | 14 ++ codex/librarian/scribe/importer/link/const.py | 2 + .../librarian/scribe/importer/link/delete.py | 4 +- .../scribe/importer/read/many_to_many.py | 79 +++++- .../scribe/importer/search/prepare.py | 5 + codex/librarian/scribe/janitor/cleanup.py | 4 +- codex/librarian/scribe/search/const.py | 1 + codex/librarian/scribe/search/prepare.py | 1 + codex/librarian/scribe/search/sync.py | 1 + codex/librarian/scribe/tag_writer.py | 2 +- codex/migrations/0049_reprints.py | 202 +++++++++++++++ codex/models/comic.py | 16 +- codex/models/identifier.py | 2 +- codex/models/named.py | 59 ++++- codex/models/settings.py | 2 + codex/serializers/admin/stats.py | 1 + codex/serializers/admin/tagging.py | 1 - codex/serializers/browser/choices.py | 23 ++ codex/serializers/browser/filters.py | 1 + codex/serializers/models/comic.py | 2 + codex/serializers/models/named.py | 25 ++ codex/settings/__init__.py | 9 +- codex/user_data/identifiers.py | 13 + codex/user_data/restore.py | 14 +- codex/user_data/schema.sql | 1 + codex/user_data/serializers.py | 7 +- codex/user_data/store.py | 48 ++++ codex/views/admin/onlinetag.py | 5 +- codex/views/browser/choices.py | 9 +- codex/views/browser/columns.py | 36 ++- codex/views/browser/const.py | 1 + codex/views/browser/filters/filter.py | 1 + .../browser/filters/search/field/column.py | 1 + codex/views/browser/intersections.py | 120 +++++++-- codex/views/browser/metadata/const.py | 7 + codex/views/browser/saved_settings.py | 9 +- codex/views/const.py | 2 + .../browser/table/browser-table-cell.vue | 1 + .../table/browser-table-column-picker.vue | 1 + .../browser/toolbars/top/filter-sub-menu.vue | 3 + .../metadata/edit-mode/edit-panel.vue | 132 ++++++++++ .../components/online-tag/prompt-popup.vue | 26 +- frontend/src/stores/metadata.js | 15 +- .../tests/unit/browser-table-cell.test.js | 14 ++ .../unit/browser-table-column-picker.test.js | 6 +- .../tests/unit/edit-panel-reprints.test.js | 228 +++++++++++++++++ frontend/tests/unit/metadata-reprints.test.js | 88 +++++++ frontend/tests/unit/prompt-popup.test.js | 130 ++++++++++ tests/importer/test_basic.py | 71 +++++- tests/importer/test_move_timestamps.py | 21 +- tests/importer/test_update_all.py | 9 + tests/test_browser_reprints_column.py | 230 ++++++++++++++++++ tests/test_browser_table_response.py | 4 +- tests/test_metadata_reprints.py | 159 ++++++++++++ tests/test_onlinetag_resume_endpoints.py | 14 +- tests/test_reprints.py | 133 ++++++++++ tests/test_search_alternate_series.py | 133 ++++++++++ tests/test_user_data_restore.py | 37 +++ tests/test_user_data_store.py | 35 +++ 67 files changed, 2211 insertions(+), 99 deletions(-) create mode 100644 codex/migrations/0049_reprints.py create mode 100644 frontend/tests/unit/edit-panel-reprints.test.js create mode 100644 frontend/tests/unit/metadata-reprints.test.js create mode 100644 frontend/tests/unit/prompt-popup.test.js create mode 100644 tests/test_browser_reprints_column.py create mode 100644 tests/test_metadata_reprints.py create mode 100644 tests/test_reprints.py create mode 100644 tests/test_search_alternate_series.py diff --git a/NEWS.md b/NEWS.md index 10ccb9dcb..45dea6e1e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -8,6 +8,22 @@ border-radius: 128px; ## v2.2.3 +- Features + - Alternate series names: the localized and variant series titles comics + carry in MetronInfo AlternativeNames and Reprints tags (and that Metron + and Comic Vine online tagging now record) are imported, shown in the + metadata panel, editable in the tag editor, and browsable — sort, table + column, filter, and `alternate_series:` search (comicbox 4.6.0). Comics + imported or tagged online from now on pick them up automatically; + already-imported comics need a Force Update Tags. + - Online tagging matches a comic filed under a localized or variant series + title to the right volume, and the match prompt shows each candidate's + other known names (comicbox 4.6.0). + +- Fixes + - User data backups no longer skip every saved filter set after an upgrade + that adds a new filter. + ## v2.2.2 - Fixes diff --git a/codex/choices/browser.py b/codex/choices/browser.py index df216e949..d570642c8 100644 --- a/codex/choices/browser.py +++ b/codex/choices/browser.py @@ -18,6 +18,7 @@ { "created_at": "Added Time", "age_rating": "Age Rating", + "reprints": "Alternate Series", "characters": "Characters", "child_count": "Child Count", "community_rating": "Community Rating", @@ -528,6 +529,17 @@ def admin_default_route_for(top_collection: str) -> dict: "editable": False, "edit_widget": None, }, + "reprints": { + # Alternate & localized series names (comicbox ``reprints``). + # "Reprints" reads as reprint editions to users, so the + # column, order-by entry and filter all say "Alternate + # Series"; only the ORM key stays ``reprints``. + "label": "Alternate Series", + "sort_key": "reprints", + "m2m": True, + "editable": False, + "edit_widget": None, + }, "series_groups": { "label": "Series Groups", "sort_key": "series_groups", @@ -602,7 +614,7 @@ def admin_default_route_for(top_collection: str) -> dict: # shape). Display is one query for the whole page; sort scales # with the filtered collection count when the user clicks the header. # - ``high``: composite-M2M columns (``credits`` / ``identifiers`` -# / ``universes`` / ``story_arcs``). Display issues its own +# / ``reprints`` / ``universes`` / ``story_arcs``). Display issues its own # per-column query (composite display strings can't share the # simple-M2M union shape); sort runs a per-outer-row correlated # subquery that JOINs the bespoke composite expression. Both @@ -624,6 +636,7 @@ def admin_default_route_for(top_collection: str) -> dict: # Composite M2M — display + sort both per-column / per-row. "credits": "high", "identifiers": "high", + "reprints": "high", "story_arcs": "high", "universes": "high", } diff --git a/codex/choices/search.py b/codex/choices/search.py index ff1489e30..cd1f1ceee 100644 --- a/codex/choices/search.py +++ b/codex/choices/search.py @@ -62,6 +62,9 @@ def _get_fieldmap_values(*args) -> tuple: "age", "age_rating", "age_rating_metron" ), "age_rating_tagged": (), + "alternate_series": _get_fieldmap_values( + "alternate_series", "alt_series", "reprints" + ), "characters": _get_fieldmap_values("category", "categories", "characters"), "collection_title": ("collection",), "country": (), diff --git a/codex/choices/tagging.py b/codex/choices/tagging.py index fcee03559..1e6413db7 100644 --- a/codex/choices/tagging.py +++ b/codex/choices/tagging.py @@ -78,8 +78,8 @@ def _vuetify_choices(pairs: Iterable[tuple[str, str]]) -> tuple[MappingProxyType # most fields; the splits/renames are codex's relational model (issue split into # number + suffix so issues sort numerically, comicbox "arcs" stored as # story_arcs, ComicInfo's "manga"/"title" surfaced as reading_direction/stories). -# Canonical keys absent here (bookmark, date, pages, reprints, page_count, -# prices, updated_at, ...) are not tag-editor fields and are dropped. +# Canonical keys absent here (bookmark, date, pages, page_count, prices, +# updated_at, ...) are not tag-editor fields and are dropped. _CANONICAL_TO_EDITOR: MappingProxyType[str, tuple[str, ...]] = MappingProxyType( { "publisher": ("publisher",), @@ -113,6 +113,9 @@ def _vuetify_choices(pairs: Iterable[tuple[str, str]]) -> tuple[MappingProxyType "community_rating": ("community_rating",), "protagonist": ("protagonist",), "identifiers": ("identifiers",), + # The series name and issue only; the volume number and language are + # MetronInfo-exclusive — see _EXTRA_FORMAT_FIELDS. + "reprints": ("reprints",), "country": ("country",), "universes": ("universes",), } @@ -121,10 +124,18 @@ def _vuetify_choices(pairs: Iterable[tuple[str, str]]) -> tuple[MappingProxyType # Editor sub-fields comicbox stores only for some formats and which its # transform specs don't surface at the SPECS_TO top level (the # hand-maintained support data): MetronInfo's Series carries a volume -# count, and only MetronInfo's CommunityRating persists a rating count. +# count, only MetronInfo's CommunityRating persists a rating count, and +# only MetronInfo encodes a reprint's volume number (in Reprints) and +# language (in Series/AlternativeNames) — ComicInfo's AlternateSeries / +# AlternateNumber / AlternateCount carry neither. _EXTRA_FORMAT_FIELDS: MappingProxyType[str, tuple[str, ...]] = MappingProxyType( { - "METRON_INFO": ("volume_count", "community_rating_count"), + "METRON_INFO": ( + "volume_count", + "community_rating_count", + "reprint_volume", + "reprint_language", + ), } ) diff --git a/codex/librarian/onlinetag/session_manager.py b/codex/librarian/onlinetag/session_manager.py index 12d600856..a8a8c92ed 100644 --- a/codex/librarian/onlinetag/session_manager.py +++ b/codex/librarian/onlinetag/session_manager.py @@ -606,7 +606,6 @@ def run_session(self, task: BulkOnlineTagTask) -> None: "sources": list(task.sources), "mode": task.mode, "prompts_mode": task.prompts_mode, - "auto_threshold": task.auto_threshold, "delete_original": task.delete_original, "merge_all_sources": task.merge_all_sources, "rename": task.rename, diff --git a/codex/librarian/onlinetag/session_state.py b/codex/librarian/onlinetag/session_state.py index 5e4a94370..e55093202 100644 --- a/codex/librarian/onlinetag/session_state.py +++ b/codex/librarian/onlinetag/session_state.py @@ -79,7 +79,13 @@ def serialize_candidate(c) -> dict[str, Any]: "year": getattr(summary, "year", None), "publisher": getattr(summary, "publisher", ""), "cover_url": getattr(summary, "cover_url", ""), + # Alternative series names comicbox scored this candidate on. + # Empty for sources whose search results don't carry them. + "alt_series": list(getattr(summary, "alt_series", ())), }, "score": c.score, "url": getattr(c, "url", ""), + # The candidate's parent container id (CV volume, Metron series). + # None for sources that don't expose it. + "volume_id": getattr(c, "volume_id", None), } diff --git a/codex/librarian/onlinetag/tasks.py b/codex/librarian/onlinetag/tasks.py index 4e916fcb8..4a4cab4b4 100644 --- a/codex/librarian/onlinetag/tasks.py +++ b/codex/librarian/onlinetag/tasks.py @@ -21,7 +21,6 @@ class BulkOnlineTagTask(OnlineTagTask): sources: tuple[str, ...] = SOURCE_NAMES mode: str = "auto" prompts_mode: str = "ask" - auto_threshold: float = 0.85 delete_original: bool = False dry_run: bool = False # Query every source per comic and merge (comicbox first_wins=False) diff --git a/codex/librarian/scribe/importer/const.py b/codex/librarian/scribe/importer/const.py index 069e81ab7..a5fa4c7cc 100644 --- a/codex/librarian/scribe/importer/const.py +++ b/codex/librarian/scribe/importer/const.py @@ -32,6 +32,7 @@ Language, Location, OriginalFormat, + Reprint, ScanInfo, SeriesGroup, Story, @@ -172,12 +173,12 @@ def _metron_fts_values(values: tuple) -> tuple: ), ) COMIC_M2M_FIELD_NAMES: tuple[str, ...] = tuple(field.name for field in COMIC_M2M_FIELDS) -COMPLEX_M2M_MODELS = (Credit, Identifier, StoryArcNumber) +COMPLEX_M2M_MODELS = (Credit, Identifier, Reprint, StoryArcNumber) ######################## # COMPLEX M2M METADATA # ######################## -DictModelType = Credit | Identifier | StoryArcNumber +DictModelType = Credit | Identifier | Reprint | StoryArcNumber CREDITS_FIELD_NAME = "credits" CREDIT_PERSON_FIELD_NAME = "person" CREDIT_ROLE_FIELD_NAME = "role" @@ -188,6 +189,13 @@ def _metron_fts_values(values: tuple) -> tuple: IDENTIFIER_TYPE_FIELD_NAME = "id_type" IDENTIFIER_ID_KEY_FIELD_NAME = "key" IDENTIFIER_URL_FIELD_NAME = "url" +REPRINTS_FIELD_NAME = "reprints" +REPRINT_SERIES_NAME_FIELD_NAME = "series_name" +REPRINT_VOLUME_NUMBER_FIELD_NAME = "volume_number" +REPRINT_ISSUE_FIELD_NAME = "issue" +REPRINT_LANGUAGE_FIELD_NAME = "language" +# Reprints index only their series names, under their own ComicFTS column. +ALTERNATE_SERIES_FTS_FIELD_NAME = "alternate_series" UNIVERSES_FIELD_NAME = "universes" NAME_FIELD_NAME = "name" NUMBER_TO_FIELD_NAME = "number_to" @@ -293,6 +301,15 @@ def _metron_fts_values(values: tuple) -> tuple: ), "", ), + Reprint: ( + ( + REPRINT_SERIES_NAME_FIELD_NAME, + REPRINT_VOLUME_NUMBER_FIELD_NAME, + REPRINT_ISSUE_FIELD_NAME, + REPRINT_LANGUAGE_FIELD_NAME, + ), + _IDENTIFIER_RELS, + ), StoryArcNumber: ( (f"{STORY_ARC_FIELD_NAME}__name", NUMBER_FIELD_NAME), "", @@ -311,6 +328,7 @@ def _metron_fts_values(values: tuple) -> tuple: Series: ("publisher", "imprint"), Volume: ("publisher", "imprint", "series"), Credit: (CREDIT_PERSON_FIELD_NAME, CREDIT_ROLE_FIELD_NAME), + Reprint: _IDENTIFIED_SELECT_RELATED, StoryArcNumber: (STORY_ARC_FIELD_NAME,), Universe: _IDENTIFIED_SELECT_RELATED, } @@ -350,6 +368,12 @@ def _metron_fts_values(values: tuple) -> tuple: *_NAMED_MODEL_ATTRS, ), CREDITS_FIELD_NAME: (CREDIT_PERSON_FIELD_NAME, CREDIT_ROLE_FIELD_NAME), + REPRINTS_FIELD_NAME: ( + REPRINT_SERIES_NAME_FIELD_NAME, + REPRINT_VOLUME_NUMBER_FIELD_NAME, + REPRINT_ISSUE_FIELD_NAME, + REPRINT_LANGUAGE_FIELD_NAME, + ), STORY_ARC_NUMBERS_FIELD_NAME: (STORY_ARC_FIELD_NAME, NUMBER_FIELD_NAME), } ) @@ -368,6 +392,7 @@ def _metron_fts_values(values: tuple) -> tuple: Identifier: IDENTIFIER_ID_KEY_FIELD_NAME, Imprint: NAME_FIELD_NAME, Publisher: NAME_FIELD_NAME, + Reprint: REPRINT_SERIES_NAME_FIELD_NAME, Series: NAME_FIELD_NAME, StoryArc: NAME_FIELD_NAME, StoryArcNumber: f"{STORY_ARC_FIELD_NAME}__name", diff --git a/codex/librarian/scribe/importer/create/const.py b/codex/librarian/scribe/importer/create/const.py index 50215d0fb..437a60e1f 100644 --- a/codex/librarian/scribe/importer/create/const.py +++ b/codex/librarian/scribe/importer/create/const.py @@ -20,12 +20,17 @@ NUMBER_FIELD_NAME, NUMBER_TO_FIELD_NAME, PUBLISHER_FIELD_NAME, + REPRINT_ISSUE_FIELD_NAME, + REPRINT_LANGUAGE_FIELD_NAME, + REPRINT_SERIES_NAME_FIELD_NAME, + REPRINT_VOLUME_NUMBER_FIELD_NAME, SERIES_FIELD_NAME, STORY_ARC_FIELD_NAME, VOLUME_COUNT_FIELD_NAME, ) from codex.models import ( Credit, + Reprint, StoryArc, StoryArcNumber, Volume, @@ -117,6 +122,15 @@ }, {}, ), + Reprint: ( + { + REPRINT_SERIES_NAME_FIELD_NAME: None, + REPRINT_VOLUME_NUMBER_FIELD_NAME: None, + REPRINT_ISSUE_FIELD_NAME: None, + REPRINT_LANGUAGE_FIELD_NAME: None, + }, + {IDENTIFIER_FIELD_NAME: Identifier}, + ), StoryArcNumber: ({STORY_ARC_FIELD_NAME: StoryArc, NUMBER_FIELD_NAME: None}, {}), Universe: ( { diff --git a/codex/librarian/scribe/importer/link/const.py b/codex/librarian/scribe/importer/link/const.py index 49ed000b7..ed2391191 100644 --- a/codex/librarian/scribe/importer/link/const.py +++ b/codex/librarian/scribe/importer/link/const.py @@ -5,6 +5,7 @@ from codex.librarian.scribe.importer.const import ( CREDITS_FIELD_NAME, IDENTIFIERS_FIELD_NAME, + REPRINTS_FIELD_NAME, STORY_ARC_NUMBERS_FIELD_NAME, ) from codex.models.base import BaseModel @@ -12,6 +13,7 @@ COMPLEX_MODEL_FIELD_NAMES = ( CREDITS_FIELD_NAME, + REPRINTS_FIELD_NAME, STORY_ARC_NUMBERS_FIELD_NAME, IDENTIFIERS_FIELD_NAME, ) diff --git a/codex/librarian/scribe/importer/link/delete.py b/codex/librarian/scribe/importer/link/delete.py index f525954cc..40f54c95f 100644 --- a/codex/librarian/scribe/importer/link/delete.py +++ b/codex/librarian/scribe/importer/link/delete.py @@ -38,9 +38,7 @@ def _delete_m2m_field_batch( return count def _delete_m2m_fts_entries(self, field_name: str, comic_ids: set[int]) -> None: - fts_field_name = ( - "story_arcs" if field_name == "story_arc_numbers" else field_name - ) + fts_field_name, _ = self.minify_complex_link_to_fts_tuple(field_name, ()) for comic_id in comic_ids: if not self.metadata.get(FTS_UPDATE, {}).get(comic_id, {}).get( fts_field_name diff --git a/codex/librarian/scribe/importer/read/many_to_many.py b/codex/librarian/scribe/importer/read/many_to_many.py index b1f432dea..668e5c0f4 100644 --- a/codex/librarian/scribe/importer/read/many_to_many.py +++ b/codex/librarian/scribe/importer/read/many_to_many.py @@ -1,10 +1,20 @@ """Aggregate ManyToMany Metadata.""" -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from types import MappingProxyType from typing import TYPE_CHECKING, cast -from comicbox.formats.comicbox.schema import IDENTIFIERS_KEY, NUMBER_KEY, ROLES_KEY +from comicbox.formats.comicbox.schema import ( + IDENTIFIERS_KEY, + ISSUE_KEY, + LANGUAGE_KEY, + NAME_KEY, + NUMBER_KEY, + ROLES_KEY, + SERIES_KEY, + SERIES_SORT_NAME_KEY, + VOLUME_KEY, +) from django.db.models import CharField, Field from django.db.models.fields.related import ManyToManyField @@ -13,6 +23,11 @@ CREDITS_FIELD_NAME, IDENTIFIERS_FIELD_NAME, LINK_M2MS, + REPRINT_ISSUE_FIELD_NAME, + REPRINT_LANGUAGE_FIELD_NAME, + REPRINT_SERIES_NAME_FIELD_NAME, + REPRINT_VOLUME_NUMBER_FIELD_NAME, + REPRINTS_FIELD_NAME, STORY_ARC_NUMBERS_FIELD_NAME, get_key_index, ) @@ -27,11 +42,16 @@ from codex.models.collections import Folder from codex.models.comic import Comic from codex.models.identifier import IdentifierSource -from codex.models.named import CreditRole +from codex.models.named import CreditRole, Reprint if TYPE_CHECKING: from codex.models.base import BaseModel +_REPRINT_SERIES_NAME_FIELD = Reprint._meta.get_field(REPRINT_SERIES_NAME_FIELD_NAME) +_REPRINT_VOLUME_NUMBER_FIELD = Reprint._meta.get_field(REPRINT_VOLUME_NUMBER_FIELD_NAME) +_REPRINT_ISSUE_FIELD = Reprint._meta.get_field(REPRINT_ISSUE_FIELD_NAME) +_REPRINT_LANGUAGE_FIELD = Reprint._meta.get_field(REPRINT_LANGUAGE_FIELD_NAME) + class AggregateManyToManyMetadataImporter(AggregateForeignKeyMetadataImporter): """Aggregate ManyToMany Metadata.""" @@ -222,7 +242,44 @@ def _get_m2m_metadata_dict_model_aggregate_sub_values( field, roles_or_numbers, clean_sub_key, clean_sub_values ) - def _get_m2m_metadata_dict_model( + @staticmethod + def _clean_reprint_key(reprint: Mapping) -> tuple | None: + """ + Flatten one comicbox reprint tree into a Reprint key tuple. + + MetronInfo AlternativeNames supply only a series ``sort_name``, + so that stands in when there's no ``series.name``. A reprint + with neither names nothing, so it's dropped. + """ + series = reprint.get(SERIES_KEY) or {} + series_name = _REPRINT_SERIES_NAME_FIELD.get_prep_value( + series.get(NAME_KEY) or series.get(SERIES_SORT_NAME_KEY) + ) + if not series_name: + return None + volume = reprint.get(VOLUME_KEY) or {} + return ( + series_name, + _REPRINT_VOLUME_NUMBER_FIELD.get_prep_value(volume.get(NUMBER_KEY)), + _REPRINT_ISSUE_FIELD.get_prep_value(reprint.get(ISSUE_KEY) or ""), + _REPRINT_LANGUAGE_FIELD.get_prep_value(reprint.get(LANGUAGE_KEY) or ""), + ) + + def _get_m2m_metadata_reprints(self, values: Iterable[Mapping]) -> dict: + """ + Aggregate reprints, the only m2m keyed by a nested comicbox tree. + + Every other complex m2m arrives as a mapping keyed by a name, a + shape that cannot express the four part reprint key. + """ + clean_values_map: dict[tuple, frozenset[tuple]] = {} + for reprint in values: + if clean_key := self._clean_reprint_key(reprint): + identifier_tuple = self.get_identifier_tuple(Reprint, reprint) + clean_values_map[clean_key] = frozenset({(identifier_tuple,)}) + return clean_values_map + + def _get_m2m_metadata_named_dict_model( self, md_key: str, field: ManyToManyField, @@ -248,7 +305,21 @@ def _get_m2m_metadata_dict_model( sub_value, ) clean_values_map.update(clean_sub_map) + return clean_values_map + def _get_m2m_metadata_dict_model( + self, + md_key: str, + field: ManyToManyField, + values: Mapping[str, Mapping | None] | list | tuple | set | frozenset, + ) -> dict: + # ``values`` is typed as the union every m2m field can present; + # comicbox always hands reprints over as a list of trees. + clean_values_map = ( + self._get_m2m_metadata_reprints(cast("Iterable[Mapping]", values)) + if field.name == REPRINTS_FIELD_NAME + else self._get_m2m_metadata_named_dict_model(md_key, field, values) + ) related_model: type[BaseModel] = field.related_model if related_model != Folder: for key, value in clean_values_map.items(): diff --git a/codex/librarian/scribe/importer/search/prepare.py b/codex/librarian/scribe/importer/search/prepare.py index 8f6388434..4c69e5502 100644 --- a/codex/librarian/scribe/importer/search/prepare.py +++ b/codex/librarian/scribe/importer/search/prepare.py @@ -1,6 +1,7 @@ """Prepare FTS update methods used in earlier import steps.""" from codex.librarian.scribe.importer.const import ( + ALTERNATE_SERIES_FTS_FIELD_NAME, CREDITS_FIELD_NAME, FTS_CREATE, FTS_CREATED_M2MS, @@ -8,6 +9,7 @@ FTS_FIELD_TARGETS, FTS_UPDATE, NON_FTS_FIELDS, + REPRINTS_FIELD_NAME, STORY_ARC_FIELD_NAME, STORY_ARC_NUMBERS_FIELD_NAME, ) @@ -27,6 +29,9 @@ def minify_complex_link_to_fts_tuple( """Only store the fts relevant parts of complex links.""" if field_name == CREDITS_FIELD_NAME: values = tuple(subvalues[0] for subvalues in values) + elif field_name == REPRINTS_FIELD_NAME: + field_name = ALTERNATE_SERIES_FTS_FIELD_NAME + values = tuple(subvalues[0] for subvalues in values) elif field_name == STORY_ARC_NUMBERS_FIELD_NAME: field_name = STORY_ARC_FIELD_NAME + "s" return field_name, tuple(values) diff --git a/codex/librarian/scribe/janitor/cleanup.py b/codex/librarian/scribe/janitor/cleanup.py index e61b6537c..60da1eb33 100644 --- a/codex/librarian/scribe/janitor/cleanup.py +++ b/codex/librarian/scribe/janitor/cleanup.py @@ -50,6 +50,7 @@ Location, OriginalFormat, Publisher, + Reprint, ScanInfo, Series, SeriesGroup, @@ -88,6 +89,7 @@ Imprint, OriginalFormat, Publisher, + Reprint, Series, SeriesGroup, ScanInfo, @@ -222,7 +224,7 @@ def cleanup_fks(self) -> None: Wrapped in ``transaction.atomic`` so the multi-pass convergence is all-or-nothing: an exception mid-pass rolls back to pre- cleanup state rather than leaving a partially-cleaned graph. - The 25 per-model deletes also coalesce into one fsync. + The per-model deletes also coalesce into one fsync. """ self.abort_event.clear() status = JanitorCleanupTagsStatus(0) diff --git a/codex/librarian/scribe/search/const.py b/codex/librarian/scribe/search/const.py index 950552366..812d40e84 100644 --- a/codex/librarian/scribe/search/const.py +++ b/codex/librarian/scribe/search/const.py @@ -20,6 +20,7 @@ "tagger", ) _COMICFTS_M2MS = ( + "alternate_series", "characters", "credits", "genres", diff --git a/codex/librarian/scribe/search/prepare.py b/codex/librarian/scribe/search/prepare.py index f7a10b16a..449007b72 100644 --- a/codex/librarian/scribe/search/prepare.py +++ b/codex/librarian/scribe/search/prepare.py @@ -29,6 +29,7 @@ "fts_original_format", "fts_scan_info", "fts_tagger", + "fts_alternate_series", "fts_characters", "fts_credits", "fts_country", diff --git a/codex/librarian/scribe/search/sync.py b/codex/librarian/scribe/search/sync.py index 857568b21..c5d6817be 100644 --- a/codex/librarian/scribe/search/sync.py +++ b/codex/librarian/scribe/search/sync.py @@ -69,6 +69,7 @@ # story_arcs) ended up empty for sync-built entries. _M2M_FTS_REL_MAP = MappingProxyType( { + "alternate_series": "reprints__series_name", "characters": "characters__name", "credits": "credits__person__name", "genres": "genres__name", diff --git a/codex/librarian/scribe/tag_writer.py b/codex/librarian/scribe/tag_writer.py index ec27063f4..46f24ca0e 100644 --- a/codex/librarian/scribe/tag_writer.py +++ b/codex/librarian/scribe/tag_writer.py @@ -121,7 +121,7 @@ def _build_base_config(task: BulkTagWriteTask): Never pass codex's read-side ``COMICBOX_CONFIG`` here. Comicbox unions a write's ``delete_keys`` with the base config's, and that config carries the big parse-skip set of schema fields codex - doesn't consume (pages, reprints, cover_image, ...). Using it as a + doesn't consume (pages, cover_image, prices, ...). Using it as a write base would strip every one of those from the user's archive. """ if not task.delete_original: diff --git a/codex/migrations/0049_reprints.py b/codex/migrations/0049_reprints.py new file mode 100644 index 000000000..a35110b46 --- /dev/null +++ b/codex/migrations/0049_reprints.py @@ -0,0 +1,202 @@ +# Generated by Django 6.0.7 on 2026-07-26 05:53 +""" +comicbox 4.6.0: reprints (alternate & localized series names). + +comicbox 4.6.0 emits a ``reprints`` list from MetronInfo +``Series/AlternativeNames`` + ``Reprints`` and from Metron / ComicVine +online tagging. Codex stores them in a denormalized :class:`Reprint` +table rather than reusing Series/Volume — those are browse collections +and alternate names would surface as phantom browser rows. + +``Reprint`` rows carry an ``Identifier``, so ``IdentifierType`` gains +its long-reserved ``reprint`` member. Reprints are also a browser order +key. Both are choices-only churn on existing columns, hence the two +``AlterField`` operations below. + +The FTS5 rebuild adds the ``alternate_series`` column. ``ComicFTS`` is +unmanaged, so makemigrations never sees it — the column list lives in +SQL and has to be dropped and recreated by hand, exactly as 0039 did +for the age-rating columns. The table comes back empty; the librarian's +regular ``SearchIndexSyncTask`` repopulates it. +""" + +import django.db.models.deletion +from django.db import migrations, models + +import codex.models.fields + +# 0039's column list plus ``alternate_series``. +_NEW_FTS_SQL = ( + "CREATE VIRTUAL TABLE codex_comicfts USING fts5(" + "comic_id UNINDEXED, created_at UNINDEXED, updated_at UNINDEXED, " + "publisher, imprint, series, name, collection_title, " + "age_rating_tagged, age_rating_metron, country, language, " + "original_format, review, scan_info, summary, tagger, " + "alternate_series, characters, " + "credits, genres, locations, roles, series_groups, stories, " + "story_arcs, tags, teams, universes, sources" + ")" +) + + +class Migration(migrations.Migration): + """Reprint model, comic m2m, filter column, and FTS alternate_series.""" + + dependencies = [ + ("codex", "0048_community_rating_alternative_issue"), + ] + + operations = [ + migrations.AddField( + model_name="settingsbrowserfilters", + name="reprints", + field=models.JSONField(default=list), + ), + migrations.AlterField( + model_name="identifier", + name="id_type", + field=models.CharField( + choices=[ + ("storyarc", "Arc"), + ("character", "Character"), + ("genre", "Genre"), + ("imprint", "Imprint"), + ("comic", "Issue"), + ("location", "Location"), + ("publisher", "Publisher"), + ("reprint", "Reprint"), + ("series", "Series"), + ("story", "Story"), + ("tag", "Tag"), + ("team", "Team"), + ("universe", "Universe"), + ("creditrole", "Role"), + ("creditperson", "Creator"), + ], + db_index=True, + max_length=16, + ), + ), + migrations.CreateModel( + name="Reprint", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "series_name", + codex.models.fields.CleaningCharField( + db_index=True, max_length=128 + ), + ), + ( + "volume_number", + codex.models.fields.CoercingPositiveSmallIntegerField( + default=None, null=True + ), + ), + ( + "issue", + codex.models.fields.CleaningCharField(default="", max_length=32), + ), + ( + "language", + codex.models.fields.CleaningCharField(default="", max_length=32), + ), + ( + "identifier", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="codex.identifier", + ), + ), + ], + options={ + "get_latest_by": "updated_at", + "abstract": False, + "unique_together": { + ("series_name", "volume_number", "issue", "language") + }, + }, + ), + migrations.AddField( + model_name="comic", + name="reprints", + field=models.ManyToManyField(to="codex.reprint"), + ), + migrations.AlterField( + model_name="settingsbrowser", + name="order_by", + field=models.CharField( + choices=[ + ("created_at", "Added Time"), + ("age_rating", "Age Rating"), + ("reprints", "Alternate Series"), + ("characters", "Characters"), + ("child_count", "Child Count"), + ("community_rating", "Community Rating"), + ("country", "Country"), + ("credits", "Credits"), + ("day", "Day"), + ("favorite", "Favorite"), + ("filename", "Filename"), + ("size", "File Size"), + ("file_type", "File Type"), + ("original_format", "Format"), + ("genres", "Genres"), + ("identifiers", "Identifiers"), + ("imprint_name", "Imprint"), + ("issue", "Issue"), + ("language", "Language"), + ("bookmark_updated_at", "Last Read"), + ("locations", "Locations"), + ("main_character", "Main Character"), + ("main_team", "Main Team"), + ("metadata_mtime", "Tags Updated"), + ("month", "Month"), + ("monochrome", "Monochrome"), + ("sort_name", "Name"), + ("page_count", "Page Count"), + ("publisher_name", "Publisher"), + ("date", "Publish Date"), + ("reading_direction", "Reading Direction"), + ("scan_info", "Scan Info"), + ("search_score", "Search Score"), + ("series_name", "Series"), + ("series_groups", "Series Groups"), + ("stories", "Stories"), + ("story_arc_number", "Story Arc Number"), + ("story_arcs", "Story Arcs"), + ("tags", "Tags"), + ("tagger", "Tagger"), + ("teams", "Teams"), + ("universes", "Universes"), + ("updated_at", "Updated Time"), + ("volume_name", "Volume"), + ("year", "Year"), + ], + default="", + max_length=32, + ), + ), + # ComicFTS is unmanaged; its Django state tracks only (comic, + # created_at, updated_at). The FTS columns are a SQL-level concern — + # no state_operations are needed, only a raw DROP + CREATE. + migrations.RunSQL( + sql="DROP TABLE IF EXISTS codex_comicfts;", + reverse_sql=migrations.RunSQL.noop, + ), + migrations.RunSQL( + sql=_NEW_FTS_SQL, + reverse_sql="DROP TABLE IF EXISTS codex_comicfts;", + ), + ] diff --git a/codex/models/comic.py b/codex/models/comic.py index ba9f93986..dc7d2e239 100644 --- a/codex/models/comic.py +++ b/codex/models/comic.py @@ -59,6 +59,7 @@ Language, Location, OriginalFormat, + Reprint, ScanInfo, SeriesGroup, Story, @@ -209,6 +210,7 @@ class Comic(WatchedPathBrowserCollection): genres = ManyToManyField(Genre) identifiers = ManyToManyField(Identifier) locations = ManyToManyField(Location) + reprints = ManyToManyField(Reprint) series_groups = ManyToManyField(SeriesGroup) stories = ManyToManyField(Story) story_arc_numbers = ManyToManyField(StoryArcNumber) @@ -218,13 +220,16 @@ class Comic(WatchedPathBrowserCollection): ##################### # Comicbox Ignored: - # alternate_issue - # alternate_volumes + # alternate_images + # bookmark # cover_image - # is_version_of - # last_mark + # credit_primaries + # critical_rating + # identifier_primary_source # manga - # price + # pages + # prices + # remainders # rights # codex only @@ -419,6 +424,7 @@ class ComicFTS(BaseModel): scan_info = CharField(db_collation="nocase", max_length=MAX_NAME_LEN) tagger = CharField(db_collation="nocase", max_length=MAX_NAME_LEN) # M2M + alternate_series = CharField(db_collation="nocase", max_length=MAX_NAME_LEN) characters = CharField(db_collation="nocase", max_length=MAX_NAME_LEN) credits = CharField(db_collation="nocase", max_length=MAX_NAME_LEN) genres = CharField(db_collation="nocase", max_length=MAX_NAME_LEN) diff --git a/codex/models/identifier.py b/codex/models/identifier.py index 0197e1709..48af5dcaa 100644 --- a/codex/models/identifier.py +++ b/codex/models/identifier.py @@ -36,7 +36,7 @@ class IdentifierType(TextChoices): ISSUE = "comic" LOCATION = "location" PUBLISHER = "publisher" - # REPRINT = "reprint" not yet implemented + REPRINT = "reprint" SERIES = "series" STORY = "story" TAG = "tag" diff --git a/codex/models/named.py b/codex/models/named.py index 582f35050..fc7c860c9 100644 --- a/codex/models/named.py +++ b/codex/models/named.py @@ -4,11 +4,12 @@ from django.db.models import ( CASCADE, + SET_NULL, ForeignKey, ) -from codex.models.base import MAX_NAME_LEN, BaseModel, NamedModel -from codex.models.collections import BrowserCollectionModel +from codex.models.base import MAX_FIELD_LEN, MAX_NAME_LEN, BaseModel, NamedModel +from codex.models.collections import BrowserCollectionModel, Volume from codex.models.fields import CleaningCharField, CoercingPositiveSmallIntegerField from codex.models.identifier import Identifier @@ -22,6 +23,7 @@ "Language", "Location", "OriginalFormat", + "Reprint", "ScanInfo", "SeriesGroup", "Story", @@ -111,6 +113,59 @@ class OriginalFormat(NamedModel): """The original published format.""" +class Reprint(BaseModel): + """ + An alternate or localized edition of this issue. + + Denormalized on purpose: alternate series names must not become + Series/Volume rows or they'd appear as phantom browser collections. + ``series_name`` absorbs comicbox's ``series.sort_name`` when the + reprint carries no ``series.name`` (MetronInfo AlternativeNames do + this), so this is the only series string stored. + """ + + series_name = CleaningCharField(db_index=True, max_length=MAX_NAME_LEN) + volume_number = CoercingPositiveSmallIntegerField(null=True, default=None) + issue = CleaningCharField(max_length=MAX_FIELD_LEN, default="") + language = CleaningCharField(max_length=MAX_FIELD_LEN, default="") + identifier = ForeignKey(Identifier, on_delete=SET_NULL, null=True) + + class Meta(BaseModel.Meta): + """Declare constraints and indexes.""" + + unique_together = ("series_name", "volume_number", "issue", "language") + + @staticmethod + def compose_name( + series_name: str, + volume_number: int | None = None, + issue: str = "", + language: str = "", + ) -> str: + """ + Compose a display name to imitate a NamedModel. + + Callers that only have the columns (browser filter choices, the + table-view intersection) format through here so every label + matches the metadata panel's. + """ + parts = [series_name] + if volume_number is not None: + parts.append(Volume.to_str(volume_number, None)) + if issue: + parts.append(f"#{issue}") + if language: + parts.append(f"({language})") + return " ".join(parts) + + @property + def name(self) -> str: + """Compose a display name to imitate a NamedModel.""" + return self.compose_name( + self.series_name, self.volume_number, self.issue, self.language + ) + + class ScanInfo(NamedModel): """Whomever scanned the comic.""" diff --git a/codex/models/settings.py b/codex/models/settings.py index 48922bd3c..2b39a4c1b 100644 --- a/codex/models/settings.py +++ b/codex/models/settings.py @@ -237,6 +237,7 @@ class SettingsBrowserFilters(BaseModel): monochrome = JSONField(default=list) original_format = JSONField(default=list) reading_direction = JSONField(default=list) + reprints = JSONField(default=list) series_groups = JSONField(default=list) stories = JSONField(default=list) story_arcs = JSONField(default=list) @@ -265,6 +266,7 @@ class SettingsBrowserFilters(BaseModel): "monochrome", "original_format", "reading_direction", + "reprints", "series_groups", "stories", "story_arcs", diff --git a/codex/serializers/admin/stats.py b/codex/serializers/admin/stats.py index f7437d1e7..44139664b 100644 --- a/codex/serializers/admin/stats.py +++ b/codex/serializers/admin/stats.py @@ -116,6 +116,7 @@ class StatsComicMetadataSerializer(Serializer): language_count = IntegerField(required=False, read_only=True) location_count = IntegerField(required=False, read_only=True) original_format_count = IntegerField(required=False, read_only=True) + reprint_count = IntegerField(required=False, read_only=True) series_group_count = IntegerField(required=False, read_only=True) scan_info_count = IntegerField(required=False, read_only=True) story_arc_count = IntegerField(required=False) diff --git a/codex/serializers/admin/tagging.py b/codex/serializers/admin/tagging.py index e1fe27735..122c9aca6 100644 --- a/codex/serializers/admin/tagging.py +++ b/codex/serializers/admin/tagging.py @@ -61,7 +61,6 @@ class OnlineTagStartSerializer(Serializer): sources = ListField(child=CharField(), required=False, default=list(SOURCE_NAMES)) mode = CharField(required=False, default="auto") prompts_mode = CharField(required=False, default="ask") - auto_threshold = CharField(required=False, default="0.85") dry_run = CharField(required=False, default="false") delete_original = BooleanField(required=False, default=None) # None falls back to the admin ComicboxTaggingDefaults default. diff --git a/codex/serializers/browser/choices.py b/codex/serializers/browser/choices.py index 31c04a320..c7504a739 100644 --- a/codex/serializers/browser/choices.py +++ b/codex/serializers/browser/choices.py @@ -10,6 +10,7 @@ ) from rest_framework.serializers import Serializer +from codex.models.named import Reprint from codex.serializers.fields import ( VuetifyBooleanField, VuetifyCharField, @@ -44,6 +45,7 @@ class BrowserFilterChoicesSerializer(Serializer): locations = BooleanField(read_only=True) original_format = BooleanField(read_only=True) reading_direction = BooleanField(read_only=True) + reprints = BooleanField(read_only=True) series_groups = BooleanField(read_only=True) stories = BooleanField(read_only=True) story_arcs = BooleanField(read_only=True) @@ -78,6 +80,7 @@ class BrowserSettingsFilterSerializer(Serializer): reading_direction = VuetifyReadOnlyListField( child=VuetifyReadingDirectionChoiceField ) + reprints = VuetifyReadOnlyListField() series_groups = VuetifyReadOnlyListField() stories = VuetifyReadOnlyListField() story_arcs = VuetifyReadOnlyListField() @@ -113,6 +116,25 @@ class BrowserChoicesUniversePkSerializer(Serializer): designation = CharField(read_only=True) +# ``Reprint`` columns the label composes from, in +# :meth:`Reprint.compose_name` argument order. The choices view projects +# exactly these instead of the ``name`` every other tag model has. +REPRINT_LABEL_FIELDS = ("series_name", "volume_number", "issue", "language") + + +class BrowserChoicesReprintPkSerializer(BrowserChoicesIntegerPkSerializer): + """Reprints have no ``name`` column; compose the label from their four.""" + + name = SerializerMethodField(read_only=True) + + def get_name(self, obj) -> str: + """Compose the same label the metadata panel shows.""" + # The null sentinel row the view prepends already has a name. + if name := obj.get("name"): + return name + return Reprint.compose_name(*(obj[field] for field in REPRINT_LABEL_FIELDS)) + + class BrowserChoicesCharPkSerializer(BrowserChoicesIntegerPkSerializer): """Named Model Serailizer with pk = char hack for languages & countries.""" @@ -134,6 +156,7 @@ class BrowserChoicesDecimalPkSerializer(BrowserChoicesIntegerPkSerializer): "community_rating": BrowserChoicesDecimalPkSerializer, "file_type": BrowserChoicesCharPkSerializer, "language": LanguageSerializer, + "reprints": BrowserChoicesReprintPkSerializer, "universe": BrowserChoicesUniversePkSerializer, } ) diff --git a/codex/serializers/browser/filters.py b/codex/serializers/browser/filters.py index f5666bf5f..233011d8a 100644 --- a/codex/serializers/browser/filters.py +++ b/codex/serializers/browser/filters.py @@ -38,6 +38,7 @@ class BrowserSettingsFilterInputSerializer(Serializer): monochrome = VuetifyListField(child=VuetifyBooleanField) original_format = VuetifyListField() reading_direction = VuetifyListField(child=VuetifyReadingDirectionChoiceField) + reprints = VuetifyListField() series_groups = VuetifyListField() stories = VuetifyListField() story_arcs = VuetifyListField() diff --git a/codex/serializers/models/comic.py b/codex/serializers/models/comic.py index 0dce27115..fb0006ead 100644 --- a/codex/serializers/models/comic.py +++ b/codex/serializers/models/comic.py @@ -20,6 +20,7 @@ IdentifierSerializer, LocationSerializer, OriginalFormatSerializer, + ReprintSerializer, ScanInfoSerializer, SeriesGroupSerializer, StoryArcNumberSerializer, @@ -66,6 +67,7 @@ class ComicSerializer(BaseModelSerializer): genres = GenreSerializer(many=True, allow_null=True) identifiers = IdentifierSerializer(many=True, allow_null=True) locations = LocationSerializer(many=True, allow_null=True) + reprints = ReprintSerializer(many=True, allow_null=True) series_groups = SeriesGroupSerializer(many=True, allow_null=True) stories = StorySerializer(many=True, allow_null=True) story_arc_numbers = StoryArcNumberSerializer( diff --git a/codex/serializers/models/named.py b/codex/serializers/models/named.py index 6968cc4ec..4d70ccf30 100644 --- a/codex/serializers/models/named.py +++ b/codex/serializers/models/named.py @@ -13,6 +13,7 @@ Genre, Location, OriginalFormat, + Reprint, ScanInfo, SeriesGroup, Story, @@ -146,6 +147,30 @@ class Meta(URLNamedModelSerializer.Meta): model = Location +class ReprintSerializer(BaseModelSerializer): + """Reprint model.""" + + name = CharField(read_only=True) + url = URLField(read_only=True, source="identifier.url") + + class Meta(BaseModelSerializer.Meta): + """Configure model.""" + + model = Reprint + # The columns ride along with the composed name so the tag editor + # can seed its rows without parsing the label back apart. + fields = ( + "pk", + "name", + "series_name", + "volume_number", + "issue", + "language", + "url", + ) + depth = 1 + + class SeriesGroupSerializer(NamedModelSerializer): """SeriesGroup model.""" diff --git a/codex/settings/__init__.py b/codex/settings/__init__.py index 16c7b4336..1b161d39f 100644 --- a/codex/settings/__init__.py +++ b/codex/settings/__init__.py @@ -1125,6 +1125,7 @@ def _get_middleware(features: FeatureFlags) -> tuple[str, ...]: "protagonist", "publisher", "reading_direction", + "reprints", "review", "scan_info", "series", @@ -1141,10 +1142,10 @@ def _get_middleware(features: FeatureFlags) -> tuple[str, ...]: ) # Tell comicbox to skip parse work for every top-level field codex -# does not consume. Pages and reprints are short-circuited inside -# their respective computed actions; the rest are excluded at schema -# parse time and dropped post-merge as a backstop. Derived from the -# live comicbox schema rather than hand-curated so a comicbox release +# does not consume. Pages are short-circuited inside their computed +# action; the rest are excluded at schema parse time and dropped +# post-merge as a backstop. Derived from the live comicbox schema +# rather than hand-curated so a comicbox release # that adds a new top-level field is auto-deleted (codex maintainer # adds it to ``USED_COMICBOX_FIELDS`` if it's needed). Closes the # pre-existing ``cover_image`` gap. diff --git a/codex/user_data/identifiers.py b/codex/user_data/identifiers.py index cdcf419a5..e5315f6a7 100644 --- a/codex/user_data/identifiers.py +++ b/codex/user_data/identifiers.py @@ -56,6 +56,7 @@ "language": "codex.Language", "locations": "codex.Location", "original_format": "codex.OriginalFormat", + "reprints": "codex.Reprint", "series_groups": "codex.SeriesGroup", "stories": "codex.Story", "story_arcs": "codex.StoryArc", @@ -66,6 +67,13 @@ } ) FILTER_TAG_COLUMNS: Final[frozenset[str]] = frozenset(_FILTER_TAG_MODEL_REFS) +# Tag models that don't inherit ``NamedModel.name``. ``Reprint`` keys on +# ``series_name``, which is not unique on its own, so a restore may +# resolve more PKs than the dump held when a series has several +# alternate-name variants. +_FILTER_TAG_NAME_FIELDS: Final[MappingProxyType[str, str]] = MappingProxyType( + {"reprints": "series_name"} +) def tag_model_for_filter(column: str) -> type[Model] | None: @@ -79,6 +87,11 @@ def tag_model_for_filter(column: str) -> type[Model] | None: return apps.get_model(app_label, model_name) +def tag_name_field_for_filter(column: str) -> str: + """Return the name-bearing field of a filter column's tag model.""" + return _FILTER_TAG_NAME_FIELDS.get(column, "name") + + def encode_identifier(collection: str, parts: list[Any]) -> str: """JSON-encode an identifier tuple for use as a sidecar primary-key column.""" # ``separators`` produces compact output so equal tuples encode identically. diff --git a/codex/user_data/restore.py b/codex/user_data/restore.py index ed96e1794..37255575f 100644 --- a/codex/user_data/restore.py +++ b/codex/user_data/restore.py @@ -629,13 +629,21 @@ def _restore_one_settings_browser( def _resolve_filter_tags(column: str, raw: list, browser, report: RestoreReport): """Resolve a tag-name list back to PKs, reporting unmatched names.""" - from codex.user_data.identifiers import tag_model_for_filter + from codex.user_data.identifiers import ( + tag_model_for_filter, + tag_name_field_for_filter, + ) model = tag_model_for_filter(column) if model is None: return [] - pks = list(model.objects.filter(name__in=raw).values_list("pk", flat=True)) - if len(pks) != len(raw): + name_field = tag_name_field_for_filter(column) + pks = list( + model.objects.filter(**{f"{name_field}__in": raw}).values_list("pk", flat=True) + ) + # A non-unique name field (``Reprint.series_name``) can resolve to + # more rows than the dump held; only a shortfall is a real drop. + if len(pks) < len(raw): dropped = len(raw) - len(pks) report.note_skipped( "settings_filters", diff --git a/codex/user_data/schema.sql b/codex/user_data/schema.sql index 07a2916ea..6b47b1348 100644 --- a/codex/user_data/schema.sql +++ b/codex/user_data/schema.sql @@ -126,6 +126,7 @@ CREATE TABLE IF NOT EXISTS settings_filters ( monochrome TEXT NOT NULL DEFAULT '[]', original_format TEXT NOT NULL DEFAULT '[]', reading_direction TEXT NOT NULL DEFAULT '[]', + reprints TEXT NOT NULL DEFAULT '[]', series_groups TEXT NOT NULL DEFAULT '[]', stories TEXT NOT NULL DEFAULT '[]', story_arcs TEXT NOT NULL DEFAULT '[]', diff --git a/codex/user_data/serializers.py b/codex/user_data/serializers.py index 37bec6d4e..2e56b846e 100644 --- a/codex/user_data/serializers.py +++ b/codex/user_data/serializers.py @@ -27,6 +27,7 @@ encode_identifier, identifier_for_browse_collection, tag_model_for_filter, + tag_name_field_for_filter, ) @@ -237,9 +238,9 @@ def _rewrite_filter_value(column: str, value: Any) -> Any: model = tag_model_for_filter(column) if model is None: return value - # Every tag model inherits ``NamedModel.name``; missing PKs drop - # silently rather than raise. - return list(model.objects.filter(pk__in=value).values_list("name", flat=True)) + # Missing PKs drop silently rather than raise. + name_field = tag_name_field_for_filter(column) + return list(model.objects.filter(pk__in=value).values_list(name_field, flat=True)) def serialize_settings_filters( diff --git a/codex/user_data/store.py b/codex/user_data/store.py index 2b3ca83e1..72975fccf 100644 --- a/codex/user_data/store.py +++ b/codex/user_data/store.py @@ -42,6 +42,19 @@ SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_version' """ +_TABLE_NAMES_SQL: Final[str] = """\ +SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' +""" + + +def _add_column_clause(column: sqlite3.Row) -> str: + """Render one ``PRAGMA table_info`` row as an ADD COLUMN clause.""" + clause = f"{column['name']} {column['type']}" + if column["notnull"]: + clause += " NOT NULL" + if column["dflt_value"] is not None: + clause += f" DEFAULT {column['dflt_value']}" + return clause class SidecarStore: @@ -93,6 +106,39 @@ def _open_connection(self) -> sqlite3.Connection: conn.execute(pragma) return conn + @staticmethod + def _reconcile_columns(conn: sqlite3.Connection, ddl: str) -> None: + """ + Add columns a release introduced to a sidecar that predates them. + + ``schema.sql`` is entirely ``CREATE TABLE IF NOT EXISTS``, so + re-running it against an existing file adds nothing and every + later upsert naming a new column fails with "no such column". + The wanted columns are read back out of a throwaway database + built from the current schema so they derive from the schema + file instead of a hand-kept list that would rot the same way. + """ + reference = sqlite3.connect(":memory:") + reference.row_factory = sqlite3.Row + try: + reference.executescript(ddl) + for table_row in reference.execute(_TABLE_NAMES_SQL).fetchall(): + table = table_row["name"] + # PRAGMA takes no bind parameters; the table names come + # from the schema file, not from user input. + have = { + column["name"] + for column in conn.execute(f"PRAGMA table_info({table})") + } + for column in reference.execute(f"PRAGMA table_info({table})"): + if column["name"] in have: + continue + clause = _add_column_clause(column) + conn.execute(f"ALTER TABLE {table} ADD COLUMN {clause}") + logger.info(f"Sidecar schema: added {table}.{column['name']}") + finally: + reference.close() + def _ensure_schema(self, conn: sqlite3.Connection) -> None: """Apply ``schema.sql`` and stamp the version row once per process.""" if self._schema_applied: @@ -103,6 +149,8 @@ def _ensure_schema(self, conn: sqlite3.Connection) -> None: ddl = _SCHEMA_PATH.read_text(encoding="utf-8") with conn: conn.executescript(ddl) + if not self._is_memory: + self._reconcile_columns(conn, ddl) conn.execute( "INSERT OR IGNORE INTO schema_version (version) VALUES (?)", (SCHEMA_VERSION,), diff --git a/codex/views/admin/onlinetag.py b/codex/views/admin/onlinetag.py index c0b0bce32..ad2c84fe3 100644 --- a/codex/views/admin/onlinetag.py +++ b/codex/views/admin/onlinetag.py @@ -100,7 +100,6 @@ def post(self, request): sources=tuple(data["sources"]), mode=data["mode"], prompts_mode=data["prompts_mode"], - auto_threshold=float(data.get("auto_threshold", 0.85)), delete_original=delete_original, merge_all_sources=merge_all_sources, rename=rename, @@ -137,8 +136,8 @@ def _resume_task_params(resume: dict) -> dict: ``sources`` round-trips through JSON as a list; the task wants a tuple. A resume descriptor persists to a file-based cache, so one written by an older Codex may carry keys a task field no longer accepts (e.g. the removed - ``effort`` knob) — drop unknown keys so resuming across an upgrade rebuilds - the task instead of raising ``TypeError``. + ``auto_threshold`` knob) — drop unknown keys so resuming across an upgrade + rebuilds the task instead of raising ``TypeError``. """ params = dict(resume.get("params") or {}) params["sources"] = tuple(params.get("sources") or ()) diff --git a/codex/views/browser/choices.py b/codex/views/browser/choices.py index 6fda131ba..8f5ed6b64 100644 --- a/codex/views/browser/choices.py +++ b/codex/views/browser/choices.py @@ -18,8 +18,9 @@ ) from codex.models.age_rating import AgeRating, AgeRatingMetron from codex.models.identifier import IdentifierSource -from codex.models.named import Universe +from codex.models.named import Reprint, Universe from codex.serializers.browser.choices import ( + REPRINT_LABEL_FIELDS, BrowserChoicesFilterSerializer, BrowserFilterChoicesSerializer, ) @@ -267,7 +268,11 @@ def _get_m2m_field_choices(self, model, comic_qs, rel): # Choices qs = self.get_m2m_field_query(model, comic_qs) values = ["pk", "name"] - if qs.model == Universe: + if qs.model == Reprint: + # No ``name`` column at all — BrowserChoicesReprintPkSerializer + # composes the label from these. + values = ["pk", *REPRINT_LABEL_FIELDS] + elif qs.model == Universe: values.append("designation") elif qs.model == AgeRatingMetron: # AgeRatingMetron.Meta.ordering = ("index",), but ``.distinct()`` diff --git a/codex/views/browser/columns.py b/codex/views/browser/columns.py index b8d5aa1c8..a37a705ec 100644 --- a/codex/views/browser/columns.py +++ b/codex/views/browser/columns.py @@ -19,12 +19,13 @@ When, ) from django.db.models.fields import CharField -from django.db.models.functions import Concat +from django.db.models.functions import Cast, Concat from codex.choices.browser import ( BROWSER_TABLE_COLUMNS, BROWSER_TABLE_DEFAULT_COLUMNS, ) +from codex.models.collections import Volume from codex.models.favorite import FAVORITE_MODEL_COLLECTIONS, Favorite from codex.models.functions import JsonGroupArray @@ -71,6 +72,37 @@ output_field=CharField(), ) +# Volume numbers this wide are rendered as a year rather than a volume. +VOLUME_YEAR_RANGE = (10 ** (Volume.YEAR_LEN - 1), 10**Volume.YEAR_LEN - 1) +_REPRINT_VOLUME_STR = Cast("reprints__volume_number", output_field=CharField()) +_M2M_REPRINT_EXPR = Concat( + # Mirror of ``Reprint.name`` in SQL: only the columns the reprint + # carries contribute. ``volume_number`` renders through + # ``Volume.to_str``'s rule — four digits are a year "(1999)", + # anything else a volume "v3". + F("reprints__series_name"), + Case( + When(reprints__volume_number__isnull=True, then=Value("")), + When( + reprints__volume_number__range=VOLUME_YEAR_RANGE, + then=Concat(Value(" ("), _REPRINT_VOLUME_STR, Value(")")), + ), + default=Concat(Value(" v"), _REPRINT_VOLUME_STR), + output_field=CharField(), + ), + Case( + When(reprints__issue="", then=Value("")), + default=Concat(Value(" #"), F("reprints__issue")), + output_field=CharField(), + ), + Case( + When(reprints__language="", then=Value("")), + default=Concat(Value(" ("), F("reprints__language"), Value(")")), + output_field=CharField(), + ), + output_field=CharField(), +) + _M2M_COLUMN_PATHS = MappingProxyType( { "characters": "characters__name", @@ -78,6 +110,7 @@ "genres": "genres__name", "identifiers": _M2M_IDENTIFIER_EXPR, "locations": "locations__name", + "reprints": _M2M_REPRINT_EXPR, "series_groups": "series_groups__name", "stories": "stories__name", "story_arcs": "story_arc_numbers__story_arc__name", @@ -98,6 +131,7 @@ { "credits": Q(credits__person__name__gt=""), "identifiers": Q(identifiers__id_type__gt="") | Q(identifiers__key__gt=""), + "reprints": Q(reprints__series_name__gt=""), } ) diff --git a/codex/views/browser/const.py b/codex/views/browser/const.py index 0e711d110..29f64ab5d 100644 --- a/codex/views/browser/const.py +++ b/codex/views/browser/const.py @@ -16,6 +16,7 @@ "monochrome", "original_format", "reading_direction", + "reprints", "series_groups", "stories", "story_arcs", diff --git a/codex/views/browser/filters/filter.py b/codex/views/browser/filters/filter.py index 669e5f7ed..67a5fa618 100644 --- a/codex/views/browser/filters/filter.py +++ b/codex/views/browser/filters/filter.py @@ -24,6 +24,7 @@ "genres", "identifier_source", "locations", + "reprints", "series_groups", "stories", "story_arcs", diff --git a/codex/views/browser/filters/search/field/column.py b/codex/views/browser/filters/search/field/column.py index c1d4ddc1b..7f8b47489 100644 --- a/codex/views/browser/filters/search/field/column.py +++ b/codex/views/browser/filters/search/field/column.py @@ -9,6 +9,7 @@ _FIELD_TO_REL_SPAN_MAP = MappingProxyType( { + "alternate_series": "reprints__series_name", "role": "credits__role__name", "credits": "credits__person__name", "identifiers": "identifiers__key", diff --git a/codex/views/browser/intersections.py b/codex/views/browser/intersections.py index 1c1552ac5..c724d6f8e 100644 --- a/codex/views/browser/intersections.py +++ b/codex/views/browser/intersections.py @@ -35,7 +35,12 @@ Series, Volume, ) -from codex.views.browser.columns import fk_name_columns, m2m_columns +from codex.models.named import Reprint +from codex.views.browser.columns import ( + VOLUME_YEAR_RANGE, + fk_name_columns, + m2m_columns, +) from codex.views.const import MODEL_REL_MAP # Comic FK column → collection model. Used to correlate the intersection @@ -88,6 +93,15 @@ def _format_identifier(source_name: str | None, id_type: str, key: str) -> str: return f"{id_type}:{key}" +# Positional order matches :meth:`Reprint.compose_name`'s signature. +_REPRINT_VALUE_RELS = ( + "reprints__series_name", + "reprints__volume_number", + "reprints__issue", + "reprints__language", +) + + # Comic field paths for scalar / FK-name columns that participate in # collection-row intersection. Keys mirror the column registry; values are # Django ORM paths from Comic. The intersection path runs only for @@ -202,8 +216,8 @@ def compute_collection_intersections( ) # Simple-M2M columns share one UNION-ALL query (metadata-view - # pattern). Composite shapes (credits / identifiers / universes / - # story_arcs) keep their per-column helpers. + # pattern). Composite shapes (credits / identifiers / reprints / + # universes / story_arcs) keep their per-column helpers. _compute_simple_m2m_intersections_batched( m2m_cols, collection_pks, comic_to_collection, counts, result ) @@ -347,9 +361,10 @@ def _compute_batched_scalars( # the target model's ``name`` field. Eligible for the through-table # batched union (see ``_compute_simple_m2m_intersections_batched``). # ``universes`` (composite ``name:designation``), ``credits`` -# (``Person (Role)``), ``identifiers`` (``[source:]type:key``), and -# ``story_arcs`` (StoryArcNumber → StoryArc indirection) need bespoke -# SQL and stay on per-column helpers. +# (``Person (Role)``), ``identifiers`` (``[source:]type:key``), +# ``reprints`` (no ``name`` column at all — the label composes from +# four), and ``story_arcs`` (StoryArcNumber → StoryArc indirection) +# need bespoke SQL and stay on per-column helpers. _SIMPLE_M2M_BATCH_FIELDS: frozenset[str] = frozenset( { "characters", @@ -474,7 +489,7 @@ def _compute_m2m_intersection( Simple M2M columns route through ``_compute_simple_m2m_intersections_batched`` instead — this helper covers the bespoke shapes (credits / identifiers / - universes / story_arcs). + reprints / universes / story_arcs). """ if col == "credits": _compute_credits_intersection( @@ -486,6 +501,11 @@ def _compute_m2m_intersection( collection_pks, comic_to_collection, counts, result ) return + if col == "reprints": + _compute_reprints_intersection( + collection_pks, comic_to_collection, counts, result + ) + return rel = _M2M_NAME_RELATIONS.get(col) if rel is None: @@ -741,6 +761,38 @@ def _build_identifiers_intersection_sort_sql( return _IntersectionSortRawSQL(sql, []) +def _build_reprints_intersection_sort_sql( + collection_model: type[BrowserCollectionModel], +) -> RawSQL | None: + """Reprints render the same composed label the table cell shows.""" + correlation = _comic_correlation_sql(collection_model) + if correlation is None: + return None + # Mirror of ``Reprint.compose_name``: only the columns the reprint + # carries contribute, and a four-digit volume number is a year. The + # year bounds are the only bound parameters any of these + # intersection subqueries take. + inner = """ + SELECT + r.id AS target_id, + r.series_name + || CASE + WHEN r.volume_number IS NULL THEN '' + WHEN r.volume_number BETWEEN %s AND %s + THEN ' (' || r.volume_number || ')' + ELSE ' v' || r.volume_number + END + || CASE WHEN r.issue = '' THEN '' ELSE ' #' || r.issue END + || CASE WHEN r.language = '' THEN '' ELSE ' (' || r.language || ')' END + AS display_name + FROM codex_reprint r + INNER JOIN codex_comic_reprints th ON th.reprint_id = r.id + INNER JOIN codex_comic c ON c.id = th.comic_id + """ + sql = _wrap_intersection_sort(inner, correlation) + return _IntersectionSortRawSQL(sql, list(VOLUME_YEAR_RANGE)) + + def _build_story_arcs_intersection_sort_sql( collection_model: type[BrowserCollectionModel], ) -> RawSQL | None: @@ -893,6 +945,18 @@ def scalar_intersection_sort_expr( return _IntersectionSortRawSQL(sql, []) +# Composite-M2M columns whose display string needs its own SELECT. +_COMPOSITE_M2M_SORT_BUILDERS = MappingProxyType( + { + "credits": _build_credits_intersection_sort_sql, + "identifiers": _build_identifiers_intersection_sort_sql, + "reprints": _build_reprints_intersection_sort_sql, + "story_arcs": _build_story_arcs_intersection_sort_sql, + "universes": _build_universes_intersection_sort_sql, + } +) + + def m2m_intersection_sort_expr( collection_model: type[BrowserCollectionModel], column: str ) -> RawSQL | None: @@ -904,15 +968,8 @@ def m2m_intersection_sort_expr( """ if column in _SIMPLE_M2M_FIELDS: return _build_simple_m2m_intersection_sort_sql(collection_model, column) - if column == "universes": - return _build_universes_intersection_sort_sql(collection_model) - if column == "credits": - return _build_credits_intersection_sort_sql(collection_model) - if column == "identifiers": - return _build_identifiers_intersection_sort_sql(collection_model) - if column == "story_arcs": - return _build_story_arcs_intersection_sort_sql(collection_model) - return None + builder = _COMPOSITE_M2M_SORT_BUILDERS.get(column) + return builder(collection_model) if builder else None def _compute_credits_intersection( @@ -995,3 +1052,34 @@ def _compute_identifiers_intersection( if cnt == total ) result[gpk]["identifiers"] = names + + +def _compute_reprints_intersection( + collection_pks: list[int], + comic_to_collection: str, + counts: dict[int, int], + result: dict[int, dict[str, Any]], +) -> None: + """Reprints have no ``name`` column; compose the label from their four.""" + rows = ( + Comic.objects.filter(**{f"{comic_to_collection}__in": collection_pks}) + .filter(reprints__series_name__gt="") + .values(comic_to_collection, *_REPRINT_VALUE_RELS) + .annotate(cnt=Count("pk", distinct=True)) + ) + per_collection: dict[int, list[tuple[tuple, int]]] = defaultdict(list) + for row in rows: + per_collection[row[comic_to_collection]].append( + (tuple(row[rel] for rel in _REPRINT_VALUE_RELS), row["cnt"]) + ) + for gpk in collection_pks: + total = counts.get(gpk, 0) + if not total: + result[gpk]["reprints"] = [] + continue + names = sorted( + Reprint.compose_name(*parts) + for parts, cnt in per_collection.get(gpk, ()) + if cnt == total + ) + result[gpk]["reprints"] = names diff --git a/codex/views/browser/metadata/const.py b/codex/views/browser/metadata/const.py index 3fa20f499..cbda90372 100644 --- a/codex/views/browser/metadata/const.py +++ b/codex/views/browser/metadata/const.py @@ -10,6 +10,7 @@ from codex.models.named import ( Character, Credit, + Reprint, SeriesGroup, StoryArcNumber, Team, @@ -75,6 +76,12 @@ "select": ("source",), "only": ("source", "key", "url"), }, + # Reprint.name is a property composed from all four columns; the + # default ``only=("name", ...)`` raises FieldDoesNotExist. + Reprint: { + "select": ("identifier",), + "only": ("series_name", "volume_number", "issue", "language", "identifier"), + }, Universe: {"only": ("name", "designation", "identifier")}, SeriesGroup: { "select": (), diff --git a/codex/views/browser/saved_settings.py b/codex/views/browser/saved_settings.py index 5bd68e726..a7a56f245 100644 --- a/codex/views/browser/saved_settings.py +++ b/codex/views/browser/saved_settings.py @@ -7,7 +7,7 @@ from rest_framework.serializers import BaseSerializer from codex.models.age_rating import AgeRating, AgeRatingMetron -from codex.models.base import NamedModel +from codex.models.base import BaseModel from codex.models.identifier import IdentifierSource from codex.models.named import ( Character, @@ -17,6 +17,7 @@ Language, Location, OriginalFormat, + Reprint, SeriesGroup, Story, StoryArc, @@ -44,7 +45,8 @@ SettingsBaseView, ) -# Map filter field names to the model whose PKs they store. +# Map filter field names to the model whose PKs they store. Most are +# NamedModels; ``Reprint`` is not, so the validator only leans on the pk. _FILTER_FK_MODEL_MAP = MappingProxyType( { "age_rating_metron": AgeRatingMetron, @@ -57,6 +59,7 @@ "language": Language, "locations": Location, "original_format": OriginalFormat, + "reprints": Reprint, "series_groups": SeriesGroup, "stories": Story, "story_arcs": StoryArc, @@ -69,7 +72,7 @@ def _validate_filter_field( - filters_data: dict, field: str, model: type[NamedModel], warnings: list[str] + filters_data: dict, field: str, model: type[BaseModel], warnings: list[str] ): """Validate one filter field by existing models.""" pk_list = filters_data.get(field) diff --git a/codex/views/const.py b/codex/views/const.py index 525b5a104..55bfbe901 100644 --- a/codex/views/const.py +++ b/codex/views/const.py @@ -31,6 +31,7 @@ Location, OriginalFormat, Publisher, + Reprint, ScanInfo, Series, SeriesGroup, @@ -166,6 +167,7 @@ Language, Location, OriginalFormat, + Reprint, SeriesGroup, ScanInfo, StoryArc, diff --git a/frontend/src/components/browser/table/browser-table-cell.vue b/frontend/src/components/browser/table/browser-table-cell.vue index a5e362134..b3dbabd54 100644 --- a/frontend/src/components/browser/table/browser-table-cell.vue +++ b/frontend/src/components/browser/table/browser-table-cell.vue @@ -66,6 +66,7 @@ const M2M_COLUMNS = new Set([ "genres", "identifiers", "locations", + "reprints", "series_groups", "stories", "story_arcs", diff --git a/frontend/src/components/browser/table/browser-table-column-picker.vue b/frontend/src/components/browser/table/browser-table-column-picker.vue index 895de0d79..1ca47b4ef 100644 --- a/frontend/src/components/browser/table/browser-table-column-picker.vue +++ b/frontend/src/components/browser/table/browser-table-column-picker.vue @@ -126,6 +126,7 @@ const _CATEGORIES = Object.freeze([ "publisher_name", "imprint_name", "series_name", + "reprints", "volume_name", "issue", "name", diff --git a/frontend/src/components/browser/toolbars/top/filter-sub-menu.vue b/frontend/src/components/browser/toolbars/top/filter-sub-menu.vue index cce61a184..f36443b60 100644 --- a/frontend/src/components/browser/toolbars/top/filter-sub-menu.vue +++ b/frontend/src/components/browser/toolbars/top/filter-sub-menu.vue @@ -126,6 +126,9 @@ import { useBrowserStore } from "@/stores/browser"; const FILTER_TITLE_OVERRIDES = { ageRatingMetron: "Age Rating", + // "Reprints" reads as reprint editions; these are alternate and + // localized series names. + reprints: "Alternate Series", }; // Tab values are the filter keys; "Standardized" is the default tab. const AGE_RATING_DEFAULT_TAB = "ageRatingMetron"; diff --git a/frontend/src/components/metadata/edit-mode/edit-panel.vue b/frontend/src/components/metadata/edit-mode/edit-panel.vue index d18b3fac6..d1af776da 100644 --- a/frontend/src/components/metadata/edit-mode/edit-panel.vue +++ b/frontend/src/components/metadata/edit-mode/edit-panel.vue @@ -847,6 +847,93 @@ +
Alternate Series
+ + + + + + + + + + + + + + + + + + × + + + + + +
+
+ + + Add Alternate Series + +
+ + + Clear All + +
+
Identifiers
@@ -1150,6 +1237,7 @@ export default { creditsByRole: {}, storyArcNames: [], universes: [], + reprints: [], identifiers: [], patch: { publisher: "", @@ -1270,6 +1358,7 @@ export default { credits: this.creditsSerialized, storyArcs: JSON.stringify(this.storyArcNames), universes: JSON.stringify(this.universes), + reprints: JSON.stringify(this.reprints), identifiers: JSON.stringify(this.identifiers), }; }, @@ -1303,6 +1392,12 @@ export default { ) { changed.add("universes"); } + if ( + this.clearedFields.has("reprints") || + cur.reprints !== orig.reprints + ) { + changed.add("reprints"); + } if ( this.clearedFields.has("identifiers") || cur.identifiers !== orig.identifiers @@ -1593,6 +1688,8 @@ export default { this.storyArcNames = []; } else if (field === "universes") { this.universes = []; + } else if (field === "reprints") { + this.reprints = []; } else if (field === "identifiers") { this.identifiers = []; } @@ -1703,6 +1800,18 @@ export default { })); } + // Alternate series — the panel renders the composed `name`, but the + // editor rebuilds comicbox's nested reprint from the flat columns. + if (this.md.reprints?.length) { + this.reprints = this.md.reprints.map((reprint) => ({ + series_name: reprint.seriesName || "", + volume: + reprint.volumeNumber == null ? "" : String(reprint.volumeNumber), + issue: reprint.issue || "", + language: reprint.language || null, + })); + } + /* Identifiers — shaped as {pk, source, type, code, displayName, url} */ if (this.md.identifiers?.length) { this.identifiers = this.md.identifiers.map((id) => ({ @@ -1732,6 +1841,22 @@ export default { this.origSnapshot = { ...this.currentSnapshot }; }, + buildReprints() { + // comicbox nests a reprint's parts under series/volume; codex stores + // them flat. A row without a series name names nothing, so it's + // dropped rather than written as an empty alternate. + const reprints = []; + for (const row of this.reprints) { + if (!row.series_name) continue; + const reprint = { series: { name: row.series_name } }; + const number = parseInt(row.volume, 10); + if (!isNaN(number)) reprint.volume = { number }; + if (row.issue) reprint.issue = row.issue; + if (row.language) reprint.language = row.language; + reprints.push(reprint); + } + return reprints; + }, buildPatch() { // Returns { patch, deleteKeys }. A merge write can only add or replace // values — comicbox prunes empty patch values on schema load — so @@ -1846,6 +1971,13 @@ export default { } } + // Alternate series — only include if changed + if (changed.has("reprints")) { + const reprints = this.buildReprints(); + if (reprints.length) cbPatch.reprints = reprints; + else deleteKeys.push("reprints"); + } + // Identifiers — only include if changed if (changed.has("identifiers")) { if (cleared.has("identifiers") || this.identifiers.length === 0) { diff --git a/frontend/src/components/online-tag/prompt-popup.vue b/frontend/src/components/online-tag/prompt-popup.vue index 5d425c0bb..6a56cba6b 100644 --- a/frontend/src/components/online-tag/prompt-popup.vue +++ b/frontend/src/components/online-tag/prompt-popup.vue @@ -60,6 +60,15 @@ {{ Math.round(candidate.score * 100) }}% + +
+ a.k.a. {{ candidate.summary.altSeries.join(", ") }} +
({ md: undefined, @@ -107,13 +114,7 @@ export const useMetadataStore = defineStore("metadata", { this.md = undefined; }, getTagName(key) { - var tagName; - if (key === "storyArcNumbers") { - tagName = "Story Arcs"; - } else { - tagName = capitalCase(key); - } - return tagName; + return TAG_NAMES[key] || capitalCase(key); }, mapTag(tagSource, keys, filter = undefined) { const tagMap = {}; diff --git a/frontend/tests/unit/browser-table-cell.test.js b/frontend/tests/unit/browser-table-cell.test.js index 1fc036fc3..6517051dd 100644 --- a/frontend/tests/unit/browser-table-cell.test.js +++ b/frontend/tests/unit/browser-table-cell.test.js @@ -87,6 +87,20 @@ describe("BrowserTableCell — list / M2M", () => { }); expect(wrapper.find(".tableListCell").text()).toBe(""); }); + + it("joins the composed alternate-series labels", () => { + /* + * ``reprints`` has to be listed in ``M2M_COLUMNS`` or the array + * falls through to the text cell and stringifies badly. + */ + const wrapper = mountCell({ + column: "reprints", + row: { reprints: ["Kapitän Wissenschaft (de)", "Capitan Sciencia v1"] }, + }); + expect(wrapper.find(".tableListCell").text()).toBe( + "Kapitän Wissenschaft (de), Capitan Sciencia v1", + ); + }); }); describe("BrowserTableCell — bool", () => { diff --git a/frontend/tests/unit/browser-table-column-picker.test.js b/frontend/tests/unit/browser-table-column-picker.test.js index 8f0f192cf..461d989ce 100644 --- a/frontend/tests/unit/browser-table-column-picker.test.js +++ b/frontend/tests/unit/browser-table-column-picker.test.js @@ -17,9 +17,9 @@ import { smartInsertIndex } from "@/components/browser/table/browser-table-colum * Canonical-rank reference for the cases below — kept here so the * test reads as a story rather than an opaque number puzzle: * cover=0, favorite=1, publisher_name=2, imprint_name=3, - * series_name=4, volume_name=5, issue=6, name=7, child_count=8, - * file_name=9, size=10, page_count=11, file_type=12, - * year=13, … + * series_name=4, reprints=5, volume_name=6, issue=7, name=8, + * child_count=9, file_name=10, size=11, page_count=12, + * file_type=13, year=14, … */ describe("smartInsertIndex — canonical-order drafts", () => { diff --git a/frontend/tests/unit/edit-panel-reprints.test.js b/frontend/tests/unit/edit-panel-reprints.test.js new file mode 100644 index 000000000..ccd582ae3 --- /dev/null +++ b/frontend/tests/unit/edit-panel-reprints.test.js @@ -0,0 +1,228 @@ +/* + * Tests for the tag edit panel's Alternate Series section. + * + * The metadata endpoint ships each reprint's flat codex columns + * ({pk, name, seriesName, volumeNumber, issue, language, url}) alongside the + * composed chip `name`, so the editor seeds its rows from the parts instead + * of parsing the label back apart. On save the parts have to be re-nested + * into comicbox's reprint shape ({series: {name}, volume: {number}, issue, + * language}), and clearing the section has to travel as the "reprints" + * delete key — a patch can only add or replace. + * + * Volume and language are MetronInfo-only: ComicInfo's AlternateSeries / + * AlternateNumber / AlternateCount carry neither. + */ +import { createTestingPinia } from "@pinia/testing"; +import { flushPromises, mount } from "@vue/test-utils"; +import { describe, expect, test } from "vitest"; + +import EditPanel from "@/components/metadata/edit-mode/edit-panel.vue"; +import vuetify from "@/plugins/vuetify"; + +const DISABLED_TIP = "Not supported by selected metadata formats"; + +// Absent parts arrive as the model's own empty defaults: "" for the +// strings, null for the volume number. +const SHAPED_REPRINTS = Object.freeze([ + { + pk: 7, + name: "Kapitän Wissenschaft (de)", + seriesName: "Kapitän Wissenschaft", + volumeNumber: null, + issue: "", + language: "de", + }, + { + pk: 8, + name: "Capitan Sciencia v1 #3", + seriesName: "Capitan Sciencia", + volumeNumber: 1, + issue: "3", + language: "", + }, +]); + +async function mountPanel({ formats = ["METRON_INFO"], md = {} } = {}) { + const pinia = createTestingPinia({ + initialState: { + metadata: { md }, + admin: { taggingDefaults: { defaultFormats: formats } }, + browser: { settings: { twentyFourHourTime: false } }, + }, + }); + const wrapper = mount(EditPanel, { + props: { book: { pk: 1, collection: "comics", ids: [1] } }, + global: { plugins: [pinia, vuetify] }, + }); + await flushPromises(); + return wrapper; +} + +function findButton(wrapper, label) { + return wrapper.findAll("button").find((b) => b.text().includes(label)); +} + +describe("EditPanel alternate series rows", () => { + test("seeds one row per reprint from the flat columns", async () => { + const wrapper = await mountPanel({ md: { reprints: SHAPED_REPRINTS } }); + + expect(wrapper.vm.reprints).toStrictEqual([ + { + series_name: "Kapitän Wissenschaft", + volume: "", + issue: "", + language: "de", + }, + { + series_name: "Capitan Sciencia", + volume: "1", + issue: "3", + language: null, + }, + ]); + expect(wrapper.vm.hasChanges).toBe(false); + }); + + test("leaves the rows empty when the comic has no reprints", async () => { + const wrapper = await mountPanel({ md: { reprints: [] } }); + expect(wrapper.vm.reprints).toStrictEqual([]); + }); + + test("the add button appends a blank row", async () => { + const wrapper = await mountPanel(); + await findButton(wrapper, "Add Alternate Series").trigger("click"); + + expect(wrapper.vm.reprints).toStrictEqual([ + { series_name: "", volume: "", issue: "", language: null }, + ]); + }); +}); + +describe("EditPanel alternate series patch", () => { + test("re-nests the parts into comicbox reprints", async () => { + const wrapper = await mountPanel({ md: { reprints: SHAPED_REPRINTS } }); + wrapper.vm.reprints[0].issue = "1"; + await flushPromises(); + + const { patch, deleteKeys } = wrapper.vm.buildPatch(); + expect(patch).toStrictEqual({ + reprints: [ + { + series: { name: "Kapitän Wissenschaft" }, + issue: "1", + language: "de", + }, + { + series: { name: "Capitan Sciencia" }, + volume: { number: 1 }, + issue: "3", + }, + ], + }); + expect(deleteKeys).toStrictEqual([]); + }); + + test("an untouched section stays out of the patch entirely", async () => { + const wrapper = await mountPanel({ md: { reprints: SHAPED_REPRINTS } }); + const { patch, deleteKeys } = wrapper.vm.buildPatch(); + + expect(patch).not.toHaveProperty("reprints"); + expect(deleteKeys).not.toContain("reprints"); + }); + + test("a newly added row travels with only the parts it carries", async () => { + const wrapper = await mountPanel(); + wrapper.vm.reprints.push({ + series_name: "Capitaine Science", + volume: "", + issue: "", + language: null, + }); + await flushPromises(); + + const { patch } = wrapper.vm.buildPatch(); + expect(patch.reprints).toStrictEqual([ + { series: { name: "Capitaine Science" } }, + ]); + }); + + test("removing a row drops it from the patch", async () => { + const wrapper = await mountPanel({ md: { reprints: SHAPED_REPRINTS } }); + wrapper.vm.reprints.splice(0, 1); + await flushPromises(); + + const { patch } = wrapper.vm.buildPatch(); + expect(patch.reprints).toHaveLength(1); + expect(patch.reprints[0].series.name).toBe("Capitan Sciencia"); + }); + + test("a row with no series name names nothing and is dropped", async () => { + const wrapper = await mountPanel(); + wrapper.vm.reprints.push({ + series_name: "", + volume: "2", + issue: "", + language: "fr", + }); + await flushPromises(); + + const { patch, deleteKeys } = wrapper.vm.buildPatch(); + expect(patch).not.toHaveProperty("reprints"); + expect(deleteKeys).toStrictEqual(["reprints"]); + }); + + test("clearing the section deletes the comicbox reprints key", async () => { + const wrapper = await mountPanel({ md: { reprints: SHAPED_REPRINTS } }); + wrapper.vm.clearField("reprints"); + await flushPromises(); + + const { patch, deleteKeys } = wrapper.vm.buildPatch(); + expect(wrapper.vm.reprints).toStrictEqual([]); + expect(deleteKeys).toStrictEqual(["reprints"]); + expect(patch).not.toHaveProperty("reprints"); + }); + + test("emptying every row also deletes rather than patching nothing", async () => { + const wrapper = await mountPanel({ md: { reprints: SHAPED_REPRINTS } }); + wrapper.vm.reprints = []; + await flushPromises(); + + const { patch, deleteKeys } = wrapper.vm.buildPatch(); + expect(deleteKeys).toStrictEqual(["reprints"]); + expect(patch).not.toHaveProperty("reprints"); + }); +}); + +describe("EditPanel alternate series format support", () => { + test("ComicInfo disables the MetronInfo-only volume and language", async () => { + const wrapper = await mountPanel({ + formats: ["COMIC_INFO"], + md: { reprints: SHAPED_REPRINTS }, + }); + const volume = wrapper + .findAll("input") + .find((i) => i.element.value === "1"); + + expect(volume.element.disabled).toBe(true); + expect(volume.element.closest("td").getAttribute("title")).toBe( + DISABLED_TIP, + ); + expect(wrapper.vm.isFieldDisabled("reprint_language")).toBe(true); + // The series name and issue do persist to ComicInfo's Alternates. + expect(wrapper.vm.isFieldDisabled("reprints")).toBe(false); + }); + + test("MetronInfo enables every part", async () => { + const wrapper = await mountPanel({ + formats: ["METRON_INFO"], + md: { reprints: SHAPED_REPRINTS }, + }); + const btn = findButton(wrapper, "Add Alternate Series"); + + expect(btn.element.disabled).toBe(false); + expect(wrapper.vm.isFieldDisabled("reprint_volume")).toBe(false); + expect(wrapper.vm.isFieldDisabled("reprint_language")).toBe(false); + }); +}); + +export default {}; diff --git a/frontend/tests/unit/metadata-reprints.test.js b/frontend/tests/unit/metadata-reprints.test.js new file mode 100644 index 000000000..3e41be1b9 --- /dev/null +++ b/frontend/tests/unit/metadata-reprints.test.js @@ -0,0 +1,88 @@ +/* + * The metadata pane's alternate-series row. + * + * The backend composes ``Reprint.name`` from whichever of series name, + * volume, issue and language the reprint carries, so the client just + * renders it like any other ``{pk, name, url}`` tag row. Only the row + * label differs from the capital-cased key: "Alternate Series". + * + * Chips route through the ``reprints`` browser filter, the key phase E + * registers. + */ +import { createTestingPinia } from "@pinia/testing"; +import { mount } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, test } from "vitest"; + +import MetadataTags from "@/components/metadata/metadata-tags.vue"; +import vuetify from "@/plugins/vuetify"; +import { useBrowserStore } from "@/stores/browser"; +import { useMetadataStore } from "@/stores/metadata"; + +const REPRINTS = Object.freeze([ + { pk: 7, name: "Kapitän Wissenschaft" }, + { pk: 8, name: "Capitan Sciencia v1 (es)" }, +]); + +describe("metadata store alternate series row", () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + test("labels the reprints row Alternate Series", () => { + const store = useMetadataStore(); + // Copied: the tags getter sorts the row in place. + store.md = { reprints: [...REPRINTS] }; + + const row = store.tags["Alternate Series"]; + expect(row.filter).toBe("reprints"); + expect(row.tags.map((tag) => tag.name)).toStrictEqual([ + "Capitan Sciencia v1 (es)", + "Kapitän Wissenschaft", + ]); + expect(store.tags.Reprints).toBeUndefined(); + }); + + test("omits the row when there are no reprints", () => { + const store = useMetadataStore(); + store.md = { reprints: [] }; + + expect(store.tags["Alternate Series"]).toBeUndefined(); + }); +}); + +describe("alternate series chips", () => { + function mountTags() { + const pinia = createTestingPinia({ + initialState: { + browser: { + settings: { show: {}, filters: {}, topCollection: "publishers" }, + }, + metadata: { md: { collection: "comics", ids: [1] } }, + }, + }); + const wrapper = mount(MetadataTags, { + global: { plugins: [pinia, vuetify] }, + props: { label: "", filter: "reprints", values: REPRINTS }, + }); + return { wrapper, browserStore: useBrowserStore() }; + } + + test("renders the composed names", () => { + const { wrapper } = mountTags(); + const text = wrapper.text(); + expect(text).toContain("Kapitän Wissenschaft"); + expect(text).toContain("Capitan Sciencia v1 (es)"); + }); + + test("clicking filters the browser by the reprint pk", async () => { + const { wrapper, browserStore } = mountTags(); + + await wrapper.findAll(".v-chip")[0].trigger("click"); + + expect(browserStore.routeWithSettings).toHaveBeenCalledWith( + { filters: { reprints: [7] } }, + { name: "browser", params: { collection: "root", pks: "0", page: 1 } }, + ); + }); +}); diff --git a/frontend/tests/unit/prompt-popup.test.js b/frontend/tests/unit/prompt-popup.test.js new file mode 100644 index 000000000..3e4c1887a --- /dev/null +++ b/frontend/tests/unit/prompt-popup.test.js @@ -0,0 +1,130 @@ +/* + * Tests for the Online Tagging match-review popup. + * + * Comicbox scores candidates against alternate series names, so a comic filed + * under a localized title matches a canonical series that looks nothing like + * its filename. The popup shows those aliases so the match is explicable, and + * a pick carries the candidate's volume id so the apply replay can narrow to + * that volume. + */ +import { createTestingPinia } from "@pinia/testing"; +import { mount } from "@vue/test-utils"; +import { describe, expect, test } from "vitest"; + +import OnlineTagPromptPopup from "@/components/online-tag/prompt-popup.vue"; +import vuetify from "@/plugins/vuetify"; +import { useOnlineTagStore } from "@/stores/online-tag"; + +// Render the dialog body inline instead of through v-dialog's overlay/teleport +// (which need browser APIs happy-dom lacks). +const VDialogStub = { name: "VDialog", template: "
" }; + +function candidate(overrides = {}) { + return { + source: "comicvine", + issueId: 42, + summary: { + series: "Captain Science", + issue: "1", + year: 1950, + publisher: "Youthful", + coverUrl: "", + altSeries: [], + }, + score: 0.91, + url: "", + volumeId: null, + ...overrides, + }; +} + +function mountPopup(candidates) { + const pinia = createTestingPinia(); + const wrapper = mount(OnlineTagPromptPopup, { + global: { + plugins: [vuetify, pinia], + stubs: { VDialog: VDialogStub }, + }, + }); + const store = useOnlineTagStore(); + store.pendingPrompts = [ + { + fingerprint: "fp1", + pk: 7, + path: "/comics/kapitan.cbz", + source: "comicvine", + candidates, + }, + ]; + store.promptDialogOpen = true; + return { wrapper, store }; +} + +describe("OnlineTagPromptPopup", () => { + describe("alternate series names", () => { + test("shows the aliases that explain an off-filename match", async () => { + const { wrapper } = mountPopup([ + candidate({ + summary: { + ...candidate().summary, + altSeries: ["Kapitän Wissenschaft", "Capitan Sciencia"], + }, + }), + ]); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain( + "a.k.a. Kapitän Wissenschaft, Capitan Sciencia", + ); + }); + + test("omits the line when the source carries no aliases", async () => { + const { wrapper } = mountPopup([candidate()]); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).not.toContain("a.k.a."); + }); + + test("omits the line for a prompt cached before aliases existed", async () => { + const summary = { ...candidate().summary }; + delete summary.altSeries; + const { wrapper } = mountPopup([candidate({ summary })]); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).not.toContain("a.k.a."); + }); + }); + + describe("pick", () => { + test("passes the chosen candidate's volume id", () => { + const { wrapper, store } = mountPopup([ + candidate(), + candidate({ volumeId: 9876 }), + ]); + + wrapper.vm.pick(store.pendingPrompts[0], 1); + + expect(store.resolvePrompt).toHaveBeenCalledWith( + "fp1", + "choose", + 1, + 9876, + ); + }); + + test("passes null when the source exposes no volume id", () => { + const { wrapper, store } = mountPopup([candidate()]); + + wrapper.vm.pick(store.pendingPrompts[0], 0); + + expect(store.resolvePrompt).toHaveBeenCalledWith( + "fp1", + "choose", + 0, + null, + ); + }); + }); +}); + +export default {}; diff --git a/tests/importer/test_basic.py b/tests/importer/test_basic.py index b13b924c2..5de1d5d6a 100644 --- a/tests/importer/test_basic.py +++ b/tests/importer/test_basic.py @@ -58,6 +58,7 @@ Location, OriginalFormat, Publisher, + Reprint, ScanInfo, Series, SeriesGroup, @@ -81,7 +82,9 @@ PATH = str(LIBRARY_PATH / "test.cbz") PATH_PARENTS = {(str(Path(PATH).parent),)} PATH_PARENTS_QUERY = {(str(Path(PATH).parent),): set()} -COMPLEX_FIELD_NAMES = frozenset({"credits", "story_arc_numbers", "identifiers"}) +COMPLEX_FIELD_NAMES = frozenset( + {"credits", "reprints", "story_arc_numbers", "identifiers"} +) AGGREGATED = MappingProxyType( { FIS: {}, @@ -118,6 +121,10 @@ }, Tagger: {("comicbox dev",): set()}, Publisher: {("Youthful Adventure Stories",): {(None,)}}, + Reprint: { + ("Capitan Sciencia", 1, "", "es"): {(None,)}, + ("Kapitän Wissenschaft", None, "", "de"): {(None,)}, + }, Imprint: { ( "Youthful Adventure Stories", @@ -254,7 +261,10 @@ ) }, "locations": {("The Moon",)}, - # reprints add + "reprints": { + ("Capitan Sciencia", 1, "", "es"), + ("Kapitän Wissenschaft", None, "", "de"), + }, "series_groups": {("science comics",)}, "stories": {("The Beginning",)}, "story_arc_numbers": { @@ -357,6 +367,10 @@ Universe: {("Young Adult Silly Universe", None, "4242")}, Folder: deepcopy(PATH_PARENTS), Publisher: {("Youthful Adventure Stories", None)}, + Reprint: { + ("Capitan Sciencia", 1, "", "es", None), + ("Kapitän Wissenschaft", None, "", "de", None), + }, Imprint: {("Youthful Adventure Stories", "TestImprint", None)}, Series: { ( @@ -377,7 +391,7 @@ 7, ) }, - TOTAL: 41, + TOTAL: 43, }, UPDATE_FKS: { TOTAL: 0, @@ -415,6 +429,10 @@ "genres": {("Science Fiction",)}, "identifiers": {("comicvine", "comic", "145269")}, "locations": {("The Moon",)}, + "reprints": { + ("Capitan Sciencia", 1, "", "es"), + ("Kapitän Wissenschaft", None, "", "de"), + }, "series_groups": {("science comics",)}, "stories": {("The Beginning",)}, "story_arc_numbers": {("c", None), ("d", 1), ("e", 3), ("f", 5)}, @@ -491,6 +509,10 @@ "genres": {("Science Fiction",)}, "identifiers": {("comicvine", "comic", "145269")}, "locations": {("The Moon",)}, + "reprints": { + ("Capitan Sciencia", 1, "", "es"), + ("Kapitän Wissenschaft", None, "", "de"), + }, "series_groups": {("science comics",)}, "stories": {("The Beginning",)}, "story_arc_numbers": {("c", None), ("d", 1), ("e", 3), ("f", 5)}, @@ -516,6 +538,10 @@ "genres": {("Science Fiction",)}, "identifiers": {("comicvine", "comic", "145269")}, "locations": {("The Moon",)}, + "reprints": { + ("Capitan Sciencia", 1, "", "es"), + ("Kapitän Wissenschaft", None, "", "de"), + }, "series_groups": {("science comics",)}, "stories": {("The Beginning",)}, "story_arc_numbers": {("c", None), ("d", 1), ("e", 3), ("f", 5)}, @@ -553,6 +579,7 @@ FIS: {}, FTS_CREATE: { 1: { + "alternate_series": ("Capitan Sciencia", "Kapitän Wissenschaft"), "characters": ("Boy Empirical", "Captain Science"), "collection_title": ("The Big Omnibus",), "country": ("US",), @@ -593,6 +620,7 @@ { FTS_CREATE: { 1: { + "alternate_series": ("Capitan Sciencia", "Kapitän Wissenschaft"), "characters": ("Boy Empirical", "Captain Science"), "collection_title": ("The Big Omnibus",), "country": ("US",), @@ -632,6 +660,7 @@ { FTS_CREATE: { 1: { + "alternate_series": ("Capitan Sciencia", "Kapitän Wissenschaft"), "characters": ("Boy Empirical", "Captain Science"), "collection_title": ("The Big Omnibus",), "country": ("US",), @@ -673,7 +702,7 @@ "volume": -2, } ) -_COMPLEX_KEYS = frozenset({"credits", "identifiers", "story_arc_numbers"}) +_COMPLEX_KEYS = frozenset({"credits", "identifiers", "reprints", "story_arc_numbers"}) _COMICFTS_IGNORE_KEYS = ("comic_id", "updated_at", "created_at") @@ -763,6 +792,18 @@ def _test_comic_creation_field_complex(field_name: str, value): for subval in value.all() ) ) + elif field_name == "reprints": + value = tuple( + sorted( + ( + subval.series_name, + subval.volume_number, + subval.issue, + subval.language, + ) + for subval in value.all() + ) + ) return value @@ -808,6 +849,28 @@ def export_test_fts_creation(values_const: MappingProxyType, comic: Comic): diff_assert(fts_values, comicfts, "COMIC_FTS") +def run_full_import(importer) -> None: + """ + Run every importer phase in order. + + ``ComicImporter.apply()`` wraps the same sequence in + ``importer_pragmas``, whose exit-time ``PRAGMA wal_checkpoint`` raises + ``database table is locked`` inside a ``TestCase``'s wrapping + transaction. Drive the phases directly instead. + """ + importer.read() + importer.query() + importer.create_all_fks() + importer.update_all_fks() + importer.prepare_fk_link_instance_maps() + importer.update_comics() + importer.create_comics() + importer.link() + importer.fail_imports() + importer.delete() + importer.full_text_search() + + class BaseTestImporter(SerializeMixin, TestCase, ABC): lockfile = __file__ diff --git a/tests/importer/test_move_timestamps.py b/tests/importer/test_move_timestamps.py index 7243d1962..0e023b265 100644 --- a/tests/importer/test_move_timestamps.py +++ b/tests/importer/test_move_timestamps.py @@ -32,27 +32,12 @@ StoryArcNumber, Volume, ) -from tests.importer.test_basic import PATH, BaseTestImporter +from tests.importer.test_basic import PATH, BaseTestImporter, run_full_import _FILE_PUBLISHER = "Youthful Adventure Stories" _SOURCE_PUBLISHER = "Throwaway Source Pub" -def _run_full_import(importer) -> None: - """Run every importer phase in order (apply() without pragmas/finish).""" - importer.read() - importer.query() - importer.create_all_fks() - importer.update_all_fks() - importer.prepare_fk_link_instance_maps() - importer.update_comics() - importer.create_comics() - importer.link() - importer.fail_imports() - importer.delete() - importer.full_text_search() - - class TestImporterMoveRestampsSource(BaseTestImporter): """A publisher change re-stamps the collection the comic left.""" @@ -90,7 +75,7 @@ def test_publisher_move_restamps_source(self) -> None: check_metadata_mtime=False, ) importer = ComicImporter(task, logger, LIBRARIAN_QUEUE, Lock(), Event()) - _run_full_import(importer) + run_full_import(importer) # The comic actually moved to the file's publisher... comic = Comic.objects.get(path=PATH) @@ -142,7 +127,7 @@ def test_storyarc_removal_restamps_source(self) -> None: check_metadata_mtime=False, ) importer = ComicImporter(task, logger, LIBRARIAN_QUEUE, Lock(), Event()) - _run_full_import(importer) + run_full_import(importer) # The comic moved to the file's arcs; "Removed Arc" lost it... comic = Comic.objects.get(path=PATH) diff --git a/tests/importer/test_update_all.py b/tests/importer/test_update_all.py index 1ad9c82e2..27966e4e2 100644 --- a/tests/importer/test_update_all.py +++ b/tests/importer/test_update_all.py @@ -40,6 +40,7 @@ Location, OriginalFormat, Publisher, + Reprint, ScanInfo, Series, SeriesGroup, @@ -130,6 +131,10 @@ ("metron", "comic", "999"), }, "locations": {("Mars",)}, + "reprints": { + ("Capitan Sciencia", 1, "", "es"), + ("Kapitän Wissenschaft", None, "", "de"), + }, "series_groups": {("adult comics",)}, "stories": {("The Beginning",), ("The End",)}, "story_arc_numbers": {("c", None), ("d", 1), ("e", 3), ("g", 5)}, @@ -186,6 +191,10 @@ (("metron", "publisher", "111"),), } }, + Reprint: { + ("Capitan Sciencia", 1, "", "es"): {(None,)}, + ("Kapitän Wissenschaft", None, "", "de"): {(None,)}, + }, Imprint: { ("Youthful Adventure Stories", "TestImprint"): { (("metron", "imprint", "123"),), diff --git a/tests/test_browser_reprints_column.py b/tests/test_browser_reprints_column.py new file mode 100644 index 000000000..62875e296 --- /dev/null +++ b/tests/test_browser_reprints_column.py @@ -0,0 +1,230 @@ +""" +The ``reprints`` browser table column and filter. + +``Reprint`` is the only tag model without a ``name`` column — the label +composes from four — so both the table cell (Comic rows and collection-row +intersections) and the filter drawer need their own label paths. These +tests pin all of them to :meth:`Reprint.compose_name`'s output. +""" + +import json +import shutil +from pathlib import Path +from typing import Final, override + +from django.contrib.auth.models import User +from django.core.cache import cache +from django.test import Client, TestCase + +from codex.choices.browser import DUMMY_NULL_NAME, VUETIFY_NULL_CODE +from codex.models import Comic, Imprint, Library, Publisher, Series, Volume +from codex.models.named import Reprint +from codex.startup import init_admin_flags + +_TEST_PASSWORD: Final = "test-pw-hush-S106" # noqa: S105 +_HTTP_OK: Final = 200 +TMP_DIR = Path("/tmp/codex.tests.browser_reprints_column") # noqa: S108 +_SETTINGS_URL: Final = "/api/v4/browse/publishers/settings" + + +def _v4(response): + """Unwrap the v4 ``{data, meta, errors}`` envelope and return ``data``.""" + body = response.json() + if isinstance(body, dict) and "data" in body and "meta" in body: + return body["data"] + return body + + +class _ReprintsFixtureTestCase(TestCase): + """One publisher / imprint / series / volume and one comic to tag.""" + + @override + def setUp(self) -> None: + cache.clear() + init_admin_flags() + TMP_DIR.mkdir(exist_ok=True, parents=True) + self.library = Library.objects.create(path=str(TMP_DIR)) # pyright: ignore[reportUninitializedInstanceVariable] + self.publisher = Publisher.objects.create(name="Pub") # pyright: ignore[reportUninitializedInstanceVariable] + self.imprint = Imprint.objects.create(name="Imp", publisher=self.publisher) # pyright: ignore[reportUninitializedInstanceVariable] + self.series = self._create_series("Ser") # pyright: ignore[reportUninitializedInstanceVariable] + self.comic = self._create_comic("C1", 1) # pyright: ignore[reportUninitializedInstanceVariable] + user = User.objects.create_user( + username="reprints_column_test", password=_TEST_PASSWORD + ) + self.client = Client() + self.client.force_login(user) + + @override + def tearDown(self) -> None: + shutil.rmtree(TMP_DIR, ignore_errors=True) + + def _create_series(self, name: str) -> Series: + return Series.objects.create( + name=name, imprint=self.imprint, publisher=self.publisher + ) + + def _create_comic( + self, name: str, issue_number: int, series: Series | None = None + ) -> Comic: + series = series or self.series + volume, _ = Volume.objects.get_or_create( + name=2024, series=series, imprint=self.imprint, publisher=self.publisher + ) + path = TMP_DIR / f"{name.lower()}.cbz" + path.touch() + return Comic.objects.create( + library=self.library, + path=path, + issue_number=issue_number, + name=name, + publisher=self.publisher, + imprint=self.imprint, + series=series, + volume=volume, + size=42 + issue_number, + year=2024, + page_count=20, + ) + + def _set_view_mode_table(self) -> None: + response = self.client.patch( + _SETTINGS_URL, + data=json.dumps({"viewMode": "table"}), + content_type="application/json", + ) + assert response.status_code == _HTTP_OK, response.content + + def _browse(self, url: str) -> dict: + response = self.client.get(url) + assert response.status_code == _HTTP_OK, response.content + return _v4(response) + + def _browse_comics(self, columns: str = "cover,name,reprints") -> dict: + return self._browse( + f"/api/v4/browse/series/{self.series.pk}?page=1&columns={columns}" + ) + + def _browse_series_rows(self, columns: str = "cover,name,reprints") -> dict: + return self._browse( + f"/api/v4/browse/publishers/{self.publisher.pk}?page=1&columns={columns}" + ) + + +class BrowserReprintsColumnTestCase(_ReprintsFixtureTestCase): + """Table-cell display and sort for the composed alternate-series label.""" + + def test_comic_row_composes_every_part(self) -> None: + """The SQL expression renders the same label as ``Reprint.compose_name``.""" + reprints = ( + Reprint.objects.create(series_name="Kapitän Wissenschaft"), + Reprint.objects.create(series_name="Capitan Sciencia", volume_number=1), + Reprint.objects.create(series_name="Yearly", volume_number=1999), + Reprint.objects.create( + series_name="Full", volume_number=3, issue="2a", language="es" + ), + ) + self.comic.reprints.set(reprints) + + self._set_view_mode_table() + row = self._browse_comics()["rows"][0] + assert sorted(row["reprints"]) == sorted( + reprint.name for reprint in reprints + ), row + # Spell the expectations out so a change to either the SQL or + # the Python composer fails loudly instead of agreeing wrongly. + # The aggregate sorts its own elements — identical reprint sets + # must render identical JSON for the M2M sort to cluster them. + assert row["reprints"] == [ + "Capitan Sciencia v1", + "Full v3 #2a (es)", + "Kapitän Wissenschaft", + "Yearly (1999)", + ] + + def test_collection_row_shows_only_shared_reprints(self) -> None: + """A series row intersects its children's reprints.""" + shared = Reprint.objects.create(series_name="Shared", language="de") + lonely = Reprint.objects.create(series_name="Lonely") + self.comic.reprints.set((shared, lonely)) + sibling = self._create_comic("C2", 2) + sibling.reprints.set((shared,)) + + self._set_view_mode_table() + row = self._browse_series_rows()["rows"][0] + assert row["reprints"] == ["Shared (de)"], row + + def test_collection_row_without_reprints_is_empty(self) -> None: + """Collections whose children carry no reprints render an empty cell.""" + self._set_view_mode_table() + row = self._browse_series_rows()["rows"][0] + assert row["reprints"] == [], row + + def test_collection_row_sort_by_reprints(self) -> None: + """Sorting collection rows by the column uses its intersection SQL.""" + self.comic.reprints.add( + Reprint.objects.create(series_name="Zulu", volume_number=2) + ) + # "Zzz" has no reprints, so ascending by the column puts it + # first — the opposite of the ``sort_name`` order the sort + # falls back to when the intersection SQL isn't wired. + self._create_comic("C2", 1, series=self._create_series("Zzz")) + + self._set_view_mode_table() + response = self.client.patch( + _SETTINGS_URL, + data=json.dumps({"orderBy": "reprints", "orderReverse": False}), + content_type="application/json", + ) + assert response.status_code == _HTTP_OK, response.content + rows = self._browse_series_rows()["rows"] + assert [(row["name"], row["reprints"]) for row in rows] == [ + ("Zzz", []), + ("Ser", ["Zulu v2"]), + ], rows + + +class BrowserReprintsFilterTestCase(_ReprintsFixtureTestCase): + """The filter drawer's choices and the resulting narrowing.""" + + def test_choices_compose_labels(self) -> None: + """The choices endpoint labels reprints like the metadata panel does.""" + reprint = Reprint.objects.create( + series_name="Kapitän Wissenschaft", language="de" + ) + self.comic.reprints.add(reprint) + # A reprint-less sibling makes the view prepend the null + # sentinel, which already carries a name of its own. + self._create_comic("C2", 2) + + body = self._browse(f"/api/v4/browse/series/{self.series.pk}/choices/reprints") + choices = {choice["pk"]: choice["name"] for choice in body["choices"]} + assert choices[reprint.pk] == "Kapitän Wissenschaft (de)", choices + assert choices[VUETIFY_NULL_CODE] == DUMMY_NULL_NAME, choices + + def test_choices_availability(self) -> None: + """``reprints`` only offers a sub-menu once a value and a null exist.""" + body = self._browse(f"/api/v4/browse/series/{self.series.pk}/choices") + assert body["reprints"] is False, body + + self.comic.reprints.add(Reprint.objects.create(series_name="Alt")) + self._create_comic("C2", 2) + cache.clear() + body = self._browse(f"/api/v4/browse/series/{self.series.pk}/choices") + assert body["reprints"] is True, body + + def test_filter_narrows_to_tagged_comics(self) -> None: + """Selecting a reprint pk filters the browse to comics carrying it.""" + reprint = Reprint.objects.create(series_name="Alt", volume_number=1) + self.comic.reprints.add(reprint) + self._create_comic("C2", 2) + + response = self.client.patch( + _SETTINGS_URL, + data=json.dumps({"filters": {"reprints": [reprint.pk]}}), + content_type="application/json", + ) + assert response.status_code == _HTTP_OK, response.content + cache.clear() + body = self._browse(f"/api/v4/browse/series/{self.series.pk}?page=1") + names = [book["name"] for book in body["books"]] + assert names == ["C1"], body diff --git a/tests/test_browser_table_response.py b/tests/test_browser_table_response.py index a34b30392..871be9d5f 100644 --- a/tests/test_browser_table_response.py +++ b/tests/test_browser_table_response.py @@ -632,8 +632,8 @@ def test_table_view_simple_m2m_intersections_share_one_union_query( metadata-view pattern (``query_intersections``'s ``_query_m2m_intersections``) and unions per-through-table sub-queries into a single SQL transmission. Composite M2M - columns (credits / identifiers / universes / story_arcs) - still issue their own queries. + columns (credits / identifiers / reprints / universes / + story_arcs) still issue their own queries. """ from django.db import connection from django.test.utils import CaptureQueriesContext diff --git a/tests/test_metadata_reprints.py b/tests/test_metadata_reprints.py new file mode 100644 index 000000000..30fbd0a5b --- /dev/null +++ b/tests/test_metadata_reprints.py @@ -0,0 +1,159 @@ +""" +The metadata pane serves alternate series names as ready-to-render chips. + +``Reprint.name`` is a property composed from four columns, so the +metadata endpoint has to hydrate all of them plus the optional +identifier in one query. Without the ``M2M_QUERY_OPTIMIZERS`` entry the +default ``only=("name", "identifier")`` raises FieldDoesNotExist. +""" + +import shutil +from pathlib import Path +from typing import Final, override + +from django.contrib.auth.models import User +from django.db import connection +from django.test import Client, TestCase +from django.test.utils import CaptureQueriesContext + +from codex.models import ( + Comic, + Imprint, + Library, + Publisher, + Reprint, + Series, + Volume, +) +from codex.models.identifier import Identifier, IdentifierSource, IdentifierType +from codex.startup import init_admin_flags + +TMP_DIR = Path("/tmp/codex.tests.metadata_reprints") # noqa: S108 +_TEST_PASSWORD: Final = "test-pw-hush-S106" # noqa: S105 +_HTTP_OK: Final = 200 +_REPRINT_TABLE: Final = 'FROM "codex_reprint"' +_REPRINT_COUNT: Final = 3 + + +def _v4(response): + """Unwrap the v4 ``{data, meta, errors}`` envelope and return ``data``.""" + body = response.json() + if isinstance(body, dict) and "data" in body and "meta" in body: + return body["data"] + return body + + +class MetadataReprintsTestCase(TestCase): + """The reprints payload shape and its query cost.""" + + @override + def setUp(self) -> None: + """Seed one comic with three reprints of varying completeness.""" + init_admin_flags() + TMP_DIR.mkdir(exist_ok=True, parents=True) + library = Library.objects.create(path=str(TMP_DIR)) + publisher = Publisher.objects.create(name="Pub") + imprint = Imprint.objects.create(name="Imp", publisher=publisher) + self.series = Series.objects.create( # pyright: ignore[reportUninitializedInstanceVariable] + name="Ser", imprint=imprint, publisher=publisher + ) + volume = Volume.objects.create( + name="2024", series=self.series, imprint=imprint, publisher=publisher + ) + path = TMP_DIR / "c1.cbz" + path.touch() + self.comic = Comic.objects.create( # pyright: ignore[reportUninitializedInstanceVariable] + library=library, + path=path, + issue_number=1, + name="C1", + publisher=publisher, + imprint=imprint, + series=self.series, + volume=volume, + size=42, + ) + source = IdentifierSource.objects.create(name="metron") + identifier = Identifier.objects.create( + source=source, + id_type=IdentifierType.REPRINT.value, + key="12345", + url="https://metron.cloud/series/12345", + ) + self.comic.reprints.set( + [ + Reprint.objects.create(series_name="Kapitän Wissenschaft"), + Reprint.objects.create( + series_name="Capitan Sciencia", volume_number=1, language="es" + ), + Reprint.objects.create( + series_name="Captain Science", + issue="3", + identifier=identifier, + ), + ] + ) + + user = User.objects.create_user( + username="reprint_reader", password=_TEST_PASSWORD, is_staff=True + ) + self.client = Client() + self.client.force_login(user) + + @override + def tearDown(self) -> None: + """Remove the temp comic tree.""" + shutil.rmtree(TMP_DIR, ignore_errors=True) + + def _get_metadata(self, url: str = ""): + url = url or f"/api/v4/browse/comics/{self.comic.pk}/metadata" + with CaptureQueriesContext(connection) as ctx: + response = self.client.get(url) + assert response.status_code == _HTTP_OK, response.content + return _v4(response), ctx.captured_queries + + def test_reprint_names_compose_present_parts(self) -> None: + """Each chip name composes only the columns the reprint carries.""" + data, _ = self._get_metadata() + names = sorted(reprint["name"] for reprint in data["reprints"]) + assert names == [ + "Capitan Sciencia v1 (es)", + "Captain Science #3", + "Kapitän Wissenschaft", + ], names + + def test_reprint_columns_ship_beside_the_composed_name(self) -> None: + """The tag editor seeds its rows from the parts, not the label.""" + data, _ = self._get_metadata() + parts = { + reprint["seriesName"]: ( + reprint["volumeNumber"], + reprint["issue"], + reprint["language"], + ) + for reprint in data["reprints"] + } + assert parts == { + "Kapitän Wissenschaft": (None, "", ""), + "Capitan Sciencia": (1, "", "es"), + "Captain Science": (None, "3", ""), + }, parts + + def test_reprint_identifier_url_ships_when_set(self) -> None: + """The identifier url rides along for the chip's external link.""" + data, _ = self._get_metadata() + urls = {reprint["name"]: reprint.get("url", "") for reprint in data["reprints"]} + assert urls["Captain Science #3"] == "https://metron.cloud/series/12345" + assert not urls["Kapitän Wissenschaft"] + + def test_collection_selection_serves_the_intersection(self) -> None: + """Collections take the plain-attribute branch, not the prefetch cache.""" + url = f"/api/v4/browse/series/{self.series.pk}/metadata" + data, _ = self._get_metadata(url) + assert len(data["reprints"]) == _REPRINT_COUNT, data["reprints"] + + def test_reprints_hydrate_in_one_query(self) -> None: + """No per-reprint query for the deferred columns or the identifier.""" + _, queries = self._get_metadata() + reprint_queries = [q for q in queries if _REPRINT_TABLE in q["sql"]] + assert len(reprint_queries) == 1, reprint_queries diff --git a/tests/test_onlinetag_resume_endpoints.py b/tests/test_onlinetag_resume_endpoints.py index 265c5acd4..e78ce6f6d 100644 --- a/tests/test_onlinetag_resume_endpoints.py +++ b/tests/test_onlinetag_resume_endpoints.py @@ -26,7 +26,6 @@ "sources": ["metron", "comicvine"], "mode": "auto", "prompts_mode": "ask", - "auto_threshold": 0.85, "delete_original": False, "merge_all_sources": False, "dry_run": False, @@ -87,6 +86,19 @@ def test_resume_enqueues_task_over_remaining_pks(self) -> None: assert task.mode == "auto" assert task.prompts_mode == "ask" + def test_resume_drops_params_from_an_older_codex(self) -> None: + # A descriptor written before a knob was removed (auto_threshold) must + # still resume instead of raising TypeError on the task constructor. + set_resume_state({**_PARAMS, "auto_threshold": 0.85}, [11]) + + with patch(_QUEUE_TARGET) as mocked_queue: + response = self.client.post(_RESUME_URL) + + assert response.status_code == HTTPStatus.ACCEPTED + task = mocked_queue.put.call_args.args[0] + assert isinstance(task, BulkOnlineTagTask) + assert task.comic_pks == frozenset([11]) + def test_resume_conflict_when_scan_active(self) -> None: set_resume_state(_PARAMS, [11]) set_active_scan_id("live-scan") diff --git a/tests/test_reprints.py b/tests/test_reprints.py new file mode 100644 index 000000000..33f0887cf --- /dev/null +++ b/tests/test_reprints.py @@ -0,0 +1,133 @@ +""" +Alternate series names, from the archive to the browser and back out. + +``tests/files/comicbox-2-example.cbz`` carries two MetronInfo alternate +names, one of which supplies only a series ``sort_name``. Importing it +is the only place the whole chain — comicbox whitelist, four-column key +flattening, m2m link, display label and the nightly orphan sweep — +meets end to end. The search half of the chain lives in +``tests/test_search_alternate_series.py``. +""" + +from http import HTTPStatus +from multiprocessing import Event as ProcessEvent +from threading import Lock +from typing import Final, override + +from django.contrib.auth.models import User +from django.test import Client, SimpleTestCase +from loguru import logger + +from codex.librarian.mp_queue import LIBRARIAN_QUEUE +from codex.librarian.scribe.importer.read.many_to_many import ( + AggregateManyToManyMetadataImporter, +) +from codex.librarian.scribe.janitor.janitor import Janitor +from codex.models import Comic, Reprint, Series, Volume +from tests.importer.test_basic import PATH, BaseTestImporter, run_full_import + +_TEST_PASSWORD: Final = "test-pw-hush-S106" # noqa: S105 +# The fixture's two alternate names, as ``Reprint`` natural keys. +_FIXTURE_KEYS: Final = { + ("Capitan Sciencia", 1, "", "es"), + ("Kapitän Wissenschaft", None, "", "de"), +} +_SORT_NAME_ONLY: Final = "Capitan Sciencia" + + +def _reprint_keys() -> set[tuple]: + """Every Reprint row in the database, as natural-key tuples.""" + return set( + Reprint.objects.values_list("series_name", "volume_number", "issue", "language") + ) + + +def _make_janitor() -> Janitor: + """Build a Janitor detached from the librarian's thread graph.""" + return Janitor(logger, LIBRARIAN_QUEUE, Lock(), ProcessEvent()) + + +class ReprintImportTestCase(BaseTestImporter): + """Import the example fixture and follow its reprints downstream.""" + + @override + def setUp(self) -> None: + super().setUp() + run_full_import(self.importer) + self.comic = Comic.objects.get(path=PATH) + + def test_import_creates_rows_keyed_on_all_four_columns(self) -> None: + """Every part comicbox supplied lands in its own column.""" + assert _reprint_keys() == _FIXTURE_KEYS + linked = self.comic.reprints.values_list( + "series_name", "volume_number", "issue", "language" + ) + assert set(linked) == _FIXTURE_KEYS + + def test_sort_name_stands_in_for_a_missing_series_name(self) -> None: + """ + One AlternativeNames entry carries no ``series.name`` at all. + + It must still import, and — the reason ``Reprint`` denormalizes + instead of reusing Series/Volume — it must not become a + browsable collection row. + """ + assert Reprint.objects.filter(series_name=_SORT_NAME_ONLY).exists() + assert not Series.objects.filter(name=_SORT_NAME_ONLY).exists() + assert not Volume.objects.filter(series__name=_SORT_NAME_ONLY).exists() + + def test_metadata_endpoint_serves_imported_reprints(self) -> None: + """Imported rows reach the panel as composed display labels.""" + user = User.objects.create_user( + username="reprint_round_trip", password=_TEST_PASSWORD + ) + client = Client() + client.force_login(user) + response = client.get(f"/api/v4/browse/comics/{self.comic.pk}/metadata") + assert response.status_code == HTTPStatus.OK, response.content + names = sorted( + reprint["name"] for reprint in response.json()["data"]["reprints"] + ) + assert names == ["Capitan Sciencia v1 (es)", "Kapitän Wissenschaft (de)"] + + def test_cleanup_fks_keeps_linked_reprints(self) -> None: + """The nightly sweep leaves reprints a comic still points at.""" + _make_janitor().cleanup_fks() + assert _reprint_keys() == _FIXTURE_KEYS + + def test_cleanup_fks_deletes_orphaned_reprints(self) -> None: + """Deleting the last comic that referenced them sweeps them away.""" + self.comic.delete() + _make_janitor().cleanup_fks() + assert not Reprint.objects.exists() + + +class ReprintKeyFlatteningTestCase(SimpleTestCase): + """The nested comicbox tree collapses to the four-column key.""" + + @staticmethod + def _clean(reprint: dict) -> tuple | None: + return AggregateManyToManyMetadataImporter._clean_reprint_key(reprint) # noqa: SLF001 + + def test_series_name_wins_over_sort_name(self) -> None: + """``sort_name`` only fills in for a reprint that has no name.""" + key = self._clean({"series": {"name": "Real", "sort_name": "Sorted"}}) + assert key == ("Real", None, "", "") + + def test_every_part_flattens_to_its_column(self) -> None: + """Volume number, issue and language come out of their subtrees.""" + key = self._clean( + { + "series": {"name": "Kapitän Wissenschaft"}, + "volume": {"number": 2}, + "issue": "3", + "language": "de", + } + ) + assert key == ("Kapitän Wissenschaft", 2, "3", "de") + + def test_reprint_without_any_series_name_is_dropped(self) -> None: + """A reprint naming nothing can't key a row, so it never becomes one.""" + assert self._clean({"volume": {"number": 1}, "language": "de"}) is None + assert self._clean({"series": {}}) is None + assert self._clean({"series": {"name": " "}}) is None diff --git a/tests/test_search_alternate_series.py b/tests/test_search_alternate_series.py new file mode 100644 index 000000000..91cc53889 --- /dev/null +++ b/tests/test_search_alternate_series.py @@ -0,0 +1,133 @@ +""" +Alternate series names are searchable. + +comicbox ``reprints`` become :class:`codex.models.named.Reprint` rows and +are indexed into a dedicated ``ComicFTS.alternate_series`` column rather +than being folded into ``series`` — a ``series:`` token has to keep +meaning the canonical series. + +Two independent code paths fill that column: the importer builds FTS +entries inline, and :class:`SearchIndexerSync` rebuilds them from the +database. Each keeps its own registry of FTS columns and drops values +*silently* when the registries disagree (the historical credits / +sources / story_arcs bug documented in ``scribe/search/sync.py``), so +both are asserted here end to end. +""" + +from datetime import UTC, datetime +from http import HTTPStatus +from pathlib import Path +from threading import Event, Lock +from typing import Final, override +from urllib.parse import urlencode + +from django.contrib.auth.models import User +from django.test import Client +from loguru import logger + +from codex.librarian.mp_queue import LIBRARIAN_QUEUE +from codex.librarian.scribe.search.sync import SearchIndexerSync +from codex.models import Comic +from codex.models.comic import ComicFTS +from tests.importer.test_basic import PATH, BaseTestImporter, run_full_import + +_TEST_PASSWORD: Final = "test-pw-hush-S106" # noqa: S105 +# ``tests/files/comicbox-2-example.cbz`` carries two MetronInfo +# alternate names. The second has only a ``series.sort_name``. +_ALTERNATE_SERIES: Final = "Capitan Sciencia,Kapitän Wissenschaft" +# Comfortably past any index watermark the run just wrote. +_FUTURE: Final = datetime(2100, 1, 1, tzinfo=UTC) + + +class AlternateSeriesSearchTestCase(BaseTestImporter): + """Import the example fixture, then find it by its alternate names.""" + + @override + def setUp(self) -> None: + super().setUp() + run_full_import(self.importer) + self.comic = Comic.objects.get(path=PATH) + self._create_sibling() + user = User.objects.create_user( + username="alt_series_search_test", password=_TEST_PASSWORD + ) + self.client = Client() + self.client.force_login(user) + + def _create_sibling(self) -> None: + """ + Add a reprint-less comic to the same series. + + Without it every positive assertion below would also pass when + the search filter is dropped entirely — the single imported + comic is the whole result set either way. + """ + path = Path(PATH).with_name("sibling.cbz") + path.touch() + Comic.objects.create( + library=self.comic.library, + path=path, + issue_number=2, + name="Sibling", + publisher=self.comic.publisher, + imprint=self.comic.imprint, + series=self.comic.series, + volume=self.comic.volume, + size=1, + page_count=1, + ) + + @staticmethod + def _sync(*, rebuild: bool) -> None: + indexer = SearchIndexerSync(logger, LIBRARIAN_QUEUE, Lock(), Event()) + indexer.update_search_index(rebuild=rebuild) + + def _alternate_series_column(self) -> str: + return ComicFTS.objects.get(comic_id=self.comic.pk).alternate_series + + def _search_book_pks(self, query: str) -> list[int]: + """Browse the comic's series with a search query and return book pks.""" + params = urlencode({"page": 1, "q": query}) + url = f"/api/v4/browse/series/{self.comic.series.pk}?{params}" + response = self.client.get(url) + assert response.status_code == HTTPStatus.OK, response.content + return [book["pk"] for book in response.json()["data"]["books"]] + + def test_import_indexes_alternate_series(self) -> None: + """The importer writes reprint series names to their own FTS column.""" + assert self._alternate_series_column() == _ALTERNATE_SERIES + + def test_unqualified_search_finds_alternate_series(self) -> None: + """A bare token matches every FTS column, alternate names included.""" + assert self._search_book_pks("Wissenschaft") == [self.comic.pk] + assert self._search_book_pks("Sciencia") == [self.comic.pk] + assert self._search_book_pks("Zzyzx") == [] + + def test_field_alias_search_finds_alternate_series(self) -> None: + """Every registered alias resolves to the alternate_series column.""" + for alias in ("alternate_series", "alt_series", "alternateseries", "reprints"): + assert self._search_book_pks(f"{alias}:Sciencia") == [self.comic.pk], alias + # An unregistered column is silently discarded from the query + # rather than erroring, so the miss has to be asserted too. + assert self._search_book_pks(f"{alias}:Zzyzx") == [], alias + + def test_series_token_excludes_alternate_series(self) -> None: + """The canonical series column stays free of alternate names.""" + assert self._search_book_pks("series:Sciencia") == [] + assert self._search_book_pks("series:Captain") == [self.comic.pk] + + def test_search_index_sync_populates_alternate_series(self) -> None: + """A full index rebuild fills the column the importer filled.""" + self._sync(rebuild=True) + assert self._alternate_series_column() == _ALTERNATE_SERIES + assert self._search_book_pks("alt_series:Wissenschaft") == [self.comic.pk] + + def test_search_index_sync_updates_alternate_series(self) -> None: + """An incremental sync carries the column through ``bulk_update``.""" + self._sync(rebuild=True) + self.comic.reprints.clear() + # ``updated_at`` is auto_now, so ``.update()`` is the only way to + # push the comic past the index watermark without a save(). + Comic.objects.filter(pk=self.comic.pk).update(updated_at=_FUTURE) + self._sync(rebuild=False) + assert self._alternate_series_column() == "" diff --git a/tests/test_user_data_restore.py b/tests/test_user_data_restore.py index e9fd7bf51..ce2abb4f6 100644 --- a/tests/test_user_data_restore.py +++ b/tests/test_user_data_restore.py @@ -242,6 +242,43 @@ def test_round_trip_restores_settings_browser_show(self) -> None: assert browser.show.series is False assert browser.show.volumes is True + def test_round_trip_restores_reprints_filter(self) -> None: + """ + A reprints filter survives a rebuild that renumbers its PKs. + + The sidecar stores tag filters by name, and ``Reprint`` is the + only tag model whose name lives on ``series_name`` rather than + ``name`` — a dump that reached for ``name`` would raise. + """ + from codex.models.named import Reprint + from codex.models.settings import ( + SettingsBrowser, + SettingsBrowserFilters, + SettingsBrowserShow, + ) + + user = User.objects.create_user(username="alice", password=_TEST_PASSWORD) + show, _ = SettingsBrowserShow.objects.get_or_create() + browser = SettingsBrowser.objects.create(user=user, show=show) + reprint = Reprint.objects.create( + series_name="Kapitän Wissenschaft", language="de" + ) + SettingsBrowserFilters.objects.create(browser=browser, reprints=[reprint.pk]) + snapshot = self._snapshot_sidecar() + + # Rebuilding the library re-creates the tag row under a new PK, + # which is the whole reason the sidecar keys on names. + SettingsBrowser.objects.all().delete() + reprint.delete() + rebuilt = Reprint.objects.create( + series_name="Kapitän Wissenschaft", language="de" + ) + assert rebuilt.pk != reprint.pk + + restore(sidecar_path=snapshot) + filters = SettingsBrowserFilters.objects.get(browser__user=user) + assert filters.reprints == [rebuilt.pk] + def test_restore_is_idempotent(self) -> None: self._seed_main_db() snapshot = self._snapshot_sidecar() diff --git a/tests/test_user_data_store.py b/tests/test_user_data_store.py index 8b0e2a270..ca9b0aedb 100644 --- a/tests/test_user_data_store.py +++ b/tests/test_user_data_store.py @@ -114,6 +114,41 @@ def test_is_empty_tracks_inserts(self) -> None: self.store.upsert("users", ("username",), {"username": "alice"}) assert not self.store.is_empty() + def test_schema_gains_columns_added_by_a_release(self) -> None: + """ + A sidecar written before a new filter column still accepts it. + + ``schema.sql`` is all ``CREATE TABLE IF NOT EXISTS``, so an + upgraded install re-runs it against a file that predates the + column and nothing happens. Simulate that by dropping the + column from a freshly created sidecar and reconnecting. + """ + conn = self.store.connection() + conn.execute("ALTER TABLE settings_filters DROP COLUMN reprints") + columns = { + column["name"] + for column in conn.execute("PRAGMA table_info(settings_filters)") + } + assert "reprints" not in columns + self.store.close() + + store = SidecarStore(self.sidecar_path) + try: + store.upsert( + "settings_filters", + ("username", "client", "name"), + { + "username": "alice", + "client": "web", + "name": "", + "reprints": '["Kapitän Wissenschaft"]', + }, + ) + rows = store.fetchall("settings_filters") + finally: + store.close() + assert rows[0]["reprints"] == '["Kapitän Wissenschaft"]' + def test_upsert_empty_data_raises(self) -> None: """``upsert`` with no columns is a programming error.""" with pytest.raises(ValueError, match="no columns"): From 265f7fa44b840ee4c90e503f378d150ff0df6be2 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sun, 26 Jul 2026 14:29:59 -0700 Subject: [PATCH 46/66] update deps and devenv --- bin/fix-django.sh | 10 ++++++++++ bin/lint-django.sh | 10 ++++++++++ bun.lock | 44 ++++++++++++++++++++++++++++++++------------ cfg/django.mk | 4 ++-- package.json | 2 +- pyproject.toml | 2 +- uv.lock | 14 +++++++------- 7 files changed, 63 insertions(+), 23 deletions(-) create mode 100755 bin/fix-django.sh create mode 100755 bin/lint-django.sh diff --git a/bin/fix-django.sh b/bin/fix-django.sh new file mode 100755 index 000000000..828426977 --- /dev/null +++ b/bin/fix-django.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Fix django template lint errors +set -euxo pipefail + +mapfile -t templates < <(find . -mindepth 1 -name '.*' -prune -o -path '*/templates/*' -name '*.html' -print) +if [ ${#templates[@]} -eq 0 ]; then + echo "No django template files found. Nothing fixed." + exit 0 +fi +uv run --group lint djlint --reformat "${templates[@]}" diff --git a/bin/lint-django.sh b/bin/lint-django.sh new file mode 100755 index 000000000..32cefb014 --- /dev/null +++ b/bin/lint-django.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Lint django templates +set -euxo pipefail + +mapfile -t templates < <(find . -mindepth 1 -name '.*' -prune -o -path '*/templates/*' -name '*.html' -print) +if [ ${#templates[@]} -eq 0 ]; then + echo "No django template files found. Nothing linted." + exit 0 +fi +uv run --group lint djlint --lint "${templates[@]}" diff --git a/bun.lock b/bun.lock index 95ad979cc..5ef28b452 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", "@eslint/js": "^10.0.1", "@eslint/json": "^2.0.1", - "@fsouza/prettierd": "^0.28.0", + "@fsouza/prettierd": "^0.29.0", "@prettier/plugin-xml": "^3.4.2", "@stylistic/eslint-plugin": "^5.10.0", "@vitest/eslint-plugin": "^1.6.24", @@ -60,13 +60,13 @@ "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], - "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@babel/parser": ["@babel/parser@8.0.4", "", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="], - "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="], "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -96,7 +96,7 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], - "@fsouza/prettierd": ["@fsouza/prettierd@0.28.0", "", { "dependencies": { "core_d": "^6.1.1", "prettier": "^3.8.3" }, "optionalDependencies": { "@babel/parser": "^7.29.7", "@typescript-eslint/typescript-estree": "^8.60.0" }, "bin": { "prettierd": "bin/prettierd" } }, "sha512-XkRkIp2en2Lcm7Qc+zqNm8ClI3RArLCBNG2A4M2xzTw43S/vYSEDQ4NcfW+6ZsUyQeoVqrFcMU5H1yAKCVkv6A=="], + "@fsouza/prettierd": ["@fsouza/prettierd@0.29.0", "", { "dependencies": { "core_d": "^6.1.1", "prettier": "^3.9.5" }, "optionalDependencies": { "@babel/parser": "^8.0.4", "@typescript-eslint/typescript-estree": "^8.64.0" }, "bin": { "prettierd": "bin/prettierd" } }, "sha512-UMBNFAVAP2Vfd8RrsH4bzchtyvqfzFp3COBYSTYmtrrU6fwK+JQLz9s6g9JNRVY6MDBWkUWNpPO63vPD/6KTFg=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -172,19 +172,19 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.63.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.63.0", "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.65.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.65.0", "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q=="], "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0" } }, "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.63.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.65.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg=="], "@typescript-eslint/types": ["@typescript-eslint/types@8.63.0", "", {}, "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.63.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.63.0", "@typescript-eslint/tsconfig-utils": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.65.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.65.0", "@typescript-eslint/tsconfig-utils": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg=="], "@typescript-eslint/utils": ["@typescript-eslint/utils@8.63.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A=="], "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.12.2", "", { "os": "android", "cpu": "arm" }, "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w=="], @@ -1110,9 +1110,9 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - "@fsouza/prettierd/prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -1132,6 +1132,16 @@ "@npmcli/promise-spawn/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.65.0", "", {}, "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg=="], + + "@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], + + "@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.65.0", "", {}, "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg=="], + + "@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.63.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.63.0", "@typescript-eslint/tsconfig-utils": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q=="], + + "@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.65.0", "", {}, "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg=="], + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -1222,6 +1232,14 @@ "@npmcli/promise-spawn/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.63.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.63.0", "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ=="], + + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.63.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg=="], + + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], "eslint-mdx/espree/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], @@ -1254,6 +1272,8 @@ "@npmcli/map-workspaces/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "npm-pick-manifest/npm-package-arg/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], diff --git a/cfg/django.mk b/cfg/django.mk index e5605d5fd..2652dc38b 100644 --- a/cfg/django.mk +++ b/cfg/django.mk @@ -5,13 +5,13 @@ export DEVENV_DJANGO ## Fix django lint errors in templates ## @category Fix fix:: - uv run --group lint djlint --reformat **/templates/**/*.html + bin/fix-django.sh .PHONY: lint ## Lint django templates ## @category Lint lint:: - uv run --group lint djlint --lint **/templates/**/*.html + bin/lint-django.sh .PHONY: django-check ## Django check diff --git a/package.json b/package.json index b9ac59b29..2462b593f 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", "@eslint/js": "^10.0.1", "@eslint/json": "^2.0.1", - "@fsouza/prettierd": "^0.28.0", + "@fsouza/prettierd": "^0.29.0", "@prettier/plugin-xml": "^3.4.2", "@stylistic/eslint-plugin": "^5.10.0", "@vitest/eslint-plugin": "^1.6.24", diff --git a/pyproject.toml b/pyproject.toml index 48329a513..d826bb306 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "adrf~=0.1.12", "bidict~=0.23", "channels~=4.2", - "comicbox[pdf]~=4.6.0", + "comicbox[pdf]~=4.6.1", "cryptography>=48.0.0", "dateparser~=1.2", "django-allauth[socialaccount]~=65.13", diff --git a/uv.lock b/uv.lock index 310dfda23..ca8d5f7b2 100644 --- a/uv.lock +++ b/uv.lock @@ -653,7 +653,7 @@ requires-dist = [ { name = "adrf", specifier = "~=0.1.12" }, { name = "bidict", specifier = "~=0.23" }, { name = "channels", specifier = "~=4.2" }, - { name = "comicbox", extras = ["pdf"], specifier = "~=4.6.0" }, + { name = "comicbox", extras = ["pdf"], specifier = "~=4.6.1" }, { name = "cryptography", specifier = ">=48.0.0" }, { name = "dateparser", specifier = "~=1.2" }, { name = "django", specifier = "~=6.0" }, @@ -739,7 +739,7 @@ wheels = [ [[package]] name = "comicbox" -version = "4.6.0" +version = "4.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bidict" }, @@ -776,9 +776,9 @@ dependencies = [ { name = "xmltodict" }, { name = "zipremove" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/99/df7bde0961d78e5ca8f5a5a06442cf11c44de180e839c799aa93478aa74a/comicbox-4.6.0.tar.gz", hash = "sha256:deceb8baaa382cb736dd9501aa2e6fec53024fa1c5b4a973e6d401a4e1ff65a2", size = 1013136, upload-time = "2026-07-26T00:41:14.701Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/05/1c2369de5b000c963fa119c2515d3fd1c64e55fa518eca2b691a86315c26/comicbox-4.6.1.tar.gz", hash = "sha256:4a4f50df0143c21853356a8defa767335434d870da6cdc6c56fef9a77c1f8c87", size = 1015362, upload-time = "2026-07-26T21:26:50.629Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/d8/199aa718d77f0eaaf71f7ef94c38eccfbc34cde420e1bb5a4064b507fb4b/comicbox-4.6.0-py3-none-any.whl", hash = "sha256:2298e6ff2fd39b5e814825405e80edd4ccff9f461bfcf4844f20f201f9c34c60", size = 341933, upload-time = "2026-07-26T00:41:13.182Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/6acdbbc6c3ef7bb9023bbeb586d442649218f72177e3013b26a0b1dadc3e/comicbox-4.6.1-py3-none-any.whl", hash = "sha256:8d4c288f775063858620679dd0e4b61291ef14180fbc24767adf5b15684208f7", size = 342787, upload-time = "2026-07-26T21:26:49.016Z" }, ] [package.optional-dependencies] @@ -2654,14 +2654,14 @@ wheels = [ [[package]] name = "prompt-toolkit" -version = "3.0.52" +version = "3.0.53" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, ] [[package]] From 5d5b3bd6c033cd21f958733dad487a3e0f769bd5 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sun, 26 Jul 2026 15:10:46 -0700 Subject: [PATCH 47/66] bump news for comicbox 4.6.1 --- NEWS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/NEWS.md b/NEWS.md index 45dea6e1e..96adfa131 100644 --- a/NEWS.md +++ b/NEWS.md @@ -21,6 +21,15 @@ border-radius: 128px; other known names (comicbox 4.6.0). - Fixes + - Identifier links point at the right page. Hand-tagged keys with a type + prefix like `series:178012`, Comic Vine long codes like `4050-160294`, and + `source:type:key` strings made broken issue-shaped URLs for every source + (comicbox 4.6.1). + - Every identifier URN in a comic's notes is imported; only the first was + read before (comicbox 4.6.1). + - Story arcs tagged online from Metron or Comic Vine get web links (comicbox + 4.6.1). These identifier fixes reach already-imported comics on their next + re-import, or use Force Update Tags. - User data backups no longer skip every saved filter set after an upgrade that adds a new filter. From 3b500ae25bbd4f165c37b824707698a9a18e0963 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sun, 26 Jul 2026 16:31:25 -0700 Subject: [PATCH 48/66] update devenv --- bin/manage.py | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/bin/manage.py b/bin/manage.py index ecd4f2a24..c9dfd2a2e 100755 --- a/bin/manage.py +++ b/bin/manage.py @@ -3,11 +3,52 @@ import os import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PRUNE = frozenset({"build", "dist", "docs", "node_modules", "test", "tests"}) + + +def _from_pyproject() -> str | None: + """Read an explicitly configured settings module.""" + pyproject = ROOT / "pyproject.toml" + if not pyproject.is_file(): + return None + with pyproject.open("rb") as f: + config = tomllib.load(f) + return config.get("tool", {}).get("devenv", {}).get("django-settings-module") + + +def _is_settings_package(pkg: Path) -> bool: + """Test if a dir is a package that holds a settings module or package.""" + if ( + not pkg.is_dir() + or pkg.name.startswith((".", "_")) + or pkg.name in PRUNE + or not (pkg / "__init__.py").is_file() + ): + return False + return (pkg / "settings.py").is_file() or ( + pkg / "settings" / "__init__.py" + ).is_file() + + +def _discover() -> str: + """Find the sole /settings module or package in the project root.""" + candidates = sorted(pkg.name for pkg in ROOT.iterdir() if _is_settings_package(pkg)) + if len(candidates) == 1: + return f"{candidates[0]}.settings" + reason = ( + f"Found {len(candidates)} django settings modules in {ROOT}: {candidates}. " + "Set [tool.devenv] django-settings-module in pyproject.toml." + ) + raise RuntimeError(reason) def main(): """Run the server.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "codex.settings") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", _from_pyproject() or _discover()) try: from django.core.management import ( execute_from_command_line, From 646fe385e4fa6571e38679d0769fced1de09de77 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sun, 26 Jul 2026 17:45:34 -0700 Subject: [PATCH 49/66] fix cron double enqueue of telemeter task --- codex/librarian/cron/crond.py | 79 ++++++++++++++++----- codex/librarian/telemeter/scheduled_time.py | 18 +++++ codex/librarian/telemeter/telemeter.py | 33 ++++++--- 3 files changed, 102 insertions(+), 28 deletions(-) diff --git a/codex/librarian/cron/crond.py b/codex/librarian/cron/crond.py index b00838e56..35c466be7 100644 --- a/codex/librarian/cron/crond.py +++ b/codex/librarian/cron/crond.py @@ -1,23 +1,42 @@ """Perform maintenance tasks.""" +from collections.abc import Callable +from datetime import datetime from threading import Condition, Event -from time import sleep from types import MappingProxyType -from typing import override +from typing import NamedTuple, override from django.db import connections from django.utils import timezone as django_timezone +from loguru._logger import Logger from codex.librarian.scribe.janitor.scheduled_time import get_janitor_time from codex.librarian.scribe.janitor.tasks import JanitorNightlyTask -from codex.librarian.telemeter.scheduled_time import get_telemeter_time +from codex.librarian.telemeter.scheduled_time import ( + get_telemeter_time, + mark_telemeter_attempt, +) from codex.librarian.telemeter.tasks import TelemeterTask from codex.librarian.threads import NamedThread -_TASK_TIME_FUNCTION_MAP = MappingProxyType( + +class _CronJob(NamedTuple): + """When a recurring task runs next, and how to spend the slot it runs in.""" + + get_time: Callable[[Logger], datetime | None] + # Called just before the task is queued, for jobs whose next time is + # read back out of state the job itself writes. ``None`` for jobs + # whose schedule is pure clock arithmetic and so advances on its own. + claim: Callable[[], None] | None = None + + +_CRON_JOBS: MappingProxyType[type, _CronJob] = MappingProxyType( { - JanitorNightlyTask: get_janitor_time, - TelemeterTask: get_telemeter_time, + # Always the next midnight, so queueing it moves the schedule. + JanitorNightlyTask: _CronJob(get_janitor_time), + # Read out of the telemeter Timestamp, which the send writes on a + # thread this one never joins. See ``mark_telemeter_attempt``. + TelemeterTask: _CronJob(get_telemeter_time, mark_telemeter_attempt), } ) @@ -34,8 +53,8 @@ def __init__(self, *args, **kwargs) -> None: def _create_task_times(self) -> None: task_times = {} - for task_class, func in _TASK_TIME_FUNCTION_MAP.items(): - if dttm := func(self.log): + for task_class, job in _CRON_JOBS.items(): + if dttm := job.get_time(self.log): task_times[dttm] = task_class self._task_times = tuple(sorted(task_times.items())) @@ -51,14 +70,34 @@ def _get_timeout(self) -> int: self.log.debug(f"Next scheduled job at {next_time} in {delta}.") return max(0, int(delta.total_seconds())) - def _run_expired_jobs(self) -> None: + def _enqueue_job(self, task_class: type) -> None: + """Spend the job's slot before queueing it, never after.""" + claim = _CRON_JOBS[task_class].claim + if claim is not None: + claim() + self.librarian_queue.put(task_class()) + + def _run_expired_jobs(self, *, timed_out: bool) -> None: + """ + Queue every job whose scheduled time has arrived. + + ``timed_out`` means the wait ran its full course instead of being + cut short by ``end_timeout``, so the job at the head of the + schedule is due even if the clock reads a hair short of its time + — ``_get_timeout`` truncates the delta to whole seconds, so the + wait always ends slightly early. Missing that job cost more than + a late run: ``_create_task_times`` would immediately push the + nightly janitor out to the *following* midnight and skip a night. + That is what ``sleep(2) # fix time rounding problems`` was for. + """ now = django_timezone.now() + if timed_out and self._task_times: + now = max(now, self._task_times[0][0]) for dttm, task_class in self._task_times: - if dttm < now: - self.librarian_queue.put(task_class()) - else: + if dttm > now: # Times are always ordered so stop checking at the first future job. break + self._enqueue_job(task_class) @override def run(self) -> None: @@ -66,10 +105,8 @@ def run(self) -> None: try: self.run_start() with self._cond: + self._create_task_times() while not self._stop_event.is_set(): - self._run_expired_jobs() - self._create_task_times() - sleep(2) # try to fix double jobs timeout = self._get_timeout() # Idle gaps between scheduled tasks are typically # hours-to-days. Release the conn so the next @@ -78,10 +115,18 @@ def run(self) -> None: # whole window. Reopen on the next query is # ~5-20 ms, invisible against the wait. connections.close_all() - self._cond.wait(timeout=timeout) + # ``Condition.wait`` returns False only when the whole + # timeout elapsed; True means ``end_timeout`` notified. + timed_out = not self._cond.wait(timeout=timeout) if self._stop_event.is_set(): break - sleep(2) # fix time rounding problems + self._run_expired_jobs(timed_out=timed_out) + # Recompute *after* queueing, so the new schedule sees + # the slots those jobs just claimed. Recomputing first + # re-read the telemeter's unchanged send time and put + # the same task on the queue again, once per pass, for + # as long as the offloaded send took to finish. + self._create_task_times() except Exception: self.log.exception(f"In {self.__class__.__name__}") self.log.debug(f"Stopped {self.__class__.__name__}.") diff --git a/codex/librarian/telemeter/scheduled_time.py b/codex/librarian/telemeter/scheduled_time.py index 5721f3882..97924a959 100644 --- a/codex/librarian/telemeter/scheduled_time.py +++ b/codex/librarian/telemeter/scheduled_time.py @@ -49,6 +49,24 @@ def _get_scheduled_time(ts) -> datetime | None: return telemeter_time +def mark_telemeter_attempt() -> None: + """ + Spend this week's send slot. + + :func:`_get_scheduled_time` decides whether to schedule by comparing + the week's slot against ``updated_at``, so ``updated_at`` has to move + the moment the task is *queued*. The send itself records its own + attempt, but it runs on a throwaway thread ``BookmarkThread`` spawns + and never joins, and it only gets to that write after roughly thirty + count queries plus up to a five second network timeout. Waiting for + it left the slot unspent, so every pass of the cron loop recomputed + the same overdue time and queued the task again. + """ + ts = get_telemeter_timestamp() + # ``updated_at`` is ``auto_now``, so a plain save is the touch. + ts.save() + + def get_telemeter_time(log: Logger) -> datetime | None: """Get the time to send telemetry.""" # Should we schedule telemeter at all? diff --git a/codex/librarian/telemeter/telemeter.py b/codex/librarian/telemeter/telemeter.py index a780466b9..94094905e 100644 --- a/codex/librarian/telemeter/telemeter.py +++ b/codex/librarian/telemeter/telemeter.py @@ -2,7 +2,6 @@ import json from base64 import a85decode, b64encode -from http import HTTPStatus from lzma import compress from os import environ from typing import Final @@ -89,12 +88,12 @@ def _post_stats(data) -> None: request = Request( # noqa: S310 _POST, data=compressed_data, headers=_new_headers(), method="POST" ) - # urlopen raises HTTPError on 4xx & 5xx, so reaching here is a success. - # http.client's response has no raise_for_status(); that was requests'. - with urlopen(request, timeout=_TIMEOUT) as response: # noqa: S310 - if response.status >= HTTPStatus.BAD_REQUEST: - reason = f"Stats server returned {response.status}" - raise ValueError(reason) + # urlopen raises HTTPError on any status outside 2xx, and follows 3xx, so + # returning from here is a success. Checking response.status ourselves + # would be dead code; http.client's response has no raise_for_status(), + # that was requests'. The server answers 202: queued, not yet stored. + with urlopen(request, timeout=_TIMEOUT): # noqa: S310 + pass def _send_telemetry(uuid) -> None: @@ -118,10 +117,22 @@ def send_telemetry(log) -> None: try: _send_telemetry(ts.value) except Exception as exc: - # Not debug: a transport bug here went unnoticed for months - # because every failure was invisible at the default log level. - log.warning(f"Failed to send anonymous stats: {exc}") - # update updated_at, even on failure to prevent rapid rescheudling. + # This was briefly a warning, because a transport bug went + # unnoticed for months while the stats server answered 200 to + # everything. The server reports honest statuses now, so its + # operator sees failures in the access log and this install has + # no reason to say anything: debug is invisible at the default + # log level, and a stats server is never the user's problem. + # repr, not str: str(TimeoutError()) is empty, and an HTTPError's + # repr names the status code. Neither can carry a response body. + log.debug(f"Failed to send anonymous stats: {exc!r}") + # Record the attempt whether or not it succeeded: retrying a + # failed send would turn a stats outage into a stampede. The cron + # thread already claimed this week's slot before queueing the + # task (``mark_telemeter_attempt``) — it cannot wait for this + # write, which happens on a thread it never joins — so this is a + # backstop for any other caller, and it leaves the recorded time + # honest about when the send actually finished. ts.save() except Exception as exc: log.debug(f"Failed to get or set telemeter timestamp: {exc}") From c014acb1b8e2c5d38911def4a904357f0271f505 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sun, 26 Jul 2026 17:45:58 -0700 Subject: [PATCH 50/66] test crond double enqueu fix --- tests/test_crond.py | 204 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 tests/test_crond.py diff --git a/tests/test_crond.py b/tests/test_crond.py new file mode 100644 index 000000000..cfa7f3aed --- /dev/null +++ b/tests/test_crond.py @@ -0,0 +1,204 @@ +""" +Cron scheduling invariants. + +:class:`CronThread` queues due jobs and *then* recomputes the schedule. +For the telemeter that schedule lives in the database and is written by +the send itself, which ``BookmarkThread`` runs on a daemon thread nobody +joins — so the recompute has to see a slot the scheduler spent, not one +the send has not gotten around to writing yet. +""" + +from __future__ import annotations + +from datetime import timedelta +from queue import SimpleQueue +from threading import Lock +from typing import TYPE_CHECKING +from uuid import UUID + +from django.test import TestCase +from django.utils import timezone +from loguru import logger as _loguru_logger + +from codex.choices.admin import AdminFlagChoices +from codex.librarian.cron.crond import CronThread +from codex.librarian.scribe.janitor.tasks import JanitorNightlyTask +from codex.librarian.telemeter.scheduled_time import ( + get_telemeter_time, + mark_telemeter_attempt, +) +from codex.librarian.telemeter.tasks import TelemeterTask +from codex.librarian.telemeter.telemeter import get_telemeter_timestamp +from codex.models.admin import AdminFlag, Timestamp +from codex.startup import init_admin_flags + +if TYPE_CHECKING: + from loguru._logger import Logger + +# The send time is the install uuid scaled across the week, so int=0 puts +# it at Monday 00:00 UTC — always in the past, so the job is always due. +_START_OF_WEEK_UUID = str(UUID(int=0)) +_LONG_AGO = timedelta(days=30) +# ``loguru.logger`` *is* a ``loguru._logger.Logger`` at runtime; loguru's +# stubs declare a second, unrelated ``loguru.Logger`` for the singleton, +# and that is not the type the librarian annotates its ``log`` params with. +_LOG: Logger = _loguru_logger # pyright: ignore[reportAssignmentType] # ty: ignore[invalid-assignment] + + +def _make_telemeter_overdue() -> Timestamp: + """Make the telemeter look installed long ago and never sent.""" + init_admin_flags() + AdminFlag.objects.filter(key=AdminFlagChoices.SEND_TELEMETRY.value).update(on=True) + ts = get_telemeter_timestamp() + long_ago = timezone.now() - _LONG_AGO + # A queryset update writes the columns auto_now/auto_now_add own. + Timestamp.objects.filter(pk=ts.pk).update( + value=_START_OF_WEEK_UUID, created_at=long_ago, updated_at=long_ago + ) + ts.refresh_from_db() + return ts + + +def _new_cron_thread() -> tuple[CronThread, SimpleQueue]: + """Build a cron thread wired to a queue the test can read, never started.""" + queue = SimpleQueue() + return CronThread(_LOG, queue, Lock()), queue + + +def _drain(queue: SimpleQueue) -> list: + """Everything the loop put on the librarian queue.""" + tasks = [] + while not queue.empty(): + tasks.append(queue.get_nowait()) + return tasks + + +def _cron_pass(thread: CronThread) -> None: + """ + One trip around the cron loop, minus the wait. + + ``timed_out=True`` is the worst case for the job at the head of the + schedule: it is the value the loop uses when it has waited out the + full timeout, so nothing about a short clock reading can excuse a + second enqueue. + """ + thread._run_expired_jobs(timed_out=True) # noqa: SLF001 + thread._create_task_times() # noqa: SLF001 + + +def _scheduled_classes(thread: CronThread) -> set[type]: + """Collect the task classes on the thread's current schedule.""" + return {task_class for _, task_class in thread._task_times} # noqa: SLF001 + + +class CronScheduleTestCase(TestCase): + """The loop must queue an overdue job exactly once.""" + + def _start(self) -> tuple[CronThread, SimpleQueue]: + """Prime a cron thread on an overdue telemeter, the way ``run`` primes it.""" + _make_telemeter_overdue() + thread, queue = _new_cron_thread() + thread._create_task_times() # noqa: SLF001 + return thread, queue + + def test_telemeter_is_queued_once_while_the_send_is_in_flight(self) -> None: + """ + The reported bug: three passes, no send, one task. + + Never calling ``send_telemetry`` is the point — it stands in for + the offloaded send still collecting counts or blocked on its five + second network timeout, having written nothing back yet. + """ + thread, queue = self._start() + for _ in range(3): + _cron_pass(thread) + queued = [task for task in _drain(queue) if isinstance(task, TelemeterTask)] + assert len(queued) == 1, ( + f"Queued {len(queued)} TelemeterTasks; the send never ran, so the " + "loop re-read an unclaimed send time and enqueued it again." + ) + + def test_queueing_the_telemeter_takes_it_off_the_schedule(self) -> None: + """A claimed slot must be gone from the very next recompute.""" + thread, _ = self._start() + assert TelemeterTask in _scheduled_classes(thread) + _cron_pass(thread) + assert TelemeterTask not in _scheduled_classes(thread) + # Not merely absent from the tuple — unschedulable at the source, + # which is what keeps ``_get_timeout`` off zero and the loop from + # spinning. + assert get_telemeter_time(_LOG) is None + + def test_next_week_is_scheduled_again(self) -> None: + """Spending this week's slot must not retire the job.""" + thread, _ = self._start() + _cron_pass(thread) + assert get_telemeter_time(_LOG) is None + # Age the claim past the start of this week: the slot moves with + # the calendar, the claim does not. + Timestamp.objects.filter(key=Timestamp.Choices.TELEMETER_SENT.value).update( + updated_at=timezone.now() - timedelta(days=8) + ) + assert get_telemeter_time(_LOG) is not None + + +class CronWakeupTestCase(TestCase): + """Which jobs a wakeup is allowed to run.""" + + @staticmethod + def _janitor_a_moment_out() -> tuple[CronThread, SimpleQueue]: + """Put the nightly janitor just past the clock, as truncation does.""" + thread, queue = _new_cron_thread() + soon = timezone.now() + timedelta(milliseconds=500) + thread._task_times = ((soon, JanitorNightlyTask),) # noqa: SLF001 + return thread, queue + + def test_a_full_timeout_runs_the_job_it_waited_for(self) -> None: + """ + ``_get_timeout`` truncates to whole seconds, so the wait ends early. + + Declining the job then would let ``_create_task_times`` push the + nightly janitor out to the following midnight and skip a night. + """ + thread, queue = self._janitor_a_moment_out() + thread._run_expired_jobs(timed_out=True) # noqa: SLF001 + assert not queue.empty() + assert isinstance(queue.get_nowait(), JanitorNightlyTask) + + def test_being_woken_early_runs_nothing_early(self) -> None: + """``end_timeout`` is a nudge to reschedule, not a licence to run.""" + thread, queue = self._janitor_a_moment_out() + thread._run_expired_jobs(timed_out=False) # noqa: SLF001 + assert queue.empty() + + +class TelemeterScheduleTestCase(TestCase): + """What ``get_telemeter_time`` will and will not schedule.""" + + def test_an_overdue_send_is_scheduled_in_the_past(self) -> None: + """A missed slot stays due rather than rolling over.""" + _make_telemeter_overdue() + dttm = get_telemeter_time(_LOG) + assert dttm is not None + assert dttm <= timezone.now() + + def test_disabled_telemetry_is_not_scheduled(self) -> None: + """The admin flag is checked before anything else.""" + _make_telemeter_overdue() + AdminFlag.objects.filter(key=AdminFlagChoices.SEND_TELEMETRY.value).update( + on=False + ) + assert get_telemeter_time(_LOG) is None + + def test_a_fresh_install_waits_a_day(self) -> None: + """New installs don't report on their first day.""" + ts = _make_telemeter_overdue() + Timestamp.objects.filter(pk=ts.pk).update(created_at=timezone.now()) + assert get_telemeter_time(_LOG) is None + + def test_claiming_the_slot_unschedules_it(self) -> None: + """The unit the cron thread relies on, independent of the loop.""" + _make_telemeter_overdue() + assert get_telemeter_time(_LOG) is not None + mark_telemeter_attempt() + assert get_telemeter_time(_LOG) is None From 7c65e0d889c07ea0a811e78b51657e7f64599625 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sun, 26 Jul 2026 17:46:21 -0700 Subject: [PATCH 51/66] test telmemeter logging fixes --- tests/test_telemeter.py | 89 ++++++++++++++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 15 deletions(-) diff --git a/tests/test_telemeter.py b/tests/test_telemeter.py index 76a7f0beb..37a9dba3e 100644 --- a/tests/test_telemeter.py +++ b/tests/test_telemeter.py @@ -1,19 +1,22 @@ """Tests for the anonymous stats payload and how it is transmitted.""" import json +from datetime import timedelta from email.message import Message from lzma import decompress from typing import override from unittest.mock import patch -from urllib.error import HTTPError +from urllib.error import HTTPError, URLError from urllib.parse import urlsplit import pytest from django.http import QueryDict from django.test import TestCase +from django.utils import timezone from codex.librarian.telemeter import telemeter from codex.librarian.telemeter.stats import CodexStats +from codex.models.admin import Timestamp from codex.serializers.admin.stats import ( AdminStatsRequestSerializer, StatsSerializer, @@ -40,9 +43,6 @@ class _FakeResponse: """Stand in for http.client.HTTPResponse.""" - def __init__(self, status: int = 200) -> None: - self.status = status - def __enter__(self): return self @@ -50,9 +50,46 @@ def __exit__(self, *_args) -> bool: return False +class _DebugOnlyLog: + """ + A log that can only be called at debug. + + Failing to reach the stats server is never the user's problem, so it is + reported at debug and nowhere else. Exposing only that method makes a + revert to warning() an AttributeError rather than a silent regression. + """ + + def __init__(self) -> None: + self.logged: list[str] = [] + + def debug(self, msg) -> None: + self.logged.append(msg) + + def sent_stats_quietly(self, *fragments: str) -> bool: + """Whether one debug line reports a failed send, mentioning fragments.""" + return any( + "Failed to send anonymous stats" in msg + and all(fragment in msg for fragment in fragments) + for msg in self.logged + ) + + class TelemeterTransportTestCase(TestCase): """The url and headers the telemeter actually puts on the wire.""" + @override + def setUp(self) -> None: + """ + Seed the admin flags the send path reads. + + Without the SEND_TELEMETRY row, _send_telemetry raises DoesNotExist at + the flag lookup and never reaches the mocked urlopen, so a transport + test would pass without exercising any transport. + """ + from codex.startup import init_admin_flags + + init_admin_flags() + def test_posts_to_wire_version_two(self) -> None: """Codex 2.2.3 and later report the expanded payload as version 2.""" assert telemeter._VERSION == "2" # noqa: SLF001 @@ -95,19 +132,41 @@ def test_http_error_reaches_the_caller(self) -> None: ): telemeter._post_stats({"uuid": "abc"}) # noqa: SLF001 - def test_send_telemetry_survives_a_failed_post(self) -> None: - """A dead stats server must never break the install.""" - logged = [] - - class _Log: - @staticmethod - def warning(msg) -> None: - logged.append(msg) - + def test_a_rejected_post_is_survived_quietly(self) -> None: + """A stats server that refuses the post must not break or alarm.""" + log = _DebugOnlyLog() error = HTTPError(telemeter._POST, 500, "boom", Message(), None) # noqa: SLF001 with patch.object(telemeter, "urlopen", side_effect=error): - telemeter.send_telemetry(_Log()) - assert logged + telemeter.send_telemetry(log) + # The status code, not merely some line: the outer handler logs at + # debug too, so presence alone would pass even if this one regressed + # to warning and blew up on the log above. + assert log.sent_stats_quietly("500") + + def test_an_unreachable_server_is_survived_quietly(self) -> None: + """Nor is a timeout or a dns failure the user's problem.""" + log = _DebugOnlyLog() + with patch.object(telemeter, "urlopen", side_effect=URLError(TimeoutError())): + telemeter.send_telemetry(log) + assert log.sent_stats_quietly("TimeoutError") + + def test_a_failed_send_still_waits_a_week(self) -> None: + """ + The timestamp marks the attempt, not the success. + + Retrying a failed send would turn a stats outage into a stream of + requests from every install at once, so the week's slot is spent + either way. + """ + ts = telemeter.get_telemeter_timestamp() + stale = timezone.now() - timedelta(days=30) + # A queryset update writes the column that auto_now would overwrite. + Timestamp.objects.filter(pk=ts.pk).update(updated_at=stale) + error = HTTPError(telemeter._POST, 503, "down", Message(), None) # noqa: SLF001 + with patch.object(telemeter, "urlopen", side_effect=error): + telemeter.send_telemetry(_DebugOnlyLog()) + ts.refresh_from_db() + assert ts.updated_at > stale class TelemeterStatsTestCase(TestCase): From 4bec196b34cf5af2880abe92d31054db9b588c60 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sun, 26 Jul 2026 19:45:23 -0700 Subject: [PATCH 52/66] feat(api): reintroduce Swagger UI at /api/v4/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive docs were lost in the v4 cutover (9b8314d3b, v2.0.0) when codex/urls/api/v3.py was deleted; only the raw schema route survived. Mount SpectacularSwaggerSplitView at the v4 root, gated on FEATURES.swagger — the flag that until now only switched a CSP overlay for routes that no longer existed. The split view keeps its init javascript in a second same-origin request, so no inline diff --git a/frontend/src/components/reader/toolbars/top/reader-toolbar-top.vue b/frontend/src/components/reader/toolbars/top/reader-toolbar-top.vue index 7f3d13b90..799fc9c94 100644 --- a/frontend/src/components/reader/toolbars/top/reader-toolbar-top.vue +++ b/frontend/src/components/reader/toolbars/top/reader-toolbar-top.vue @@ -136,7 +136,7 @@ export default { book.childCount = 0; // Browser card items carry an ``ids`` array; the reader's book only // has a ``pk``. Without ``ids`` the online-tag dialog's ``idTaggable`` - // check fails and the tag-by-id tab never appears for a single comic. + // check fails and its ID inputs never appear for a single comic. book.ids = book.pk == undefined ? [] : [book.pk]; return book; }, diff --git a/frontend/src/stores/online-tag.js b/frontend/src/stores/online-tag.js index 2d70903d8..56374e704 100644 --- a/frontend/src/stores/online-tag.js +++ b/frontend/src/stores/online-tag.js @@ -33,7 +33,11 @@ export const useOnlineTagStore = defineStore("onlineTag", { deleteOriginal, mergeAllSources, rename, + ids, }) { + // ``ids`` pins an issue id per source ({metron: "metron:123"}, single + // comic only): those sources are fetched by id and the rest are + // searched, in one lookup, so a mixed run still merges. const response = await HTTP.post("/admin/tag-sessions/start", { collection, pks: pks.map(String), @@ -43,32 +47,11 @@ export const useOnlineTagStore = defineStore("onlineTag", { deleteOriginal, mergeAllSources, rename, + ids: ids || {}, }); this.activeSessionId = response.data.sessionId; return response.data; }, - async tagById({ - collection, - pk, - identifier, - identifiers, - source, - mergeAllSources, - rename, - }) { - // Tag one comic by a known Metron / Comic Vine issue id, skipping - // search. Returns the resolved { source, id } the server queued. - const response = await HTTP.post("/admin/tag-by-id", { - collection, - pk: String(pk), - identifier, - identifiers: identifiers || [identifier], - source: source || "", - mergeAllSources, - rename, - }); - return response.data; - }, async discoverSession() { const response = await HTTP.get("/admin/tag-sessions"); const sid = response.data.sessionId; diff --git a/frontend/tests/unit/launcher-dialog.test.js b/frontend/tests/unit/launcher-dialog.test.js index 1475541bd..1e3456b48 100644 --- a/frontend/tests/unit/launcher-dialog.test.js +++ b/frontend/tests/unit/launcher-dialog.test.js @@ -1,10 +1,11 @@ /* * Tests for the unified Online Tagging dialog. * - * One dialog with two expansion panels: "Search" (filename search + match) - * and "By ID" (exact issue id). Only one panel opens at a time; the action - * button reads "Search" or "Tag" depending on the open panel; the By ID panel - * only exists when the selection is a single, id-taggable comic. + * One pane: sources, optional per-source pinned ids, and the search controls. + * A source with a pinned id is fetched by that id; the rest are searched, in + * one session — so the action button reads "Search", "Tag by ID", or + * "Tag by ID & Search" depending on how many selected sources are pinned. The + * id inputs only appear when the selection is a single, id-taggable comic. */ import { createTestingPinia } from "@pinia/testing"; import { mount } from "@vue/test-utils"; @@ -46,39 +47,71 @@ function mountDialog({ describe("OnlineTagLauncherDialog", () => { describe("action button", () => { - test("reads Search on the search panel and Tag on the By ID panel", async () => { + test("reads Search when nothing is pinned", async () => { const { wrapper } = mountDialog(); + wrapper.vm.sources = ["metron", "comicvine"]; + await wrapper.vm.$nextTick(); + expect(wrapper.vm.actionLabel).toBe("Search"); + }); + + test("reads Tag by ID when every selected source is pinned", async () => { + const { wrapper } = mountDialog(); + + wrapper.vm.sources = ["metron"]; + wrapper.vm.idInputs = ["metron:12345"]; + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.actionLabel).toBe("Tag by ID"); + }); + + test("reads Tag by ID & Search when only some sources are pinned", async () => { + const { wrapper } = mountDialog(); - wrapper.vm.activeTab = "byId"; + wrapper.vm.sources = ["metron", "comicvine"]; + wrapper.vm.idInputs = ["metron:12345"]; await wrapper.vm.$nextTick(); - expect(wrapper.vm.actionLabel).toBe("Tag"); + expect(wrapper.vm.actionLabel).toBe("Tag by ID & Search"); }); test("search is disabled without a selected source", async () => { const { wrapper } = mountDialog(); - wrapper.vm.activeTab = "search"; wrapper.vm.sources = []; await wrapper.vm.$nextTick(); expect(wrapper.vm.actionDisabled).toBe(true); }); - test("By ID is disabled until an identifier is entered", async () => { + test("an unpinned source is still searchable, so submit stays enabled", async () => { const { wrapper } = mountDialog(); - wrapper.vm.activeTab = "byId"; + wrapper.vm.sources = ["metron"]; await wrapper.vm.$nextTick(); - expect(wrapper.vm.actionDisabled).toBe(true); - wrapper.vm.idInputs = ["metron:12345"]; - await wrapper.vm.$nextTick(); expect(wrapper.vm.actionDisabled).toBe(false); }); + test("an id for an unconfigured source blocks submit", async () => { + const { wrapper } = mountDialog({ + taggingDefaults: { + hasMetronCredentials: true, + hasComicvineCredentials: false, + metronKeySet: true, + }, + }); + + wrapper.vm.sources = ["metron"]; + wrapper.vm.idInputs = ["comicvine:4000-456"]; + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.pinnedIds).toEqual({}); + expect(wrapper.vm.unconfiguredIdWarning).toContain("Comic Vine"); + expect(wrapper.vm.actionDisabled).toBe(true); + }); + test("disables the action when no source credentials are configured", () => { const { wrapper } = mountDialog({ taggingDefaults: { @@ -125,7 +158,7 @@ describe("OnlineTagLauncherDialog", () => { expect(wrapper.find(".credentialWarning").exists()).toBe(false); }); - test("tracks the By ID tab's id source", async () => { + test("returns once a pinned Metron id auto-selects Metron", async () => { const { wrapper } = mountDialog({ taggingDefaults: { hasMetronCredentials: true, @@ -133,7 +166,7 @@ describe("OnlineTagLauncherDialog", () => { metronKeySet: false, }, }); - wrapper.vm.activeTab = "byId"; + wrapper.vm.sources = ["comicvine"]; wrapper.vm.idInputs = ["comicvine:4000-12345"]; await wrapper.vm.$nextTick(); expect(wrapper.vm.metronLegacyCredentials).toBe(false); @@ -166,7 +199,7 @@ describe("OnlineTagLauncherDialog", () => { }); }); - describe("idTaggable (whether the By ID panel exists)", () => { + describe("idTaggable (whether the id inputs exist)", () => { test("true for a single comic", () => { const { wrapper } = mountDialog(); expect(wrapper.vm.idTaggable).toBe(true); @@ -187,11 +220,10 @@ describe("OnlineTagLauncherDialog", () => { }); }); - describe("Search panel", () => { - test("submit starts a tagging session", async () => { + describe("submit", () => { + test("an unpinned session starts a plain search", async () => { const { wrapper, onlineTagStore } = mountDialog(); - wrapper.vm.activeTab = "search"; wrapper.vm.sources = ["metron"]; await wrapper.vm.$nextTick(); await wrapper.vm.submit(); @@ -203,9 +235,9 @@ describe("OnlineTagLauncherDialog", () => { sources: ["metron"], mode: "auto", promptsMode: "ask", + ids: {}, }), ); - expect(onlineTagStore.tagById).not.toHaveBeenCalled(); }); test("submits sources in the admin-configured priority order", async () => { @@ -217,7 +249,6 @@ describe("OnlineTagLauncherDialog", () => { defaultSources: ["comicvine", "metron"], }; - wrapper.vm.activeTab = "search"; // Checkbox v-model recorded the clicks metron-first... wrapper.vm.sources = ["metron", "comicvine"]; await wrapper.vm.$nextTick(); @@ -228,30 +259,156 @@ describe("OnlineTagLauncherDialog", () => { expect.objectContaining({ sources: ["comicvine", "metron"] }), ); }); - }); - describe("By ID panel", () => { - test("submit tags by id", async () => { + test("a mixed session sends the pinned id and both sources", async () => { + const { wrapper, onlineTagStore } = mountDialog(); + + wrapper.vm.sources = ["metron", "comicvine"]; + wrapper.vm.idInputs = ["metron:12345"]; + await wrapper.vm.$nextTick(); + await wrapper.vm.submit(); + + expect(onlineTagStore.startSession).toHaveBeenCalledWith( + expect.objectContaining({ + ids: { metron: "metron:12345" }, + sources: ["metron", "comicvine"], + }), + ); + expect(wrapper.emitted("started")).toBeTruthy(); + }); + + test("a fully pinned session still goes through startSession", async () => { const { wrapper, onlineTagStore } = mountDialog(); - onlineTagStore.tagById.mockResolvedValue({ source: "metron", id: 12345 }); - wrapper.vm.activeTab = "byId"; + wrapper.vm.sources = ["metron"]; wrapper.vm.idInputs = ["metron:12345"]; await wrapper.vm.$nextTick(); await wrapper.vm.submit(); - expect(onlineTagStore.tagById).toHaveBeenCalledWith({ - collection: "comics", - pk: 7, - identifier: "metron:12345", - identifiers: ["metron:12345"], - source: "", - mergeAllSources: false, - rename: false, + expect(onlineTagStore.startSession).toHaveBeenCalledWith( + expect.objectContaining({ ids: { metron: "metron:12345" } }), + ); + expect(wrapper.emitted("started")).toBeTruthy(); + }); + + test("both sources pinned sends both ids", async () => { + const { wrapper, onlineTagStore } = mountDialog(); + + wrapper.vm.sources = ["metron", "comicvine"]; + wrapper.vm.idInputs = ["metron:12345", "comicvine:4000-456"]; + await wrapper.vm.$nextTick(); + await wrapper.vm.submit(); + + expect(onlineTagStore.startSession).toHaveBeenCalledWith( + expect.objectContaining({ + ids: { metron: "metron:12345", comicvine: "comicvine:4000-456" }, + }), + ); + }); + + test("a stale id can't leak from a selection with no id inputs", async () => { + const { wrapper, onlineTagStore } = mountDialog({ + book: { collection: "comics", ids: [7, 8], childCount: 2 }, }); - expect(onlineTagStore.startSession).not.toHaveBeenCalled(); + + wrapper.vm.sources = ["metron"]; + wrapper.vm.idInputs = ["metron:12345"]; + await wrapper.vm.$nextTick(); + await wrapper.vm.submit(); + + expect(wrapper.vm.idTaggable).toBe(false); + expect(onlineTagStore.startSession).toHaveBeenCalledWith( + expect.objectContaining({ ids: {} }), + ); }); + }); + describe("pinned ids", () => { + test("pinning a source that isn't selected selects it", async () => { + const { wrapper } = mountDialog(); + + wrapper.vm.sources = ["comicvine"]; + await wrapper.vm.$nextTick(); + wrapper.vm.idInputs = ["metron:12345"]; + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.sources).toContain("metron"); + }); + + test("clicking an existing-id chip selects its source", async () => { + const { wrapper } = mountDialog({ + identifiers: [ + { + source: "metron", + type: "comic", + code: "12345", + displayName: "Metron", + }, + ], + }); + + wrapper.vm.sources = ["comicvine"]; + await wrapper.vm.$nextTick(); + wrapper.vm.addId(wrapper.vm.existingOptions[0].value); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.sources).toContain("metron"); + expect(wrapper.vm.pinnedIds).toEqual({ metron: "metron:12345" }); + }); + + test("a bare number pins once its source is picked", async () => { + const { wrapper } = mountDialog(); + + wrapper.vm.idInputs = ["12345"]; + await wrapper.vm.$nextTick(); + expect(wrapper.vm.pinnedIds).toEqual({}); + + wrapper.vm.sourceChoice = "metron"; + await wrapper.vm.$nextTick(); + expect(wrapper.vm.pinnedIds).toEqual({ metron: "12345" }); + }); + + test("only the first id per source is used", async () => { + const { wrapper } = mountDialog(); + + wrapper.vm.idInputs = ["metron:12345", "metron:99999"]; + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.pinnedIds).toEqual({ metron: "metron:12345" }); + expect(wrapper.vm.duplicateIdWarning).toContain("Metron"); + }); + }); + + describe("search controls with every source pinned", () => { + test("match mode and prompts are disabled and explain why", async () => { + const { wrapper } = mountDialog(); + + wrapper.vm.sources = ["metron"]; + wrapper.vm.idInputs = ["metron:12345"]; + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.searchControlsDisabled).toBe(true); + expect(wrapper.vm.matchModeHint).toBe( + "Not used when all sources are tagged by ID.", + ); + expect(wrapper.vm.promptsModeHint).toBe( + "Not used when all sources are tagged by ID.", + ); + }); + + test("they come back for a mixed session", async () => { + const { wrapper } = mountDialog(); + + wrapper.vm.sources = ["metron", "comicvine"]; + wrapper.vm.idInputs = ["metron:12345"]; + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.searchControlsDisabled).toBe(false); + expect(wrapper.vm.promptsModeHint).toContain("Pauses on ambiguous"); + }); + }); + + describe("id inputs", () => { test("offers the comic's Metron / Comic Vine ids as options", () => { const { wrapper } = mountDialog({ identifiers: [ @@ -296,7 +453,6 @@ describe("OnlineTagLauncherDialog", () => { test("a bare integer with both sources requires a source pick", async () => { const { wrapper } = mountDialog(); - wrapper.vm.activeTab = "byId"; wrapper.vm.idInputs = ["12345"]; await wrapper.vm.$nextTick(); @@ -313,48 +469,18 @@ describe("OnlineTagLauncherDialog", () => { }, }); - wrapper.vm.activeTab = "byId"; wrapper.vm.idInputs = ["12345"]; await wrapper.vm.$nextTick(); expect(wrapper.vm.needsSourceSelect).toBe(false); expect(wrapper.vm.needsSourcePick).toBe(false); + expect(wrapper.vm.pinnedIds).toEqual({ metron: "12345" }); expect(wrapper.vm.actionDisabled).toBe(false); }); - test("selecting an existing id fills the field and submits it", async () => { - const { wrapper, onlineTagStore } = mountDialog({ - identifiers: [ - { - source: "metron", - type: "comic", - code: "12345", - displayName: "Metron", - }, - ], - }); - onlineTagStore.tagById.mockResolvedValue({ source: "metron", id: 12345 }); - - wrapper.vm.activeTab = "byId"; - // The chip's @click adds the option value to the chips input. - wrapper.vm.addId(wrapper.vm.existingOptions[0].value); - await wrapper.vm.submit(); - - expect(onlineTagStore.tagById).toHaveBeenCalledWith({ - collection: "comics", - pk: 7, - identifier: "metron:12345", - identifiers: ["metron:12345"], - source: "", - mergeAllSources: false, - rename: false, - }); - }); - test("only the bare-number chip triggers the source select", async () => { const { wrapper } = mountDialog(); - wrapper.vm.activeTab = "byId"; wrapper.vm.idInputs = ["https://metron.cloud/issue/12345/"]; await wrapper.vm.$nextTick(); // A URL self-identifies, so no manual source needed. @@ -365,41 +491,17 @@ describe("OnlineTagLauncherDialog", () => { expect(wrapper.vm.needsSourceSelect).toBe(true); }); - test("merge is gated on two identifiers in the input", async () => { + test("a pasted pair splits into one chip per source", async () => { const { wrapper } = mountDialog(); - wrapper.vm.activeTab = "byId"; - wrapper.vm.idInputs = ["metron:12345"]; - await wrapper.vm.$nextTick(); - expect(wrapper.vm.canMergeById).toBe(false); - wrapper.vm.setIdInputs(["metron:12345 comicvine:4000-456"]); await wrapper.vm.$nextTick(); - // The space-joined paste splits into two chips. + expect(wrapper.vm.idInputs).toEqual([ "metron:12345", "comicvine:4000-456", ]); - expect(wrapper.vm.canMergeById).toBe(true); - }); - - test("submits both identifiers when merging by id", async () => { - const { wrapper, onlineTagStore } = mountDialog(); - onlineTagStore.tagById.mockResolvedValue({ source: "metron", id: 12345 }); - - wrapper.vm.activeTab = "byId"; - wrapper.vm.idInputs = ["metron:12345", "comicvine:4000-456"]; - wrapper.vm.mergeAllSources = true; - await wrapper.vm.$nextTick(); - await wrapper.vm.submit(); - - expect(onlineTagStore.tagById).toHaveBeenCalledWith( - expect.objectContaining({ - identifier: "metron:12345", - identifiers: ["metron:12345", "comicvine:4000-456"], - mergeAllSources: true, - }), - ); + expect(wrapper.vm.pinnedSources).toEqual(["metron", "comicvine"]); }); }); diff --git a/frontend/tests/unit/online-tag-store.test.js b/frontend/tests/unit/online-tag-store.test.js index 4f1da261f..95d47dd1a 100644 --- a/frontend/tests/unit/online-tag-store.test.js +++ b/frontend/tests/unit/online-tag-store.test.js @@ -147,4 +147,44 @@ describe("useOnlineTagStore — resolution reconciliation", () => { }); }); +describe("useOnlineTagStore — startSession", () => { + const started = { data: { sessionId: "s1", skipped: 0 } }; + + it("sends the pinned per-source ids", async () => { + const store = useOnlineTagStore(); + HTTP.post.mockResolvedValue(started); + + await store.startSession({ + collection: "comics", + pks: [7], + sources: ["metron", "comicvine"], + mode: "auto", + promptsMode: "ask", + ids: { metron: "metron:12345" }, + }); + + expect(HTTP.post).toHaveBeenCalledWith( + "/admin/tag-sessions/start", + expect.objectContaining({ + pks: ["7"], + sources: ["metron", "comicvine"], + ids: { metron: "metron:12345" }, + }), + ); + expect(store.activeSessionId).toBe("s1"); + }); + + it("defaults ids to an empty mapping for a plain search", async () => { + const store = useOnlineTagStore(); + HTTP.post.mockResolvedValue(started); + + await store.startSession({ collection: "comics", pks: [7] }); + + expect(HTTP.post).toHaveBeenCalledWith( + "/admin/tag-sessions/start", + expect.objectContaining({ ids: {} }), + ); + }); +}); + export default {}; diff --git a/frontend/tests/unit/rename-toggle.test.js b/frontend/tests/unit/rename-toggle.test.js index 78a4c3b5d..03e42822e 100644 --- a/frontend/tests/unit/rename-toggle.test.js +++ b/frontend/tests/unit/rename-toggle.test.js @@ -2,7 +2,8 @@ * Tests for the comicbox "rename files" toggle across the UI. * * Behavior locked in here: - * - The online-tag store forwards `rename` to the start and by-id endpoints. + * - The online-tag store forwards `rename` to the start endpoint, searching + * or tagging by a pinned id. * - The edit panel seeds its rename toggle from the admin default and lets * the toggle alone enable Save (rename-only, no tag edits). * - The admin Tagging tab renders a "Rename files" default and binds it. @@ -47,18 +48,18 @@ describe("useOnlineTagStore — rename forwarding", () => { ); }); - it("forwards rename to the by-id endpoint", async () => { + it("forwards rename alongside a pinned id", async () => { const store = useOnlineTagStore(); - HTTP.post.mockResolvedValue({ data: { source: "metron", id: 1 } }); - await store.tagById({ + HTTP.post.mockResolvedValue({ data: { sessionId: "s1" } }); + await store.startSession({ collection: "comics", - pk: 1, - identifier: "metron:1", + pks: [1], + ids: { metron: "metron:1" }, rename: true, }); expect(HTTP.post).toHaveBeenCalledWith( - "/admin/tag-by-id", - expect.objectContaining({ rename: true }), + "/admin/tag-sessions/start", + expect.objectContaining({ ids: { metron: "metron:1" }, rename: true }), ); }); }); diff --git a/tests/test_onlinetag_resume_endpoints.py b/tests/test_onlinetag_resume_endpoints.py index e78ce6f6d..702691e0b 100644 --- a/tests/test_onlinetag_resume_endpoints.py +++ b/tests/test_onlinetag_resume_endpoints.py @@ -28,7 +28,7 @@ "prompts_mode": "ask", "delete_original": False, "merge_all_sources": False, - "dry_run": False, + "ids": {}, } @@ -87,9 +87,12 @@ def test_resume_enqueues_task_over_remaining_pks(self) -> None: assert task.prompts_mode == "ask" def test_resume_drops_params_from_an_older_codex(self) -> None: - # A descriptor written before a knob was removed (auto_threshold) must - # still resume instead of raising TypeError on the task constructor. - set_resume_state({**_PARAMS, "auto_threshold": 0.85}, [11]) + # A descriptor written before a knob was removed (auto_threshold, + # dry_run) must still resume instead of raising TypeError on the task + # constructor. An older descriptor also predates ``ids`` entirely. + stale = {**_PARAMS, "auto_threshold": 0.85, "dry_run": False} + del stale["ids"] + set_resume_state(stale, [11]) with patch(_QUEUE_TARGET) as mocked_queue: response = self.client.post(_RESUME_URL) @@ -98,6 +101,18 @@ def test_resume_drops_params_from_an_older_codex(self) -> None: task = mocked_queue.put.call_args.args[0] assert isinstance(task, BulkOnlineTagTask) assert task.comic_pks == frozenset([11]) + assert task.ids == {} + + def test_resume_keeps_pinned_ids(self) -> None: + """A paused pinned session resumes pinned; JSON string keys coerce back.""" + set_resume_state({**_PARAMS, "ids": {"metron": 12345}}, [11]) + + with patch(_QUEUE_TARGET) as mocked_queue: + response = self.client.post(_RESUME_URL) + + assert response.status_code == HTTPStatus.ACCEPTED + task = mocked_queue.put.call_args.args[0] + assert task.ids == {"metron": 12345} def test_resume_conflict_when_scan_active(self) -> None: set_resume_state(_PARAMS, [11]) diff --git a/tests/test_onlinetag_session_manager.py b/tests/test_onlinetag_session_manager.py index 00729a352..4dab49541 100644 --- a/tests/test_onlinetag_session_manager.py +++ b/tests/test_onlinetag_session_manager.py @@ -284,18 +284,43 @@ def test_run_session_unresolved_stored_id_falls_back_to_search(self) -> None: assert not [i for i in self.queue.items if isinstance(i, BulkTagWriteTask)] assert searched == [Path(comic.path)] - def test_run_session_dry_run_skips_prefetch(self) -> None: - """A dry run never prefetches; every comic goes to the search pass.""" + def test_run_session_passes_pinned_ids_to_session(self) -> None: + """A pinned id per source reaches OnlineSession, which fetches it.""" + comic = _make_comic() + self.manager._pass_runner = _double( # noqa: SLF001 + SimpleNamespace(collect_results=lambda *_args, **_kwargs: None) + ) + task = BulkOnlineTagTask( + comic_pks=frozenset({comic.pk}), + session_id="scan-pinned", + sources=("metron", "comicvine"), + mode="auto", + ids={"metron": 12345}, + ) + + with patch(_PATCH_TARGET, _FakeSession): + self.manager.run_session(task) + + assert _FakeSession.last_kwargs["ids"] == {"metron": 12345} + + def test_run_session_pinned_ids_bypass_the_stored_id_prepass(self) -> None: + """ + A pinned request keeps its comic in the session instead of prefetching. + + The prepass drops what it fetches out of the search set, which would + leave the unpinned sources with nothing to search — the pinned and + searched sources have to resolve in the same lookup to merge. + """ comic = _make_comic() self._add_issue_id(comic, "metron", "123495") searched: list = [] self._capture_search_paths(searched) task = BulkOnlineTagTask( comic_pks=frozenset({comic.pk}), - session_id="scan-dry", - sources=("metron",), + session_id="scan-pinned-prepass", + sources=("metron", "comicvine"), mode="auto", - dry_run=True, + ids={"comicvine": 456}, ) fetch_calls: list = [] @@ -307,6 +332,32 @@ def test_run_session_dry_run_skips_prefetch(self) -> None: assert fetch_calls == [] assert searched == [Path(comic.path)] + assert not [i for i in self.queue.items if isinstance(i, BulkTagWriteTask)] + + def test_run_session_pinned_ids_land_in_resume_params(self) -> None: + """A paused pinned session resumes still pinned.""" + comic = _make_comic() + captured: list = [] + self.manager._pass_runner = _double( # noqa: SLF001 + SimpleNamespace( + collect_results=lambda state, *_a, **_k: captured.append( + state.resume_params + ) + ) + ) + task = BulkOnlineTagTask( + comic_pks=frozenset({comic.pk}), + session_id="scan-pinned-resume", + sources=("metron",), + mode="auto", + ids={"metron": 12345}, + ) + + with patch(_PATCH_TARGET, _FakeSession): + self.manager.run_session(task) + + assert captured + assert captured[0]["ids"] == {"metron": 12345} def test_prefetch_keeps_comic_in_status_out_of_search_and_resume(self) -> None: """A prefetched comic shows as matched yet is excluded from Resume.""" diff --git a/tests/test_read_only_library.py b/tests/test_read_only_library.py index 52c72e81f..f45b2ec17 100644 --- a/tests/test_read_only_library.py +++ b/tests/test_read_only_library.py @@ -18,7 +18,15 @@ from django.core.cache import cache from django.test import Client, TestCase -from codex.models import Comic, Imprint, Library, Publisher, Series, Volume +from codex.models import ( + Comic, + ComicboxTaggingDefaults, + Imprint, + Library, + Publisher, + Series, + Volume, +) from codex.startup import init_admin_flags _TEST_PASSWORD: Final = "test-pw-hush-S106" # noqa: S105 @@ -144,6 +152,21 @@ def test_online_tag_skips_read_only(self) -> None: assert frozenset(task.comic_pks) == {self.rw_comic.pk} assert self._body(resp)["skipped"] == 1 + def test_online_tag_by_id_rejects_read_only_comic(self) -> None: + """A pinned id can't rescue a comic whose library is read-only.""" + ComicboxTaggingDefaults.objects.update_or_create( + pk=1, defaults={"metron_key": "t"} + ) + payload = { + "collection": "comics", + "pks": [str(self.ro_comic.pk)], + "ids": {"metron": "metron:123"}, + } + with patch("codex.views.admin.onlinetag.LIBRARIAN_QUEUE") as queue: + resp = self._post(_ONLINE_TAG_URL, payload) + assert resp.status_code == HTTPStatus.BAD_REQUEST, resp.content + assert not queue.put.called + def _editable(self, collection: str, pks) -> bool: pks_str = ",".join(str(pk) for pk in pks) url = f"/api/v4/browse/{collection}/{pks_str}/metadata" diff --git a/tests/test_tag_by_id.py b/tests/test_tag_by_id.py index f7a999d6b..aea3867d2 100644 --- a/tests/test_tag_by_id.py +++ b/tests/test_tag_by_id.py @@ -1,18 +1,18 @@ """ Tests for tagging a comic by a known online issue id. -Covers the three layers the feature adds: the identifier parser (URL / -``source:id`` / bare ``4000-NNN`` / bare integer precedence), the session -manager's ``tag_by_id`` (fetch → write, or surface a not-found), and the admin -endpoint's validation + task enqueue. +Tagging by id is not a separate endpoint: an id pinned per source rides along +in the ordinary start-session request, so a session can fetch the pinned +sources by id and search the rest in one lookup. Covered here: the identifier +parser (URL / ``source:id`` / bare ``4000-NNN`` / bare integer precedence), the +explicit-id config builder the stored-id prepass and prompt resolution share, +and the start endpoint's pinned-id validation + task enqueue. """ from __future__ import annotations -import shutil from http import HTTPStatus -from pathlib import Path -from typing import Any, Final, override +from typing import Final, override from unittest.mock import patch import pytest @@ -20,37 +20,22 @@ from django.contrib.auth.models import User from django.core.cache import caches from django.test import Client, SimpleTestCase, TestCase -from loguru import logger from codex.librarian.onlinetag.explicit_id import ( _result_has_requested_id, build_explicit_id_config, ) -from codex.librarian.onlinetag.session_manager import OnlineTagSessionManager -from codex.librarian.onlinetag.tasks import OnlineTagByIdTask -from codex.librarian.scribe.tagwrite_errors import get_tag_write_errors -from codex.librarian.scribe.tasks import BulkTagWriteTask -from codex.models import ( - Comic, - ComicboxTaggingDefaults, - Imprint, - Library, - Publisher, - Series, - Volume, -) +from codex.librarian.onlinetag.tasks import BulkOnlineTagTask +from codex.models import ComicboxTaggingDefaults from codex.views.admin.identifier_parse import parse_identifier_input -from codex.views.admin.tagwrite import AdminTagByIdView +from codex.views.admin.onlinetag import AdminOnlineTagStartView -_TMP_DIR: Final = Path("/tmp/codex.tests.tagbyid") # noqa: S108 -_FETCH_TARGET: Final = ( - "codex.librarian.onlinetag.session_manager.fetch_tags_by_explicit_id" -) -_VIEW_QUEUE_TARGET: Final = "codex.views.admin.tagwrite.LIBRARIAN_QUEUE" -_TAG_BY_ID_URL: Final = "/api/v4/admin/tag-by-id" +_VIEW_QUEUE_TARGET: Final = "codex.views.admin.onlinetag.LIBRARIAN_QUEUE" +_START_URL: Final = "/api/v4/admin/tag-sessions/start" _TEST_PASSWORD: Final = "test-pw-hush-S106" # noqa: S105 _PK: Final = 7 _ISSUE_ID: Final = 12345 +_CV_ISSUE_ID: Final = 456 class ParseIdentifierInputTests(SimpleTestCase): @@ -164,114 +149,6 @@ def test_build_explicit_id_config_merge(self) -> None: assert set(settings.online.auth.sources) == {"metron", "comicvine"} -def _double(stub: object) -> Any: - """Pass a test double through the manager's strictly-typed seams.""" - return stub - - -class _FakeQueue: - """Collects everything put on it for assertions.""" - - def __init__(self) -> None: - self.items: list = [] - - def put(self, item) -> None: - self.items.append(item) - - -def _make_comic() -> Comic: - _TMP_DIR.mkdir(exist_ok=True, parents=True) - library = Library.objects.create(path=str(_TMP_DIR)) - publisher = Publisher.objects.create(name="P") - imprint = Imprint.objects.create(name="I", publisher=publisher) - series = Series.objects.create(name="S", publisher=publisher, imprint=imprint) - volume = Volume.objects.create( - name="1", publisher=publisher, imprint=imprint, series=series - ) - path = _TMP_DIR / "c.cbz" - path.touch() - return Comic.objects.create( - library=library, - path=path, - issue_number=1, - name="c", - publisher=publisher, - imprint=imprint, - series=series, - volume=volume, - size=1, - file_type="CBZ", - ) - - -class TagByIdSessionManagerTests(TestCase): - """tag_by_id fetches the issue and enqueues a write, or surfaces failure.""" - - @override - def setUp(self) -> None: - caches["default"].clear() - caches["tagging"].clear() - ComicboxTaggingDefaults.objects.update_or_create( - pk=1, - defaults={"metron_key": "t"}, - ) - self.queue = _FakeQueue() # pyright: ignore[reportUninitializedInstanceVariable] - self.manager = OnlineTagSessionManager( # pyright: ignore[reportUninitializedInstanceVariable] - _double(logger), - _double(self.queue), - thread_queue=None, - ) - - @override - def tearDown(self) -> None: - shutil.rmtree(_TMP_DIR, ignore_errors=True) - - def test_success_enqueues_write(self) -> None: - comic = _make_comic() - tags = {"series": "X", "identifiers": {"metron": {"key": "123"}}} - task = OnlineTagByIdTask(comic_pk=comic.pk, source="metron", issue_id=123) - - with patch(_FETCH_TARGET, return_value=tags): - self.manager.tag_by_id(task) - - writes = [i for i in self.queue.items if isinstance(i, BulkTagWriteTask)] - assert len(writes) == 1 - assert writes[0].comic_pks == frozenset({comic.pk}) - assert writes[0].per_comic_patches == {comic.pk: tags} - - def test_not_found_records_error_and_skips_write(self) -> None: - comic = _make_comic() - task = OnlineTagByIdTask(comic_pk=comic.pk, source="metron", issue_id=999) - - with patch(_FETCH_TARGET, return_value=None): - self.manager.tag_by_id(task) - - assert not [i for i in self.queue.items if isinstance(i, BulkTagWriteTask)] - errors = get_tag_write_errors() - assert errors - assert errors[0]["path"] == str(comic.path) - - def test_missing_source_credentials_skips_fetch(self) -> None: - comic = _make_comic() - # Only Metron is configured; a Comic Vine request must not fetch. - task = OnlineTagByIdTask(comic_pk=comic.pk, source="comicvine", issue_id=1) - - with patch(_FETCH_TARGET) as fetch: - self.manager.tag_by_id(task) - - fetch.assert_not_called() - assert not [i for i in self.queue.items if isinstance(i, BulkTagWriteTask)] - - def test_unknown_comic_skips_fetch(self) -> None: - task = OnlineTagByIdTask(comic_pk=999999, source="metron", issue_id=1) - - with patch(_FETCH_TARGET) as fetch: - self.manager.tag_by_id(task) - - fetch.assert_not_called() - assert not self.queue.items - - def _make_admin() -> User: return User.objects.create_user( username="tag_by_id_admin", @@ -281,20 +158,20 @@ def _make_admin() -> User: ) -class TagByIdViewAuthTests(TestCase): - """The tag-by-id endpoint requires admin auth.""" +class TagByIdAuthTests(TestCase): + """The start endpoint requires admin auth, pinned ids or not.""" def test_anonymous_blocked(self) -> None: response = Client().post( - _TAG_BY_ID_URL, - data={"collection": "comics", "pk": "1", "identifier": "metron:1"}, + _START_URL, + data={"collection": "comics", "pks": ["1"], "ids": {"metron": "1"}}, content_type="application/json", ) assert response.status_code == HTTPStatus.FORBIDDEN -class TagByIdViewTests(TestCase): - """Validation paths return 400; a valid request enqueues the task.""" +class TagByIdStartViewTests(TestCase): + """Pinned-id validation returns 400; a valid request enqueues the task.""" @override def setUp(self) -> None: @@ -307,17 +184,25 @@ def setUp(self) -> None: defaults={"metron_key": "t"}, ) - def _post(self, identifier: str, source: str | None = None): - data: dict[str, str] = { - "collection": "comics", - "pk": str(_PK), - "identifier": identifier, - } - if source is not None: - data["source"] = source - return self.client.post( - _TAG_BY_ID_URL, data=data, content_type="application/json" - ) + def _post(self, ids: dict[str, str], sources: list[str] | None = None): + data: dict = {"collection": "comics", "pks": [str(_PK)], "ids": ids} + if sources is not None: + data["sources"] = sources + return self.client.post(_START_URL, data=data, content_type="application/json") + + def _post_resolving_one_comic(self, ids: dict[str, str], **kwargs): + """Post with the comic resolution and queue stubbed; return the task.""" + with ( + patch.object( + AdminOnlineTagStartView, + "resolve_comic_pks", + return_value=frozenset({_PK}), + ), + patch(_VIEW_QUEUE_TARGET) as mocked_queue, + ): + response = self._post(ids, **kwargs) + assert response.status_code == HTTPStatus.ACCEPTED, response.content + return mocked_queue.put.call_args.args[0] def test_no_credentials_returns_400(self) -> None: ComicboxTaggingDefaults.objects.update_or_create( @@ -329,7 +214,7 @@ def test_no_credentials_returns_400(self) -> None: "comicvine_key": "", }, ) - response = self._post("metron:1") + response = self._post({"metron": "1"}) assert response.status_code == HTTPStatus.BAD_REQUEST def test_legacy_login_still_counts_as_configured(self) -> None: @@ -338,69 +223,83 @@ def test_legacy_login_still_counts_as_configured(self) -> None: pk=1, defaults={"metron_key": "", "metron_user": "u", "metron_password": "p"}, ) - with ( - patch.object( - AdminTagByIdView, "resolve_comic_pks", return_value=frozenset({_PK}) - ), - patch(_VIEW_QUEUE_TARGET), - ): - response = self._post(f"metron:{_ISSUE_ID}") - assert response.status_code == HTTPStatus.ACCEPTED + task = self._post_resolving_one_comic({"metron": f"metron:{_ISSUE_ID}"}) + assert task.ids == {"metron": _ISSUE_ID} def test_unparseable_identifier_returns_400(self) -> None: - response = self._post("not-an-id") + response = self._post({"metron": "not-an-id"}) assert response.status_code == HTTPStatus.BAD_REQUEST def test_unconfigured_source_returns_400(self) -> None: # Only Metron is configured; a Comic Vine id has no credentials. - response = self._post("comicvine:4000-1") + response = self._post({"comicvine": "comicvine:4000-1"}) assert response.status_code == HTTPStatus.BAD_REQUEST - def test_success_enqueues_task(self) -> None: - with ( - patch.object( - AdminTagByIdView, "resolve_comic_pks", return_value=frozenset({_PK}) - ), - patch(_VIEW_QUEUE_TARGET) as mocked_queue, + def test_unknown_source_key_returns_400(self) -> None: + response = self._post({"gcd": "123"}) + assert response.status_code == HTTPStatus.BAD_REQUEST + + def test_id_for_another_source_returns_400(self) -> None: + """A Comic Vine id filed under the Metron key is a mistake, not a hint.""" + ComicboxTaggingDefaults.objects.update_or_create( + pk=1, defaults={"metron_key": "t", "comicvine_key": "k"} + ) + response = self._post({"metron": "comicvine:4000-1"}) + assert response.status_code == HTTPStatus.BAD_REQUEST + + def test_id_for_unselected_source_returns_400(self) -> None: + """A pinned source that isn't being run would silently do nothing.""" + ComicboxTaggingDefaults.objects.update_or_create( + pk=1, defaults={"metron_key": "t", "comicvine_key": "k"} + ) + response = self._post({"comicvine": "4000-1"}, sources=["metron"]) + assert response.status_code == HTTPStatus.BAD_REQUEST + + def test_multiple_comics_with_pinned_id_returns_400(self) -> None: + """One issue id can't describe a multi-comic selection.""" + with patch.object( + AdminOnlineTagStartView, + "resolve_comic_pks", + return_value=frozenset({_PK, _PK + 1}), ): - response = self._post(f"metron:{_ISSUE_ID}") - - assert response.status_code == HTTPStatus.ACCEPTED - task = mocked_queue.put.call_args.args[0] - assert isinstance(task, OnlineTagByIdTask) - assert task.comic_pk == _PK - assert task.source == "metron" - assert task.issue_id == _ISSUE_ID - assert task.extra_ids == () - - def test_merge_two_ids_enqueues_extra_ids(self) -> None: - # Merge on + a Metron and a Comic Vine id: the second id rides along as - # an extra to fetch and merge. A single id leaves extra_ids empty. + response = self._post({"metron": f"metron:{_ISSUE_ID}"}) + assert response.status_code == HTTPStatus.BAD_REQUEST + + def test_blank_id_is_ignored(self) -> None: + """An empty token is a cleared input, not a pin: plain search.""" + task = self._post_resolving_one_comic({"metron": " "}) + assert task.ids == {} + + def test_success_enqueues_task_with_pinned_id(self) -> None: + task = self._post_resolving_one_comic({"metron": f"metron:{_ISSUE_ID}"}) + assert isinstance(task, BulkOnlineTagTask) + assert task.comic_pks == frozenset({_PK}) + assert task.ids == {"metron": _ISSUE_ID} + + def test_mixed_pin_and_search(self) -> None: + """Metron pinned, Comic Vine unpinned: one task, one pinned source.""" ComicboxTaggingDefaults.objects.update_or_create( - pk=1, - defaults={ - "metron_key": "t", - "comicvine_key": "k", - "default_sources": ["metron", "comicvine"], - "merge_all_sources": True, + pk=1, defaults={"metron_key": "t", "comicvine_key": "k"} + ) + task = self._post_resolving_one_comic( + {"metron": f"metron:{_ISSUE_ID}"}, sources=["metron", "comicvine"] + ) + assert task.ids == {"metron": _ISSUE_ID} + assert task.sources == ("metron", "comicvine") + + def test_both_sources_pinned(self) -> None: + ComicboxTaggingDefaults.objects.update_or_create( + pk=1, defaults={"metron_key": "t", "comicvine_key": "k"} + ) + task = self._post_resolving_one_comic( + { + "metron": f"metron:{_ISSUE_ID}", + "comicvine": f"4000-{_CV_ISSUE_ID}", }, + sources=["metron", "comicvine"], ) - data = { - "collection": "comics", - "pk": str(_PK), - "identifier": f"metron:{_ISSUE_ID}", - "identifiers": [f"metron:{_ISSUE_ID}", "comicvine:4000-456"], - } - with ( - patch.object( - AdminTagByIdView, "resolve_comic_pks", return_value=frozenset({_PK}) - ), - patch(_VIEW_QUEUE_TARGET) as mocked_queue, - ): - response = self.client.post( - _TAG_BY_ID_URL, data=data, content_type="application/json" - ) + assert task.ids == {"metron": _ISSUE_ID, "comicvine": _CV_ISSUE_ID} - assert response.status_code == HTTPStatus.ACCEPTED - task = mocked_queue.put.call_args.args[0] - assert task.extra_ids == (("comicvine", 456),) + def test_bare_number_resolves_against_the_sole_configured_source(self) -> None: + task = self._post_resolving_one_comic({"metron": str(_ISSUE_ID)}) + assert task.ids == {"metron": _ISSUE_ID} diff --git a/tests/test_tagging_rename_wiring.py b/tests/test_tagging_rename_wiring.py index 7a5f7327c..8c5e1de30 100644 --- a/tests/test_tagging_rename_wiring.py +++ b/tests/test_tagging_rename_wiring.py @@ -2,7 +2,7 @@ Wiring tests for the comicbox rename toggle. Confirms ``rename`` threads through the manual tag-write view (request value or -admin default), the preflight filename preview, the tag-by-id online path, and +admin default), the preflight filename preview, the online tag-by-id path, and deferred-prompt serialization. """ @@ -17,10 +17,8 @@ from django.contrib.auth.models import User from django.core.cache import caches from django.test import Client, SimpleTestCase, TestCase -from loguru import logger from codex.librarian.onlinetag.session_manager import OnlineTagSessionManager -from codex.librarian.onlinetag.tasks import OnlineTagByIdTask from codex.librarian.scribe.tasks import BulkTagWriteTask from codex.models import ( Comic, @@ -31,6 +29,7 @@ Series, Volume, ) +from codex.views.admin.onlinetag import AdminOnlineTagStartView from codex.views.admin.tagwrite import AdminTagWritePreflightView, AdminTagWriteView _TMP_DIR: Final = Path("/tmp/codex.tests.tagrenamewiring") # noqa: S108 @@ -38,29 +37,13 @@ _PK: Final = 7 _TAG_WRITE_URL: Final = "/api/v4/admin/tag-write" _PREFLIGHT_URL: Final = "/api/v4/admin/tag-write/preflight" +_START_URL: Final = "/api/v4/admin/tag-sessions/start" _QUEUE_TARGET: Final = "codex.views.admin.tagwrite.LIBRARIAN_QUEUE" +_START_QUEUE_TARGET: Final = "codex.views.admin.onlinetag.LIBRARIAN_QUEUE" _VIEW_COMICBOX_TARGET: Final = "codex.views.admin.tagwrite.Comicbox" -_FETCH_TARGET: Final = ( - "codex.librarian.onlinetag.session_manager.fetch_tags_by_explicit_id" -) _PREVIEW_NAME: Final = "Series v01 #001.cbz" -def _double(stub: object) -> Any: - """Pass a test double through a concretely-typed seam.""" - return stub - - -class _FakeQueue: - """Collects everything put on it for assertions.""" - - def __init__(self) -> None: - self.items: list = [] - - def put(self, item) -> None: - self.items.append(item) - - class _PreviewComicbox: """Minimal Comicbox stand-in for the preflight filename preview.""" @@ -265,7 +248,7 @@ def test_preview_lists_every_comic(self) -> None: class TagByIdRenamePropagationTests(TestCase): - """rename on the task flows into the enqueued BulkTagWriteTask.""" + """rename survives alongside a pinned id on the start request.""" @override def setUp(self) -> None: @@ -274,26 +257,32 @@ def setUp(self) -> None: ComicboxTaggingDefaults.objects.update_or_create( pk=1, defaults={"metron_key": "t"} ) - self.queue = _FakeQueue() # pyright: ignore[reportUninitializedInstanceVariable] - self.manager = OnlineTagSessionManager( # pyright: ignore[reportUninitializedInstanceVariable] - _double(logger), _double(self.queue), thread_queue=None - ) + self.client = Client() + self.client.force_login(_make_admin()) - @override - def tearDown(self) -> None: - shutil.rmtree(_TMP_DIR, ignore_errors=True) + def test_rename_propagates_with_a_pinned_id(self) -> None: + data = { + "collection": "comics", + "pks": [str(_PK)], + "ids": {"metron": "metron:123"}, + "rename": True, + } + with ( + patch.object( + AdminOnlineTagStartView, + "resolve_comic_pks", + return_value=frozenset({_PK}), + ), + patch(_START_QUEUE_TARGET) as mocked_queue, + ): + response = self.client.post( + _START_URL, data=data, content_type="application/json" + ) - def test_rename_propagates_to_write(self) -> None: - comic = _make_comic() - tags = {"series": "X", "identifiers": {"metron": {"key": "123"}}} - task = OnlineTagByIdTask( - comic_pk=comic.pk, source="metron", issue_id=123, rename=True - ) - with patch(_FETCH_TARGET, return_value=tags): - self.manager.tag_by_id(task) - writes = [i for i in self.queue.items if isinstance(i, BulkTagWriteTask)] - assert len(writes) == 1 - assert writes[0].rename is True + assert response.status_code == HTTPStatus.ACCEPTED, response.content + task = mocked_queue.put.call_args.args[0] + assert task.rename is True + assert task.ids == {"metron": 123} class _DeferredPrompt: From 97c65d6d8069b72deb7329e4913720de6094b6f0 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sat, 1 Aug 2026 17:58:53 -0700 Subject: [PATCH 65/66] update deps --- bun.lock | 4 ++-- frontend/bun.lock | 6 +++--- pyproject.toml | 2 +- uv.lock | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index 7dfdda8e1..d2c8ed3e0 100644 --- a/bun.lock +++ b/bun.lock @@ -35,8 +35,8 @@ "eslint-plugin-unicorn": "^72.0.0", "eslint-plugin-vue": "^10.10.0", "eslint-plugin-vue-scoped-css": "^3.1.1", - "eslint-plugin-yml": "^3.6.0", - "eslint_d": "^15.0.2", + "eslint-plugin-yml": "^3.7.0", + "eslint_d": "^15.0.3", "postcss-scss": "^4.0.9", "prettier": "^3.9.6", "prettier-plugin-nginx": "^1.0.3", diff --git a/frontend/bun.lock b/frontend/bun.lock index 4cef888cd..b62780c64 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -15,7 +15,7 @@ "vue": "^3.5.40", "vue-pdf-embed": "^2.1.5", "vue-router": "^5.2.0", - "vuetify": "^4.1.6", + "vuetify": "^4.1.7", "xior": "^0.8.3", }, "devDependencies": { @@ -26,14 +26,14 @@ "@unhead/bundler": "^3.2.3", "@vitejs/plugin-vue": "^6.0.8", "@vue/test-utils": "^2.4.11", - "@vue/typescript-plugin": "^3.3.8", + "@vue/typescript-plugin": "^3.3.9", "happy-dom": "^20.11.1", "rollup-plugin-visualizer": "^7.0.1", "sass": "^1.102.0", "sass-loader": "^17.0.0", "toml": "^5.0.0", "typeface-roboto": "^1.1.13", - "vite": "^8.1.5", + "vite": "^8.2.0", "vite-plugin-checker": "^0.14.5", "vite-plugin-dynamic-base": "^1.4.1", "vite-plugin-run": "^0.9.1", diff --git a/pyproject.toml b/pyproject.toml index bb3bb57d9..f3306cce5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "adrf~=0.1.12", "bidict~=0.23", "channels~=4.2", - "comicbox[pdf]~=4.7.1", + "comicbox[pdf]~=4.8.0", "cryptography>=48.0.0", "dateparser~=1.2", "django-allauth[socialaccount]~=65.13", diff --git a/uv.lock b/uv.lock index 0b3376522..6c0f3de57 100644 --- a/uv.lock +++ b/uv.lock @@ -653,7 +653,7 @@ requires-dist = [ { name = "adrf", specifier = "~=0.1.12" }, { name = "bidict", specifier = "~=0.23" }, { name = "channels", specifier = "~=4.2" }, - { name = "comicbox", extras = ["pdf"], specifier = "~=4.7.1" }, + { name = "comicbox", extras = ["pdf"], specifier = "~=4.8.0" }, { name = "cryptography", specifier = ">=48.0.0" }, { name = "dateparser", specifier = "~=1.2" }, { name = "django", specifier = "~=6.0" }, @@ -739,7 +739,7 @@ wheels = [ [[package]] name = "comicbox" -version = "4.7.1" +version = "4.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bidict" }, @@ -776,9 +776,9 @@ dependencies = [ { name = "xmltodict" }, { name = "zipremove" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/15/b1dcfc4aa93829f13bd71fc440e8432fea7eb01fc6fc02001faa1dea5d8f/comicbox-4.7.1.tar.gz", hash = "sha256:eda90b247d1961b32ece8b878a9ce1204a39d609d56b21eac337d5e7d727b45a", size = 1018345, upload-time = "2026-07-29T05:33:53.615Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/2b/38f5122b1aa64f72b36d851335b5ac532ea74f8c44b7d1c094d796f3177a/comicbox-4.8.0.tar.gz", hash = "sha256:28acf608904f2353e7c48d422d29d1f62f493ef9b36e40bf983577348cebb75a", size = 1019177, upload-time = "2026-08-02T00:57:00.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/1a/579af5c6853adcd48e177717dd7ff24b273cf34e45d9e8269e88cb5fcd4e/comicbox-4.7.1-py3-none-any.whl", hash = "sha256:cac0906bcc0a8f393a09db5b1590091c120dde2ed349856965a7c87816fdb8e6", size = 343779, upload-time = "2026-07-29T05:33:52.472Z" }, + { url = "https://files.pythonhosted.org/packages/38/3c/e36e02402579645f818d12602a5d0c3336fd05cff773ca3c6c994c1885bc/comicbox-4.8.0-py3-none-any.whl", hash = "sha256:7f96ee493d9fb416846a55eb8a2bec61e6ec1460625821b6bf83cdbeac623798", size = 344052, upload-time = "2026-08-02T00:56:59.356Z" }, ] [package.optional-dependencies] From f54e70b6afc2d1f7dc5ef26e000e29836bdecdf9 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sat, 1 Aug 2026 18:03:48 -0700 Subject: [PATCH 66/66] style(news): prettier wrap the online tagging entry Co-Authored-By: Claude Fable 5 --- NEWS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index a8537c4d6..c626de807 100644 --- a/NEWS.md +++ b/NEWS.md @@ -10,8 +10,8 @@ border-radius: 128px; - Features - Online tagging combines search and tag by id. A source given an issue id - fetches it directly, the rest search, and the results merge - (comicbox 4.8.0). + fetches it directly, the rest search, and the results merge (comicbox + 4.8.0). - Fixes - Tagging a comic with renaming enabled no longer fails the import and