diff --git a/NEWS.md b/NEWS.md index e89b6ba48..c626de807 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,20 @@ width: 128px; border-radius: 128px; " /> +## v2.2.5 + +- 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). + +- Fixes + - Tagging a comic with renaming enabled no longer fails the import and + discards the new tags. Comics renamed this way since v2.1.0 need a Force + Update Tags to pick up their metadata. + - Comics renamed or moved by another program mid-scan no longer leave a + permanent failed import. + ## v2.2.4 - Features diff --git a/bun.lock b/bun.lock index 5ef28b452..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", @@ -426,7 +426,7 @@ "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.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-plugin-yml": ["eslint-plugin-yml@3.7.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-cLkVHdBHyRifThJA3ufuEW+imPRO7rHBQXdWgthouY6qEapUw8/0zW6V8NcMEGWzuMykc/ITYl6AuhZLPzI+fw=="], "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=="], @@ -434,7 +434,7 @@ "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - "eslint_d": ["eslint_d@15.0.2", "", { "dependencies": { "debug": "^4.4.3", "eslint": "^10.0.3", "supports-color": "^10.2.2" }, "bin": { "eslint_d": "bin/eslint_d.js" } }, "sha512-08qxnTNAHi6gaOJdixu/5gliLFRQzTAeX/aXxcFK78FPK9RQuCNnvwq2tDzlSmXc/dkK/Q2n3P4nvPhvTQJa2Q=="], + "eslint_d": ["eslint_d@15.0.3", "", { "dependencies": { "debug": "^4.4.3", "eslint": "^10.0.3", "supports-color": "^10.2.2" }, "bin": { "eslint_d": "bin/eslint_d.js" } }, "sha512-bCS3HTNhIZLTaOXmJ7Xp63itEfwGWjmHfZh5CtvCZ2Re4UVOSwSZTdvOiqMQ7ceXDGJDgty3HlL3fCfFH/Rl6g=="], "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], @@ -1170,8 +1170,6 @@ "eslint-plugin-yml/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "eslint_d/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=="], - "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -1244,14 +1242,6 @@ "eslint-mdx/espree/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "eslint_d/eslint/@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], - - "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.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=="], diff --git a/codex/librarian/fs/import_task.py b/codex/librarian/fs/import_task.py index 5470fb4e9..40f4b621c 100644 --- a/codex/librarian/fs/import_task.py +++ b/codex/librarian/fs/import_task.py @@ -65,7 +65,18 @@ def _deduplicate(kwargs: dict[str, Any]) -> None: kwargs["files_modified"] -= kwargs["files_added"] kwargs["files_modified"] -= kwargs["files_deleted"] - kwargs["files_modified"] -= files_dest_paths + # A modified path that takes part in a move refers to content that now + # lives at the destination. The watcher emits the source path (an + # external tagger writing tags and then renaming produces the modify + # before the rename), while the poller emits the destination path on + # purpose for a move whose stats also changed. Remap sources and keep + # destinations so both reach the read phase alive. A pure rename's + # destination costs only a stat() there, because a move preserves the + # stored stat (see MOVED_BULK_COMIC_UPDATE_FIELDS). + files_moved = kwargs["files_moved"] + kwargs["files_modified"] = { + files_moved.get(path, path) for path in kwargs["files_modified"] + } kwargs["covers_modified"] -= kwargs["covers_deleted"] kwargs["covers_modified"] -= kwargs["covers_added"] diff --git a/codex/librarian/onlinetag/explicit_id.py b/codex/librarian/onlinetag/explicit_id.py index 0d3d30f87..22f9f13bd 100644 --- a/codex/librarian/onlinetag/explicit_id.py +++ b/codex/librarian/onlinetag/explicit_id.py @@ -2,12 +2,18 @@ Fetch comic metadata by a known online issue id (comicbox explicit-id tagging). Codex's online tagging normally *searches* a source by filename and ranks -candidates. When the operator already knows the exact Metron / Comic Vine issue -id, this module drives comicbox's explicit-id path instead -(``online.lookup.ids``), which skips the search entirely and calls -``source.get(issue_id)`` for that one issue. It mirrors the per-file flow of -``comicbox.online_session.OnlineSession._run_one`` minus the selector — an -explicit-id fetch never reaches the matcher, so it never prompts. +candidates. When a comic's issue id is already known, this module drives +comicbox's explicit-id path instead (``online.lookup.ids``), which skips the +search entirely and calls ``source.get(issue_id)`` for that one issue. It +mirrors the per-file flow of ``comicbox.online_session.OnlineSession._run_one`` +minus the selector — an explicit-id fetch never reaches the matcher, so it +never prompts. + +Two callers, both outside a running session: the stored-id prepass (comics +Codex already holds an identifier for) and prompt resolution (an admin pinned +the issue by hand). Ids pinned in the tagging request itself go through the +session — ``OnlineSession(ids=...)`` — so pinned sources and searched sources +resolve in one lookup and merge. """ from __future__ import annotations diff --git a/codex/librarian/onlinetag/onlinetagd.py b/codex/librarian/onlinetag/onlinetagd.py index db681cb0b..291fbdff8 100644 --- a/codex/librarian/onlinetag/onlinetagd.py +++ b/codex/librarian/onlinetag/onlinetagd.py @@ -8,7 +8,6 @@ from codex.librarian.onlinetag.tasks import ( BulkOnlineTagTask, OnlineTagAbortTask, - OnlineTagByIdTask, OnlineTagDismissTask, OnlineTagPromptResponseTask, OnlineTagSkipAllPromptsTask, @@ -64,8 +63,6 @@ def process_item(self, item) -> None: match item: case BulkOnlineTagTask(): self.session_manager.run_session(item) - case OnlineTagByIdTask(): - self.session_manager.tag_by_id(item) case OnlineTagAbortTask(): self.session_manager.cancel_session(item.session_id) case OnlineTagPromptResponseTask(): diff --git a/codex/librarian/onlinetag/session_manager.py b/codex/librarian/onlinetag/session_manager.py index 7830a4bb5..6ab3c64ef 100644 --- a/codex/librarian/onlinetag/session_manager.py +++ b/codex/librarian/onlinetag/session_manager.py @@ -58,7 +58,6 @@ from codex.librarian.onlinetag.tasks import ( BulkOnlineTagTask, OnlineTagAbortTask, - OnlineTagByIdTask, OnlineTagPromptResponseTask, OnlineTagSkipAllPromptsTask, ) @@ -130,71 +129,6 @@ def _source_has_credentials(credentials: OnlineCredentials, source: str) -> bool return bool(credentials.comicvine_key) return False - # --- tag by explicit id (no search) -------------------------------- - - def tag_by_id(self, task: OnlineTagByIdTask) -> None: - """ - Tag one comic by a known online issue id, skipping search entirely. - - The operator already knows the exact Metron / Comic Vine issue, so we - fetch that record directly and hand the tags to the same - ``BulkTagWriteTask`` write + re-import path the scan uses. A wrong or - unknown id resolves to nothing; that surfaces in the admin Tagging-tab - error panel rather than silently re-writing the comic's existing tags. - """ - comic = ( - Comic.objects.filter(pk=task.comic_pk) - .exclude(library__read_only=True) - .only("pk", "path") - .first() - ) - if not comic: - self.log.warning(f"Online tag by id: comic {task.comic_pk} not found.") - return - path = Path(comic.path) - credentials = self._build_credentials() - if not credentials or not self._source_has_credentials( - credentials, task.source - ): - self.log.warning( - f"Online tag by id: no {task.source} credentials configured." - ) - return - - try: - tags = fetch_tags_by_explicit_id( - path, - task.source, - task.issue_id, - credentials, - extra_ids=task.extra_ids, - ) - 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) - self.librarian_queue.put(TAG_WRITE_ERRORS_CHANGED_TASK) - return - if not tags: - msg = f"No {task.source} issue found for id {task.issue_id}." - self.log.warning(f"Online tag by id: {msg} ({path})") - add_tag_write_error(str(path), msg) - self.librarian_queue.put(TAG_WRITE_ERRORS_CHANGED_TASK) - return - - write_task = BulkTagWriteTask( - comic_pks=frozenset({comic.pk}), - per_comic_patches={comic.pk: tags}, - mode="update", - formats=task.formats, - delete_original=task.delete_original, - rename=task.rename, - ) - self.librarian_queue.put(write_task) - self.log.info( - f"Online tag by id: applied {task.source}:{task.issue_id} to {path}." - ) - # --- events -------------------------------------------------------- def _active_state(self) -> SessionState | None: @@ -527,10 +461,8 @@ def _prefetch_stored_ids( Fetches each already-identified comic in one API call, writes the tags through the same ``BulkTagWriteTask`` path the scan uses, and drops it from ``comic_paths`` so the search session only handles the rest. - Skipped on dry runs and for sources without credentials. + Skipped for sources without credentials. """ - if task.dry_run: - return usable_sources = tuple( source for source in task.sources @@ -586,6 +518,8 @@ def run_session(self, task: BulkOnlineTagTask) -> None: ) session = OnlineSession( sources=task.sources, + # Pinned sources fetch their issue id directly; the rest search. + ids=dict(task.ids), credentials=credentials, mode=MatchMode(task.mode), defer_prompts=defer_prompts, @@ -615,7 +549,7 @@ def run_session(self, task: BulkOnlineTagTask) -> None: "delete_original": task.delete_original, "merge_all_sources": task.merge_all_sources, "rename": task.rename, - "dry_run": task.dry_run, + "ids": dict(task.ids), }, ) self._active_session_id = task.session_id @@ -633,7 +567,12 @@ def run_session(self, task: BulkOnlineTagTask) -> None: # Fast path: comics codex already has an issue id for are fetched # directly by that id (one API call each) and dropped from the set, # so the search pass below only handles the unidentified remainder. - self._prefetch_stored_ids(state, comic_paths, task, credentials) + # Skipped when the request pinned ids: the prepass would drop that + # single comic out of the session entirely, but the pinned sources + # must be fetched *and* the unpinned ones searched in one lookup so + # merge_all_sources can merge both results. + if not task.ids: + self._prefetch_stored_ids(state, comic_paths, task, credentials) # Pass 1: auto-match and write the confident comics. When deferring, # ambiguous matches become deferred prompts persisted for later, # independent resolution; with "never" prompts they're skipped inline diff --git a/codex/librarian/onlinetag/tasks.py b/codex/librarian/onlinetag/tasks.py index 4a4cab4b4..6213dcb7f 100644 --- a/codex/librarian/onlinetag/tasks.py +++ b/codex/librarian/onlinetag/tasks.py @@ -1,6 +1,6 @@ """Online tagging tasks.""" -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any from comicbox.formats.base.online import SOURCE_NAMES @@ -22,12 +22,15 @@ class BulkOnlineTagTask(OnlineTagTask): mode: str = "auto" prompts_mode: str = "ask" delete_original: bool = False - dry_run: bool = False # Query every source per comic and merge (comicbox first_wins=False) # instead of stopping at the first match. merge_all_sources: bool = False # Rename each written archive to the comicbox filename scheme. rename: bool = False + # Pinned issue ids by source for a single-comic session. A pinned source + # fetches that issue directly; the rest of ``sources`` search as usual, so + # one session mixes tag-by-id and search and merges both results. + ids: dict[str, int] = field(default_factory=dict) @dataclass @@ -37,22 +40,6 @@ class OnlineTagAbortTask(OnlineTagTask): session_id: str = "" -@dataclass -class OnlineTagByIdTask(OnlineTagTask): - """Tag one comic by a known online issue id (skips search).""" - - comic_pk: int - source: str - issue_id: int - formats: tuple[str, ...] = ("COMIC_INFO",) - delete_original: bool = False - rename: bool = False - # Additional (source, issue_id) pairs to fetch by explicit id and merge - # onto the primary fetch — the Tag-by-id "merge all sources" path. Empty - # means a single-source fetch. - extra_ids: tuple[tuple[str, int], ...] = () - - @dataclass class OnlineTagPromptResponseTask(OnlineTagTask): """Admin response to a deferred online tagging prompt (keyed by fingerprint).""" diff --git a/codex/librarian/scribe/importer/const.py b/codex/librarian/scribe/importer/const.py index a5fa4c7cc..56497aaec 100644 --- a/codex/librarian/scribe/importer/const.py +++ b/codex/librarian/scribe/importer/const.py @@ -482,7 +482,16 @@ def get_through_model(field: ManyToManyField) -> type[BaseModel]: ######### # MOVED # ######### -MOVED_BULK_COMIC_UPDATE_FIELDS = ("path", "parent_folder", "stat", "updated_at") +# ``stat`` is deliberately absent: the stored stat means "the state of the +# file the last time its tags were imported", not "the last time codex saw +# the file". A pure rename changes neither inode, mtime, nor size, so the +# preserved stat still matches disk exactly and inode move-detection keeps +# working. When content changed too (an external tagger writes tags and +# then renames), the preserved stat is the only evidence the read phase's +# filesystem prefilter has that the archive needs re-reading. update_comics +# writes a fresh stat once the new tags actually import +# (ALWAYS_UPDATE_COMIC_FIELDS). +MOVED_BULK_COMIC_UPDATE_FIELDS = ("path", "parent_folder", "updated_at") CUSTOM_COVER_UPDATE_FIELDS = ("path", "stat", "updated_at", "sort_name", "collection") ########### diff --git a/codex/librarian/scribe/importer/failed/create.py b/codex/librarian/scribe/importer/failed/create.py index 3474946be..97082b34a 100644 --- a/codex/librarian/scribe/importer/failed/create.py +++ b/codex/librarian/scribe/importer/failed/create.py @@ -72,8 +72,12 @@ def _bulk_create_failed_imports( library=self.library, path=path, parent_folder=None ) fi.set_reason(exc) - create_objs.append(fi) + # presave() stats the path, so it must succeed before + # the row is queued. A file that vanished mid-import + # (renamed out from under us) raises here and gets no + # failed import row. fi.presave() + create_objs.append(fi) except OSError: self.log.warning( f"Error preparing failed import create for {path}: {exc}" diff --git a/codex/librarian/scribe/importer/read/extract.py b/codex/librarian/scribe/importer/read/extract.py index 204860f19..be27e95ce 100644 --- a/codex/librarian/scribe/importer/read/extract.py +++ b/codex/librarian/scribe/importer/read/extract.py @@ -194,7 +194,9 @@ def _extract_post_process_comic( result, exc = value path_str = str(path) if exc: - self.metadata[FIS].update({path: exc}) + # str keys, not Path: the failed import phase matches these + # against path strings queried from the db. + self.metadata[FIS].update({path_str: exc}) self.metadata[SKIPPED].add(path_str) status.increment_complete() self.status_controller.update(status) diff --git a/codex/librarian/scribe/tag_writer.py b/codex/librarian/scribe/tag_writer.py index 46f24ca0e..f3541ed85 100644 --- a/codex/librarian/scribe/tag_writer.py +++ b/codex/librarian/scribe/tag_writer.py @@ -296,8 +296,10 @@ def _enqueue_rename_imports( Sync the DB for renamed comics, watcher-aware. Watched libraries: enqueue nothing — the watcher's inode move-detection - emits the ``files_moved`` import and the tag-write content-modify - refreshes metadata, so a self-enqueued move would only duplicate it. + emits the ``files_moved`` import, and ``build_import_task`` remaps the + tag-write's modify event from the pre-rename path onto the move + destination, so the new tags re-read from the same batch. A + self-enqueued move would only duplicate it. Unwatched libraries: enqueue one targeted move import that updates the path and, when tags were written, re-reads the new file's metadata (``move_and_modify_dirs`` runs before the per-comic ``read`` phase). diff --git a/codex/serializers/admin/tagging.py b/codex/serializers/admin/tagging.py index 22da2edbb..9890d5183 100644 --- a/codex/serializers/admin/tagging.py +++ b/codex/serializers/admin/tagging.py @@ -63,31 +63,31 @@ 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") - dry_run = CharField(required=False, default="false") delete_original = BooleanField(required=False, default=None) # None falls back to the admin ComicboxTaggingDefaults default. merge_all_sources = BooleanField(required=False, default=None) rename = BooleanField(required=False, default=None) + # Pinned issue ids by source, as entered: an issue URL, ``source:id``, + # ``4000-id``, or a bare number. A pinned source is fetched by id instead + # of searched; single-comic sessions only. + ids = DictField(child=CharField(allow_blank=True), required=False, default=dict) @staticmethod def validate_sources(value: list) -> list: """Require known source names; preserve the priority order.""" return _validate_ordered_sources(value) - -class TagByIdRequestSerializer(Serializer): - """Serializer for tagging one comic by a known online issue id.""" - - collection = CharField() - pk = CharField() - identifier = CharField() - source = CharField(required=False, allow_blank=True, default="") - # All identifiers entered (one per source) when merging by id; the primary - # ``identifier`` is the first. Ignored unless merging. - identifiers = ListField(child=CharField(), required=False, default=list) - # None falls back to the admin ComicboxTaggingDefaults default. - merge_all_sources = BooleanField(required=False, default=None) - rename = BooleanField(required=False, default=None) + @staticmethod + def validate_ids(value: dict) -> dict: + """Require known source names as the pinned-id keys.""" + unknown = sorted(set(value) - KNOWN_SOURCES) + if unknown: + msg = ( + f"Unknown online tagging source(s): {', '.join(unknown)}. " + f"Known: {', '.join(sorted(KNOWN_SOURCES))}." + ) + raise ValidationError(msg) + return value class OnlineTagPromptResponseSerializer(Serializer): diff --git a/codex/urls/api/v4/admin.py b/codex/urls/api/v4/admin.py index cb095e058..508836abc 100644 --- a/codex/urls/api/v4/admin.py +++ b/codex/urls/api/v4/admin.py @@ -46,7 +46,6 @@ from codex.views.admin.tagging_validate import AdminTaggingValidateView from codex.views.admin.tagwrite import ( AdminParseIdentifierURLView, - AdminTagByIdView, AdminTagWritePreflightView, AdminTagWriteView, ) @@ -275,11 +274,6 @@ AdminParseIdentifierURLView.as_view(), name="identifier_url", ), - path( - "tag-by-id", - AdminTagByIdView.as_view(), - name="tag_by_id", - ), path( "tag-write/preflight", AdminTagWritePreflightView.as_view(), diff --git a/codex/views/admin/onlinetag.py b/codex/views/admin/onlinetag.py index ad2c84fe3..8436c237e 100644 --- a/codex/views/admin/onlinetag.py +++ b/codex/views/admin/onlinetag.py @@ -31,9 +31,22 @@ OnlineTagStartSerializer, ) from codex.views.admin.auth import AdminAPIView +from codex.views.admin.identifier_parse import parse_identifier_input from codex.views.admin.tagwrite import FilteredComicPksView +def _configured_sources(defaults: ComicboxTaggingDefaults | None) -> frozenset[str]: + """Which online sources actually have credentials configured.""" + if not defaults: + return frozenset() + sources = set() + if defaults.metron_key or (defaults.metron_user and defaults.metron_password): + sources.add("metron") + if defaults.comicvine_key: + sources.add("comicvine") + return frozenset(sources) + + class AdminOnlineTagActiveView(AdminAPIView): """Return the in-flight online tagging scan id, if any.""" @@ -60,22 +73,80 @@ def get(self, _request): class AdminOnlineTagStartView(FilteredComicPksView): """Start an online tagging scan.""" + @staticmethod + def _parse_pinned_ids( + raw_ids: dict, sources: list, configured: frozenset[str] + ) -> dict[str, int]: + """ + Parse the entered per-source identifiers into ``{source: issue_id}``. + + A pinned source is fetched by that issue id instead of searched, so a + session can mix id retrieval and search. Raises ``ValueError`` with a + user-facing message. + """ + entered = { + source: raw for source, raw in raw_ids.items() if (raw or "").strip() + } + if not entered: + return {} + if not configured: + msg = "No online source credentials configured." + raise ValueError(msg) + if unselected := sorted(set(entered) - set(sources)): + msg = f"Pinned id for unselected source(s): {', '.join(unselected)}." + raise ValueError(msg) + ids: dict[str, int] = {} + for source, raw in entered.items(): + if source not in configured: + msg = f"No {source} credentials configured." + raise ValueError(msg) + parsed_source, issue_id = parse_identifier_input( + raw, source_hint=source, configured_sources=configured + ) + if parsed_source != source: + msg = ( + f"That id is a {parsed_source} id, but it was entered for {source}." + ) + raise ValueError(msg) + ids[source] = issue_id + return ids + + def _comic_pks_error(self, comic_pks: frozenset[int], *, pinned: bool) -> str: + """Return a user-facing message when the resolved comics are unusable.""" + if not comic_pks: + if pinned and self.skipped_read_only: + return "Comic is in a read-only library." + return "No comics matched." + # comicbox pins an id per source for one comic; a multi-comic selection + # has no meaningful single issue to pin. + if pinned and len(comic_pks) > 1: + return "Tagging by id requires a single comic." + return "" + def post(self, request): """Validate and enqueue a BulkOnlineTagTask.""" serializer = OnlineTagStartSerializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.validated_data - comic_pks = self.resolve_comic_pks(data["collection"], data["pks"]) - if not comic_pks: - return Response({"detail": "No comics matched."}, status=400) - - session_id = str(uuid.uuid4()) try: defaults = ComicboxTaggingDefaults.objects.get(pk=1) except ComicboxTaggingDefaults.DoesNotExist: defaults = None + try: + ids = self._parse_pinned_ids( + data.get("ids") or {}, data["sources"], _configured_sources(defaults) + ) + except ValueError as exc: + return Response({"detail": str(exc)}, status=400) + + comic_pks = self.resolve_comic_pks(data["collection"], data["pks"]) + if detail := self._comic_pks_error(comic_pks, pinned=bool(ids)): + return Response({"detail": detail}, status=400) + + session_id = str(uuid.uuid4()) + req_delete = data.get("delete_original") if req_delete is not None: delete_original = req_delete @@ -103,6 +174,7 @@ def post(self, request): delete_original=delete_original, merge_all_sources=merge_all_sources, rename=rename, + ids=ids, ) LIBRARIAN_QUEUE.put(task) return Response( @@ -133,14 +205,19 @@ 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 - ``auto_threshold`` knob) — drop unknown keys so resuming across an upgrade - rebuilds the task instead of raising ``TypeError``. + ``sources`` round-trips through JSON as a list; the task wants a tuple, and + the pinned ``ids`` want int values. 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 ``auto_threshold`` and ``dry_run`` + knobs) — 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 ()) + params["ids"] = { + str(source): int(issue_id) + for source, issue_id in (params.get("ids") or {}).items() + } valid_fields = {f.name for f in fields(BulkOnlineTagTask)} return {k: v for k, v in params.items() if k in valid_fields} diff --git a/codex/views/admin/tagwrite.py b/codex/views/admin/tagwrite.py index 3b7c84a3a..9c0aa85ad 100644 --- a/codex/views/admin/tagwrite.py +++ b/codex/views/admin/tagwrite.py @@ -14,20 +14,13 @@ from rest_framework.status import HTTP_202_ACCEPTED from codex.librarian.mp_queue import LIBRARIAN_QUEUE -from codex.librarian.onlinetag.tasks import OnlineTagByIdTask from codex.librarian.scribe.tasks import BulkTagWriteTask from codex.models.admin import ComicboxTaggingDefaults from codex.models.comic import Comic -from codex.serializers.admin.tagging import ( - TagByIdRequestSerializer, - TagWriteRequestSerializer, -) +from codex.serializers.admin.tagging import TagWriteRequestSerializer from codex.settings import COMICBOX_CONFIG from codex.views.admin.auth import AdminAPIView -from codex.views.admin.identifier_parse import ( - parse_identifier_input, - parse_identifier_url, -) +from codex.views.admin.identifier_parse import parse_identifier_url from codex.views.browser.filters.filter import BrowserFilterView #: Cap on how many per-comic rename previews the preflight builds — each opens @@ -107,136 +100,6 @@ def post(self, request): return Response({"source": source, "id_type": id_type, "key": id_key}) -class AdminTagByIdView(FilteredComicPksView): - """Tag a single comic by a known Metron / Comic Vine issue id (no search).""" - - @staticmethod - def _get_defaults() -> ComicboxTaggingDefaults | None: - """Load the tagging-defaults singleton, or None when unset.""" - try: - return ComicboxTaggingDefaults.objects.get(pk=1) - except ComicboxTaggingDefaults.DoesNotExist: - return None - - @staticmethod - def _configured_sources(defaults: ComicboxTaggingDefaults | None) -> frozenset[str]: - """Which online sources actually have credentials configured.""" - if not defaults: - return frozenset() - sources = set() - if defaults.metron_key or (defaults.metron_user and defaults.metron_password): - sources.add("metron") - if defaults.comicvine_key: - sources.add("comicvine") - return frozenset(sources) - - @staticmethod - def _write_defaults( - defaults: ComicboxTaggingDefaults | None, - ) -> tuple[tuple[str, ...], bool]: - """Resolve the write formats and delete-original flag from defaults.""" - if not defaults: - return ("COMIC_INFO",), False - formats = tuple(defaults.default_formats) or ("COMIC_INFO",) - return formats, bool(defaults.delete_original) - - @staticmethod - def _parse_extra_ids( - identifiers: list[str], primary_source: str, configured: frozenset[str] - ) -> tuple[tuple[str, int], ...]: - """ - Parse the entered identifiers into extra ``(source, id)`` pairs to merge. - - Keeps one id per source (the primary source excluded — it's already - pinned), so a Metron + Comic Vine pair yields the Comic Vine entry. - Raises ValueError on an unparseable or unconfigured-source identifier. - """ - seen = {primary_source} - extras: list[tuple[str, int]] = [] - for raw in identifiers: - if not (raw or "").strip(): - continue - src, issue_id = parse_identifier_input(raw, configured_sources=configured) - if src in seen or src not in configured: - continue - seen.add(src) - extras.append((src, issue_id)) - return tuple(extras) - - @staticmethod - def _resolve_primary(data, configured: frozenset[str]) -> tuple[str, int]: - """Parse the primary identifier and confirm its source is configured.""" - source, issue_id = parse_identifier_input( - data["identifier"], - source_hint=data.get("source") or None, - configured_sources=configured, - ) - if source not in configured: - msg = f"No {source} credentials configured." - raise ValueError(msg) - return source, issue_id - - def _resolve_extra_ids( - self, - data, - defaults: ComicboxTaggingDefaults | None, - source: str, - configured: frozenset[str], - ) -> tuple[tuple[str, int], ...]: - """Resolve the merge default and parse the extra ids to merge, if any.""" - req_merge = data.get("merge_all_sources") - if req_merge is None: - req_merge = bool(defaults and defaults.merge_all_sources) - if not req_merge: - return () - return self._parse_extra_ids(data.get("identifiers") or [], source, configured) - - def post(self, request): - """Parse the identifier, resolve the comic, and enqueue an id fetch.""" - serializer = TagByIdRequestSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - data = serializer.validated_data - - defaults = self._get_defaults() - configured = self._configured_sources(defaults) - if not configured: - return Response( - {"detail": "No online source credentials configured."}, status=400 - ) - try: - source, issue_id = self._resolve_primary(data, configured) - extra_ids = self._resolve_extra_ids(data, defaults, source, configured) - except ValueError as exc: - return Response({"detail": str(exc)}, status=400) - - comic_pks = self.resolve_comic_pks(data["collection"], [data["pk"]]) - if len(comic_pks) != 1: - if self.skipped_read_only: - detail = "Comic is in a read-only library." - else: - detail = "No comic matched." - return Response({"detail": detail}, status=400) - (comic_pk,) = tuple(comic_pks) - - formats, delete_original = self._write_defaults(defaults) - req_rename = data.get("rename") - if req_rename is not None: - rename = req_rename - else: - rename = bool(defaults and defaults.rename_files) - task = OnlineTagByIdTask( - comic_pk=comic_pk, - source=source, - issue_id=issue_id, - formats=formats, - delete_original=delete_original, - rename=rename, - extra_ids=extra_ids, - ) - LIBRARIAN_QUEUE.put(task) - return Response({"source": source, "id": issue_id}, status=HTTP_202_ACCEPTED) - - class AdminTagWritePreflightView(FilteredComicPksView): """Check how many comics need conversion before writing.""" diff --git a/frontend/bun.lock b/frontend/bun.lock index ec6ccf1ee..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", @@ -382,7 +382,7 @@ "@vue/devtools-shared": ["@vue/devtools-shared@8.1.5", "", {}, "sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ=="], - "@vue/language-core": ["@vue/language-core@3.3.8", "", { "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-ieGT8jJdhhy0mGzStZhsg/qPw5bQZJg5yF+3+XU6saf4sM7yo9ZXy3h+nCwrm2+b4qS/SypkNdR2jAF3uei9tA=="], + "@vue/language-core": ["@vue/language-core@3.3.9", "", { "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-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ=="], "@vue/reactivity": ["@vue/reactivity@3.5.40", "", { "dependencies": { "@vue/shared": "3.5.40" } }, "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA=="], @@ -396,7 +396,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.8", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.8", "@vue/shared": "^3.5.0", "path-browserify": "^1.0.1", "vue-component-meta": "3.3.8" } }, "sha512-0GOhAdbMAt5KjGDsPXgcXkLWYBFwoEowPn1AYOUAYuxNup7CVbFJzup8yFGa9KZBtz/juk4FXS/Zirx3WWjLJw=="], + "@vue/typescript-plugin": ["@vue/typescript-plugin@3.3.9", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.9", "@vue/shared": "^3.5.0", "path-browserify": "^1.0.1", "vue-component-meta": "3.3.9" } }, "sha512-lU5YLNiuaClZX1U9XwRaGZMeIFFvCin8SrESm5/azzfw/RvYvYj42sspy5/+XfSIZHHPn8Kdc2nm5qZ9C4OFfw=="], "@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=="], @@ -666,7 +666,7 @@ "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], - "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -724,7 +724,7 @@ "pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], - "postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], + "postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="], "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], @@ -872,7 +872,7 @@ "valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="], - "vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "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-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="], + "vite": ["vite@8.2.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.23", "rolldown": "~1.2.0", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.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-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ=="], "vite-plugin-checker": ["vite-plugin-checker@0.14.5", "", { "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-c9lQ92eisUO+F7Fd93aelojmiOS+NQpPgQ1XR2LTQHox1/laZf4yAoQj+L3RA9Vgh10e2nFd9b8r2LLyYZsbpA=="], @@ -888,7 +888,7 @@ "vue": ["vue@3.5.40", "", { "dependencies": { "@vue/compiler-dom": "3.5.40", "@vue/compiler-sfc": "3.5.40", "@vue/runtime-dom": "3.5.40", "@vue/server-renderer": "3.5.40", "@vue/shared": "3.5.40" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig=="], - "vue-component-meta": ["vue-component-meta@3.3.8", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.8", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-ISPs6028bEvf1Hn44kFZ+uOiDomEkZCvfUgcdA83sVOYJ9g6EkH1M8yCmJP0ewLyf/0M+sF41bjgIg7jPw4MmA=="], + "vue-component-meta": ["vue-component-meta@3.3.9", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.9", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-4u1VmMVjqs9BbKaISBZD7b+dNdqFteh5BYN8xVaXkjjJ66no1sRtDnS6aSs5/P5AzQQGPw10snHTf/z3HpBn2w=="], "vue-component-type-helpers": ["vue-component-type-helpers@3.3.7", "", {}, "sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg=="], @@ -898,7 +898,7 @@ "vue-router": ["vue-router@5.2.0", "", { "dependencies": { "@babel/generator": "^8.0.0", "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", "ast-walker-scope": "^0.9.0", "chokidar": "^5.0.0", "json5": "^2.2.3", "local-pkg": "^1.2.1", "magic-string": "^0.30.21", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "nostics": "^1.1.4", "pathe": "^2.0.3", "picomatch": "^4.0.5", "scule": "^1.3.0", "tinyglobby": "^0.2.17", "unplugin": "^3.3.0", "unplugin-utils": "^0.3.2", "yaml": "^2.9.0" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", "pinia": "^3.0.4 || ^4.0.2", "vite": "^7.3.0 || ^8.0.0", "vue": "^3.5.34 || ^4.0.0" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw=="], - "vuetify": ["vuetify@4.1.6", "", { "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-VOsRTsNfs+FE4JXMONZkqX2yznSqC/FXG9/pgVVjsoqIjUvJg+CfFTuOlgyWVFrZYY+crk7LV9uaiN8bZREV/g=="], + "vuetify": ["vuetify@4.1.7", "", { "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-07qxu0S1oxPjmknP/Yn5276PtIhlCvHnpcp7HuOKEHni89/6BGiM2aVdKlEGeyM5RfytDkiv0SNieabGT2D8qQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], @@ -982,6 +982,8 @@ "@vue/compiler-sfc/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "@vue/compiler-sfc/postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], + "@vue/compiler-ssr/@vue/shared": ["@vue/shared@3.5.39", "", {}, "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="], "@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], @@ -1024,6 +1026,10 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "vite/rolldown": ["rolldown@1.2.1", "", { "dependencies": { "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.1", "@rolldown/binding-darwin-arm64": "1.2.1", "@rolldown/binding-darwin-x64": "1.2.1", "@rolldown/binding-freebsd-x64": "1.2.1", "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", "@rolldown/binding-linux-arm64-gnu": "1.2.1", "@rolldown/binding-linux-arm64-musl": "1.2.1", "@rolldown/binding-linux-ppc64-gnu": "1.2.1", "@rolldown/binding-linux-s390x-gnu": "1.2.1", "@rolldown/binding-linux-x64-gnu": "1.2.1", "@rolldown/binding-linux-x64-musl": "1.2.1", "@rolldown/binding-openharmony-arm64": "1.2.1", "@rolldown/binding-wasm32-wasi": "1.2.1", "@rolldown/binding-win32-arm64-msvc": "1.2.1", "@rolldown/binding-win32-x64-msvc": "1.2.1" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw=="], + "vitest/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "vitest/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=="], @@ -1044,6 +1050,8 @@ "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "@vue/compiler-sfc/postcss/nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "@vue/language-core/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.40", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.40", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ=="], "ast-walker-scope/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], @@ -1056,6 +1064,60 @@ "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.142.0", "", {}, "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ=="], + + "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA=="], + + "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA=="], + + "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ=="], + + "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg=="], + + "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA=="], + + "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg=="], + + "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A=="], + + "vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg=="], + + "vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ=="], + + "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw=="], + + "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A=="], + + "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.1", "", { "os": "none", "cpu": "arm64" }, "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw=="], + + "vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.2.1", "", { "dependencies": { "@emnapi/core": "2.0.0-alpha.3", "@emnapi/runtime": "2.0.0-alpha.3", "@napi-rs/wasm-runtime": "^1.2.0" } }, "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww=="], + + "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw=="], + + "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ=="], + "vitest/vite/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=="], "vue/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.40", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.40", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ=="], @@ -1072,6 +1134,16 @@ "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "vite/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@2.0.0-alpha.3", "", { "dependencies": { "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" } }, "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g=="], + + "vite/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@2.0.0-alpha.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA=="], + + "vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="], + + "vitest/vite/postcss/nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "vue/@vue/compiler-dom/@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "vite/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@2.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ=="], } } diff --git a/frontend/package.json b/frontend/package.json index 2e455b262..34ca1cc04 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "codex", - "version": "2.2.4", + "version": "2.2.5", "private": true, "description": "ui for codex api", "type": "module", @@ -26,7 +26,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": { @@ -37,14 +37,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/frontend/src/components/admin/tabs/tagging-tab.vue b/frontend/src/components/admin/tabs/tagging-tab.vue index 3d878cca1..f80c510cc 100644 --- a/frontend/src/components/admin/tabs/tagging-tab.vue +++ b/frontend/src/components/admin/tabs/tagging-tab.vue @@ -136,8 +136,10 @@
+ @@ -146,18 +148,37 @@ {{ defaults.hasMetronCredentials - ? "Credentials set" + ? metronLabels.status : "Not configured" }} +

+ {{ mdiAlert }} + This install still authenticates with a Metron Cloud + username & password. Metron has deprecated password + authentication and will throttle, then remove it — save + an API key below to keep online tagging working. Read + Metron's token authentication announcement{{ mdiOpenInNew }}. +

- Save Metron Cloud Credentials + Save Metron Cloud API Key - Save Comic Vine Credentials + Save Comic Vine API Key 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 08c3e1a4e..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"; @@ -25,6 +26,8 @@ function mountDialog({ taggingDefaults = { hasMetronCredentials: true, hasComicvineCredentials: true, + metronKeySet: true, + comicvineKeySet: true, }, identifiers = [], } = {}) { @@ -44,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: { @@ -90,7 +125,81 @@ describe("OnlineTagLauncherDialog", () => { }); }); - describe("idTaggable (whether the By ID panel exists)", () => { + describe("Metron deprecated credentials warning", () => { + test("warns and links to admin when only a legacy login is stored", async () => { + const { wrapper } = mountDialog({ + taggingDefaults: { + hasMetronCredentials: true, + hasComicvineCredentials: true, + metronKeySet: false, + }, + }); + await wrapper.vm.$nextTick(); + + const warning = wrapper.find(".credentialWarning"); + expect(warning.text()).toContain( + "Metron password authentication is deprecated", + ); + expect(warning.find("router-link-stub").exists()).toBe(true); + }); + + test("stays quiet when Metron is deselected for this session", async () => { + const { wrapper } = mountDialog({ + taggingDefaults: { + hasMetronCredentials: true, + hasComicvineCredentials: true, + metronKeySet: false, + }, + }); + wrapper.vm.sources = ["comicvine"]; + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.metronLegacyCredentials).toBe(false); + expect(wrapper.find(".credentialWarning").exists()).toBe(false); + }); + + test("returns once a pinned Metron id auto-selects Metron", async () => { + const { wrapper } = mountDialog({ + taggingDefaults: { + hasMetronCredentials: true, + hasComicvineCredentials: true, + metronKeySet: false, + }, + }); + wrapper.vm.sources = ["comicvine"]; + wrapper.vm.idInputs = ["comicvine:4000-12345"]; + await wrapper.vm.$nextTick(); + expect(wrapper.vm.metronLegacyCredentials).toBe(false); + + wrapper.vm.idInputs = ["metron:12345"]; + await wrapper.vm.$nextTick(); + expect(wrapper.vm.metronLegacyCredentials).toBe(true); + }); + + test("stays quiet once a Metron API key is set", async () => { + const { wrapper } = mountDialog(); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.metronLegacyCredentials).toBe(false); + expect(wrapper.find(".credentialWarning").exists()).toBe(false); + }); + + test("stays quiet when Metron is not configured at all", async () => { + const { wrapper } = mountDialog({ + taggingDefaults: { + hasMetronCredentials: false, + hasComicvineCredentials: true, + comicvineKeySet: true, + }, + }); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.metronLegacyCredentials).toBe(false); + expect(wrapper.find(".credentialWarning").exists()).toBe(false); + }); + }); + + describe("idTaggable (whether the id inputs exist)", () => { test("true for a single comic", () => { const { wrapper } = mountDialog(); expect(wrapper.vm.idTaggable).toBe(true); @@ -111,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(); @@ -127,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 () => { @@ -141,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(); @@ -152,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(); - onlineTagStore.tagById.mockResolvedValue({ source: "metron", id: 12345 }); - wrapper.vm.activeTab = "byId"; + wrapper.vm.sources = ["metron", "comicvine"]; 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" }, + sources: ["metron", "comicvine"], + }), + ); + expect(wrapper.emitted("started")).toBeTruthy(); + }); + + test("a fully pinned session still goes through startSession", async () => { + const { wrapper, onlineTagStore } = mountDialog(); + + wrapper.vm.sources = ["metron"]; + wrapper.vm.idInputs = ["metron:12345"]; + await wrapper.vm.$nextTick(); + await wrapper.vm.submit(); + + 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: [ @@ -220,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(); @@ -237,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. @@ -289,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/frontend/tests/unit/tagging-tab.test.js b/frontend/tests/unit/tagging-tab.test.js index 173012ced..269922984 100644 --- a/frontend/tests/unit/tagging-tab.test.js +++ b/frontend/tests/unit/tagging-tab.test.js @@ -19,6 +19,8 @@ * lingers in ``defaultSources``. * - Saving credentials for a source that had none enables it as a default * source automatically; re-saving credentials leaves the checkbox alone. + * - Metron is presented as an API key source like Comic Vine; only an install + * whose stored credential is still a legacy login says "Credentials". * - The Online Tagging Defaults section shows help for Match Mode and Prompts. * - The Metadata Formats field's hint (not a section header) links out to the * ComicInfo and MetronInfo docs. @@ -43,8 +45,7 @@ const BASE_DEFAULTS = { comicvineKeySet: false, }; -const SOURCE_TIP = - "A source can only be enabled once its credentials are saved."; +const SOURCE_TIP = "A source can only be enabled once its API key is saved."; function mountTab(overrides = {}) { const taggingDefaults = { ...BASE_DEFAULTS, ...overrides }; @@ -199,11 +200,67 @@ describe("AdminTaggingTab — Online Sources enable checkboxes", () => { } await new Promise((resolve) => setTimeout(resolve, 0)); - expect(wrapper.text()).toContain("Save Metron Cloud Credentials"); - expect(wrapper.text()).toContain("Save Comic Vine Credentials"); + expect(wrapper.text()).toContain("Save Metron Cloud API Key"); + expect(wrapper.text()).toContain("Save Comic Vine API Key"); expect(wrapper.text()).not.toContain("Custom URL"); }); + test("Metron reads as an API key source, like Comic Vine", () => { + const wrapper = mountTab({ + hasMetronCredentials: true, + metronKeySet: true, + }); + const vm = wrapper.vm; + + expect(wrapper.text()).toContain("API key set"); + expect(wrapper.text()).not.toContain("Credentials set"); + expect(vm.metronLabels.clearButton).toBe("Clear API Key"); + expect(vm.metronLabels.clearTitle).toBe("Clear Metron Cloud API Key"); + expect(vm.metronLabels.clearText).toBe( + "Remove the saved Metron Cloud API key?", + ); + }); + + test("a legacy login still reads as credentials, not an API key", () => { + // Pre-key installs keep a username & password; don't call that a key. + const wrapper = mountTab({ + hasMetronCredentials: true, + metronKeySet: false, + }); + const vm = wrapper.vm; + + expect(wrapper.text()).toContain("Credentials set"); + expect(vm.metronLabels.clearButton).toBe("Clear Credentials"); + expect(vm.metronLabels.clearTitle).toBe("Clear Metron Cloud Credentials"); + expect(vm.metronLabels.clearText).toBe( + "Remove the saved Metron Cloud credentials?", + ); + }); + + test("a legacy login warns that password auth is deprecated", () => { + const wrapper = mountTab({ + hasMetronCredentials: true, + metronKeySet: false, + }); + + const warning = wrapper.find(".deprecatedHint"); + expect(warning.exists()).toBe(true); + expect(warning.text()).toContain("deprecated password authentication"); + expect(warning.find("a").attributes("href")).toBe( + "https://metron-project.github.io/blog/token-authentication", + ); + // The panel opens itself so the warning isn't buried behind a click. + expect(wrapper.find(".v-expansion-panel--active").exists()).toBe(true); + }); + + test("no deprecation warning once an API key is saved", () => { + const wrapper = mountTab({ + hasMetronCredentials: true, + metronKeySet: true, + }); + expect(wrapper.find(".deprecatedHint").exists()).toBe(false); + }); + test("saving sends only the API key, never a username or password", async () => { const wrapper = mountTab(); const vm = wrapper.vm; diff --git a/package.json b/package.json index 2462b593f..51d2f9c11 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "@stylistic/eslint-plugin": "^5.10.0", "@vitest/eslint-plugin": "^1.6.24", "eslint": "^10.8.0", - "eslint_d": "^15.0.2", + "eslint_d": "^15.0.3", "eslint-config-prettier": "^10.1.8", "eslint-plugin-array-func": "^5.1.1", "eslint-plugin-compat": "^7.0.2", @@ -93,7 +93,7 @@ "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-plugin-yml": "^3.7.0", "postcss-scss": "^4.0.9", "prettier": "^3.9.6", "prettier-plugin-nginx": "^1.0.3", diff --git a/pyproject.toml b/pyproject.toml index 1daabd590..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", @@ -49,7 +49,7 @@ readme = "README.md" requires-python = ">=3.12" license = "GPL-3.0-only" name = "codex" -version = "2.2.4" +version = "2.2.5" [[project.authors]] name = "AJ Slater" email = "aj@slater.net" @@ -109,7 +109,7 @@ docs = ["mkdocs-minify-plugin~=0.8", "mkdocs~=1.6"] ci = [] [build-system] -requires = ["uv_build~=0.11.2"] +requires = ["uv_build~=0.12.0"] build-backend = "uv_build" [tool.uv.build-backend] diff --git a/tests/importer/test_failed_import_vanished.py b/tests/importer/test_failed_import_vanished.py new file mode 100644 index 000000000..c0566b347 --- /dev/null +++ b/tests/importer/test_failed_import_vanished.py @@ -0,0 +1,60 @@ +""" +Failed imports for files that no longer exist are not recorded. + +A file renamed out from under a running import fails to read, but the +failure belongs to a path that no longer exists — recording it leaves a +permanent phantom row in the admin's failed imports panel. +""" + +from threading import Event, Lock +from typing import override + +from loguru import logger + +from codex.librarian.mp_queue import LIBRARIAN_QUEUE +from codex.librarian.scribe.importer.const import FIS +from codex.librarian.scribe.importer.importer import ComicImporter +from codex.librarian.scribe.importer.tasks import ImportTask +from codex.models import FailedImport, Library +from tests.importer.test_basic import LIBRARY_PATH, PATH, BaseTestImporter + +_VANISHED = str(LIBRARY_PATH / "gone.cbz") + + +class TestFailedImportVanished(BaseTestImporter): + """Only failures for files still on disk become rows.""" + + @override + def setUp(self) -> None: + super().setUp() + task = ImportTask(library_id=self.task.library_id) + self.fi_importer = ComicImporter(task, logger, LIBRARIAN_QUEUE, Lock(), Event()) + + def test_vanished_path_creates_no_row(self) -> None: + self.fi_importer.metadata[FIS] = {_VANISHED: ValueError("does not exist")} + + self.fi_importer.fail_imports() + + assert FailedImport.objects.count() == 0 + + def test_existing_path_creates_row(self) -> None: + self.fi_importer.metadata[FIS] = {PATH: ValueError("bad tags")} + + self.fi_importer.fail_imports() + + fi = FailedImport.objects.get(path=PATH) + assert "bad tags" in fi.name + + def test_repeated_failure_updates_row(self) -> None: + library = Library.objects.get(pk=self.task.library_id) + old = FailedImport(library=library, path=PATH) + old.set_reason(ValueError("first reason")) + old.presave() + old.save() + self.fi_importer.metadata[FIS] = {PATH: ValueError("second reason")} + + self.fi_importer.fail_imports() + + assert FailedImport.objects.count() == 1 + fi = FailedImport.objects.get(path=PATH) + assert "second reason" in fi.name diff --git a/tests/importer/test_moved_stat_preserved.py b/tests/importer/test_moved_stat_preserved.py new file mode 100644 index 000000000..f9a01c6c6 --- /dev/null +++ b/tests/importer/test_moved_stat_preserved.py @@ -0,0 +1,88 @@ +""" +A move must not refresh Comic.stat. + +The stored stat means "the state of the file the last time its tags were +imported". Refreshing it during a move erases the only evidence the read +phase has that a renamed file's contents also changed, so a write-then- +rename would import its new tags never, rather than late. +""" + +import shutil +from pathlib import Path +from threading import Event, Lock +from typing import override + +from loguru import logger + +from codex.librarian.mp_queue import LIBRARIAN_QUEUE +from codex.librarian.scribe.importer.importer import ComicImporter +from codex.librarian.scribe.importer.tasks import ImportTask +from codex.models import ( + Comic, + Folder, + Imprint, + Library, + Publisher, + Series, + Volume, +) +from tests.importer.test_basic import ( + COMIC_PATH, + LIBRARY_PATH, + PATH, + BaseTestImporter, +) + +_NEW_PATH = str(LIBRARY_PATH / "renamed.cbz") +# Deliberately unlike anything on disk, per WatchedPath.set_stat's layout: +# [1] is the inode, [6] the size, [8] the mtime. +_SENTINEL_STAT = [1, 424242, 0, 0, 0, 0, 99, 0, 12345.0, 0] + + +class TestImporterMovePreservesStat(BaseTestImporter): + """A moved comic keeps the stat from its last tag import.""" + + @override + def setUp(self) -> None: + super().setUp() + # setUpTestData copies the fixture once per class, and this test + # moves it away. Restore it so each method starts whole. + shutil.copy(COMIC_PATH, PATH) + library = Library.objects.get(pk=self.task.library_id) + folder = Folder.objects.create( + library=library, path=str(Path(PATH).parent), name=LIBRARY_PATH.name + ) + pub = Publisher.objects.create(name="Move Test Pub") + imp = Imprint.objects.create(name="Move Imprint", publisher=pub) + ser = Series.objects.create(name="Move Series", imprint=imp, publisher=pub) + vol = Volume.objects.create(name="1", series=ser, imprint=imp, publisher=pub) + comic = Comic.objects.create( + library=library, + path=PATH, + parent_folder=folder, + issue_number=1, + name="before", + publisher=pub, + imprint=imp, + series=ser, + volume=vol, + size=1, + page_count=1, + ) + # Comic.save() presaves a real stat from disk, so plant the sentinel + # with an update() that skips it. + Comic.objects.filter(pk=comic.pk).update(stat=_SENTINEL_STAT) + + def test_move_preserves_stat(self) -> None: + shutil.move(PATH, _NEW_PATH) + task = ImportTask( + library_id=self.task.library_id, files_moved={PATH: _NEW_PATH} + ) + importer = ComicImporter(task, logger, LIBRARIAN_QUEUE, Lock(), Event()) + + importer.move_and_modify_dirs() + + comic = Comic.objects.get(path=_NEW_PATH) + assert comic.parent_folder is not None + assert comic.parent_folder.path == str(Path(_NEW_PATH).parent) + assert comic.stat == _SENTINEL_STAT, comic.stat diff --git a/tests/importer/test_write_then_rename.py b/tests/importer/test_write_then_rename.py new file mode 100644 index 000000000..ff5565d1b --- /dev/null +++ b/tests/importer/test_write_then_rename.py @@ -0,0 +1,90 @@ +""" +An external write-then-rename imports the new tags, not a failed import. + +Every tagger that renames (including codex's own tag writer) writes tags +into the file and then renames it. Both land in one watch batch, so the +task carries a move plus a modify that still names the pre-rename path. +The importer applies the move first, and the modify used to be read from +the vanished old path: a phantom failed import, and tags that never +imported because the move had already refreshed Comic.stat to match disk. +""" + +import os +import shutil +from datetime import UTC, datetime +from pathlib import Path +from threading import Event, Lock +from typing import override + +from loguru import logger +from watchfiles import Change + +from codex.librarian.fs.import_task import build_import_task +from codex.librarian.fs.watcher.events import process_changes +from codex.librarian.mp_queue import LIBRARIAN_QUEUE +from codex.librarian.scribe.importer.importer import ComicImporter +from codex.models import Comic, FailedImport +from tests.importer.test_basic import ( + COMIC_PATH, + LIBRARY_PATH, + PATH, + BaseTestImporter, + run_full_import, +) + +_NEW_PATH = str(LIBRARY_PATH / "Captain Science v1950 #001.cbz") +# Predates the fixture archive's embedded metadata mtime, so comicbox +# re-parses the tags instead of short-circuiting on them. +_STALE_METADATA_MTIME = datetime(2020, 1, 1, tzinfo=UTC) + + +class TestImporterWriteThenRename(BaseTestImporter): + """A moved comic whose contents also changed re-reads its tags.""" + + @override + def setUp(self) -> None: + super().setUp() + # setUpTestData copies the fixture once per class, and this test + # renames it away. Restore it so each method starts whole. + shutil.copy(COMIC_PATH, PATH) + run_full_import(self.importer) + # Stand in for the tag write: the archive's bytes changed, so its + # filesystem mtime advances past the stat stored at import. + mtime = Path(PATH).stat().st_mtime + 100 + os.utime(PATH, (mtime, mtime)) + Comic.objects.filter(path=PATH).update( + name="stale", metadata_mtime=_STALE_METADATA_MTIME + ) + # Then the rename. + shutil.move(PATH, _NEW_PATH) + + def test_write_then_rename_reimports_tags(self) -> None: + # Drive the real watcher pipeline from the raw changes the kernel + # coalesces into one batch: the tag write's modify on the old path, + # and the rename's delete+add that inode matching pairs into a move. + changes = { + (Change.modified, PATH), + (Change.deleted, PATH), + (Change.added, _NEW_PATH), + } + events = [ + event + for library_pk, event in process_changes( + changes, {str(LIBRARY_PATH): self.task.library_id} + ) + if library_pk == self.task.library_id + ] + task = build_import_task(self.task.library_id, events) + assert task + assert task.files_moved == {PATH: _NEW_PATH} + assert task.files_modified == {_NEW_PATH} + importer = ComicImporter(task, logger, LIBRARIAN_QUEUE, Lock(), Event()) + + importer.move_and_modify_dirs() + run_full_import(importer) + + assert not FailedImport.objects.exists(), list( + FailedImport.objects.values_list("path", "name") + ) + comic = Comic.objects.get(path=_NEW_PATH) + assert comic.name == "The Beginning", comic.name diff --git a/tests/test_import_task_build.py b/tests/test_import_task_build.py new file mode 100644 index 000000000..bf536643e --- /dev/null +++ b/tests/test_import_task_build.py @@ -0,0 +1,91 @@ +""" +Test ImportTask assembly from filesystem events. + +The interesting case is a write followed by a rename, which every external +tagger (including codex's own) performs: the modify event names the file +before the rename, and the rename lands in the same watch batch as a +delete+add pair that inode-matching turns into a move. If the modify keeps +naming the vanished source path, the importer reads a dead path and files a +failed import instead of picking up the new tags. +""" + +from codex.librarian.fs.events import FSChange, FSEvent +from codex.librarian.fs.import_task import build_import_task + +_LIBRARY_ID = 1 +_OLD = "/comics/Series/Old Name #001.cbz" +_NEW = "/comics/Series/New Name v2017 #001.cbz" +_OTHER = "/comics/Series/Untouched #002.cbz" + + +def _modified(path: str) -> FSEvent: + return FSEvent(src_path=path, change=FSChange.modified) + + +def _moved(src: str, dest: str) -> FSEvent: + return FSEvent(src_path=src, change=FSChange.moved, dest_path=dest) + + +def _build(*events: FSEvent): + return build_import_task(_LIBRARY_ID, events) + + +def test_modified_source_remapped_to_move_dest(): + """A write-then-rename reads the destination, not the vanished source.""" + task = _build(_modified(_OLD), _moved(_OLD, _NEW)) + + assert task + assert task.files_modified == {_NEW} + assert task.files_moved == {_OLD: _NEW} + assert task.files_created == set() + + +def test_modified_dest_passes_through(): + """The poller emits dest-modified for a move whose stats changed.""" + task = _build(_modified(_NEW), _moved(_OLD, _NEW)) + + assert task + assert task.files_modified == {_NEW} + assert task.files_moved == {_OLD: _NEW} + + +def test_modified_unrelated_to_move_untouched(): + """A modified path that takes no part in a move is left alone.""" + task = _build(_modified(_OTHER), _moved(_OLD, _NEW)) + + assert task + assert task.files_modified == {_OTHER} + + +def test_deleted_wins_over_move_and_modified(): + """A deleted source cancels its move, and the remap cannot revive it.""" + task = _build( + _modified(_OLD), + _moved(_OLD, _NEW), + FSEvent(src_path=_OLD, change=FSChange.deleted), + ) + + assert task + assert task.files_moved == {} + assert task.files_modified == set() + assert task.files_deleted == {_OLD} + + +def test_added_dest_still_dropped(): + """A move's destination is not also a creation.""" + task = _build(FSEvent(src_path=_NEW, change=FSChange.added), _moved(_OLD, _NEW)) + + assert task + assert task.files_created == set() + assert task.files_moved == {_OLD: _NEW} + + +def test_no_work_returns_none(): + """A batch with nothing left to do builds no task.""" + # A directory creation has no ImportTask field; the walk that expands it + # into child file events is what carries the work. + task = _build( + FSEvent(src_path="/comics/Series", change=FSChange.added, is_directory=True) + ) + + assert task is None 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: diff --git a/uv.lock b/uv.lock index e3da8a562..6c0f3de57 100644 --- a/uv.lock +++ b/uv.lock @@ -567,7 +567,7 @@ wheels = [ [[package]] name = "codex" -version = "2.2.4" +version = "2.2.5" 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.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] @@ -957,52 +957,52 @@ wheels = [ [[package]] name = "cryptography" -version = "49.0.0" +version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, ] [[package]] @@ -1227,7 +1227,7 @@ wheels = [ [[package]] name = "djlint" -version = "1.43.1" +version = "1.43.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1238,45 +1238,45 @@ dependencies = [ { name = "pyyaml" }, { name = "regex" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/91/78be6c3a7d60c5d1c3c473fdc74bad878e3580ef22349329d8d4b15e4df9/djlint-1.43.1.tar.gz", hash = "sha256:25e6e12f01eaddc8fc40d841e6a1611b6cfda041e885f1494cf5eae71efb987c", size = 79144, upload-time = "2026-07-28T12:12:41.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/00/60f314835d5d246d1af56aaed20d4f16c84c2067edb06fb1af1ff3d5c1b8/djlint-1.43.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:abcc445fd68f534456bb88f8ac35fd7638aa7f490a81323a9627bcd6cdf1a0cc", size = 797104, upload-time = "2026-07-28T12:11:52.025Z" }, - { url = "https://files.pythonhosted.org/packages/16/92/87c352041006e14ccaa90596b1625b6941451a1de23c57054dcf39c8db5d/djlint-1.43.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14401d0611d26c20303fcdeae12cf6b09eb55870a1211dea3266c3e0e90af3cf", size = 744656, upload-time = "2026-07-28T12:11:53.431Z" }, - { url = "https://files.pythonhosted.org/packages/e5/68/d326b9a6e708f13265e051bb5983ec8644d91f7b77c69f1b0dc4e58a2183/djlint-1.43.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0137f0114386c3d0ffa5e505d75c1c74b22a9a6a1a2e2b4dd6945cd5efcf9bc4", size = 784196, upload-time = "2026-07-28T12:11:54.844Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b6/abcea8866aae81d82876fafeadbb48d422ce55c2c17610c50e4798ad5bf1/djlint-1.43.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6fdc73dece61a61aa9e30e97927f18cf040f85adb48d5bfc5ff5bfede44342dc", size = 825057, upload-time = "2026-07-28T12:11:55.994Z" }, - { url = "https://files.pythonhosted.org/packages/86/5f/ad4d29882a6752866f7ce38485b0214d08be86bf2c45c6a1f99f01b05b03/djlint-1.43.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afe99dbe9553f47876ab9fc36c6419f40d35b12f3b1738e6f59375218fa292e1", size = 796247, upload-time = "2026-07-28T12:11:57.318Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f6/c41b559441a2e69dcf0b42d3b9575bb0a0f19a0a1500f4ee53cbcc1d8870/djlint-1.43.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:111db2fdd5cf315f6c31844f50a26cae0eba38e55fbeb6a6687e1c4a2866131c", size = 838273, upload-time = "2026-07-28T12:11:58.607Z" }, - { url = "https://files.pythonhosted.org/packages/7a/2f/feceb7f480a5c991ee3918c62cc5ab9799100769f0ec1c2f64fa2c4d2290/djlint-1.43.1-cp312-cp312-win32.whl", hash = "sha256:b20a5f684da860da0c1d92b3f2abb2d834d715c5c1d85a2d529ec805e93c73b5", size = 603539, upload-time = "2026-07-28T12:11:59.909Z" }, - { url = "https://files.pythonhosted.org/packages/82/13/aebfb866b7a458fe05475326e878579fd25900561a6397805edbdae7c95c/djlint-1.43.1-cp312-cp312-win_amd64.whl", hash = "sha256:440af8edfaa437b0f855caa0378c32028038a9c54d6c40cb5f4a8bb941d3b23b", size = 681396, upload-time = "2026-07-28T12:12:01.223Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4e/30e0572f8456691093dcbae50a57d71cca34cf9da005279a780dfab96439/djlint-1.43.1-cp312-cp312-win_arm64.whl", hash = "sha256:0fb2ee99176a645246844868a10c6de69c4bb644e530cbfd5e050e0a08da3649", size = 600239, upload-time = "2026-07-28T12:12:02.585Z" }, - { url = "https://files.pythonhosted.org/packages/28/4f/8af18ad463da7ad79af61dda8de93615471e336e2d29b4f9ae9c8a164db3/djlint-1.43.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e12abfd8c1dc33cdab18dbca21398a75fd99088ed1025716327fca38ed1ea2d8", size = 785761, upload-time = "2026-07-28T12:12:04.15Z" }, - { url = "https://files.pythonhosted.org/packages/6a/89/a40480cdcb943cf1a017af972cdd54975ecb5e4b2a41b6a80432069bee42/djlint-1.43.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:614eec9813eb405742daac9322d32647d2ac457e9ed89d6171ee2fa26b8cb14d", size = 736802, upload-time = "2026-07-28T12:12:05.392Z" }, - { url = "https://files.pythonhosted.org/packages/f3/81/9b3bbe528d2880c14b96a4f9ed7ee0f229bb7129f8e1787c792911f78265/djlint-1.43.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:903feda8d4f500dbba3a94aaa0a1565d884d8a144ef0c21e194496cc7970975c", size = 780969, upload-time = "2026-07-28T12:12:06.806Z" }, - { url = "https://files.pythonhosted.org/packages/e7/49/5d942d9090edadf174dde87e41f46abe4293ba1012c83a33704da4edcebd/djlint-1.43.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6940d366f933c3db58ce58ab1f12668062934f0e93c6c0737d113b29479ab65c", size = 823191, upload-time = "2026-07-28T12:12:08.212Z" }, - { url = "https://files.pythonhosted.org/packages/22/8d/68d8870e5514070467441e8c4fdce2be1b220ba54dfb0e582f48f0036bde/djlint-1.43.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:95084e1c175e270c65e4a4d7c6b4f0ae75810bbf02509e59c10bbcce456d89ef", size = 793340, upload-time = "2026-07-28T12:12:09.68Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/6114e7a5b8c9ff361450d6854f603617389fd121355e9b23dd9ec18c37ef/djlint-1.43.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00e28ec5cb6ecca1e8d3d1ee5af6cfd2d4dc78f686e232516759cdbdccb39313", size = 835743, upload-time = "2026-07-28T12:12:10.998Z" }, - { url = "https://files.pythonhosted.org/packages/40/d8/aaf36b55d708a24b3873471db16a4e096fca8e532c478aa3e121579e04a1/djlint-1.43.1-cp313-cp313-win32.whl", hash = "sha256:0fa0a5adf5427535c6c2fb243aef4ed31cdbda6bbca82cf1e3ac3b04afe44a92", size = 604170, upload-time = "2026-07-28T12:12:12.314Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/ebc903c76fd72b0075ff9323a051d2281a79c08494895a8840cc5d8ec7ef/djlint-1.43.1-cp313-cp313-win_amd64.whl", hash = "sha256:c14a0a0a2d84217406f0fabd7c6012c2766019589b03b10174ac9f7fa5f4e80a", size = 682685, upload-time = "2026-07-28T12:12:13.644Z" }, - { url = "https://files.pythonhosted.org/packages/81/29/020acd1096de20f9b57104aa49f231c8162e8fa42615a85760ed54d07399/djlint-1.43.1-cp313-cp313-win_arm64.whl", hash = "sha256:6fe7b8fc390ec1d7ae72416f91d9492b70c30bc4866d93d81c207c85135ba226", size = 600635, upload-time = "2026-07-28T12:12:14.97Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e5/d058a05767276aef83be6aa9c66eec41300eec4f92d679d26341c2dcf28a/djlint-1.43.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5054f815045ee90f5ca876d6d828f1ae0e4421049c0ef1ed2b02b7f555c149cb", size = 781942, upload-time = "2026-07-28T12:12:16.609Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9f/b03383a2bc8e4d42b46d01bd9ebba561e953e751162147ff9a97bdd5f28d/djlint-1.43.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1aace39bc4c85cbb819219ce713b78bb11f0e64f17be474e2b0ff29939cc09ac", size = 736940, upload-time = "2026-07-28T12:12:17.817Z" }, - { url = "https://files.pythonhosted.org/packages/10/c3/d85da23ed84e6dd460536eae7b6758ed3b93e0f1417710994da63a90a7cc/djlint-1.43.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:736177e5e670f8fc15c955e62a64bbfdfffbe5eda2735d15099948eeca15908c", size = 789966, upload-time = "2026-07-28T12:12:19.168Z" }, - { url = "https://files.pythonhosted.org/packages/f4/47/1d39759c37ffcc7880fd50bd83e3c82d1e95cf389fc723d8e5c60475ff15/djlint-1.43.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a1b1af926551f7df7246c98bdf2a433f70ea24bc7668aa65f11cc118f32531", size = 822194, upload-time = "2026-07-28T12:12:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/b6/7c/783740134f1f475fbb13cb8be1c9254903b7907682c8f508b9f82b892f83/djlint-1.43.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:58a3eca0d4b226cc17c54d4f2e6ad989fc5fe62590bcefbef9d84e7408b258b0", size = 799954, upload-time = "2026-07-28T12:12:21.795Z" }, - { url = "https://files.pythonhosted.org/packages/e7/18/d34bd637814f2ad55af56babd6a1ede08ee50ed8fdf52c394a0ab6afbe49/djlint-1.43.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b7550d10b8e339fddcd636ee4aa4b857815a50cfa8941640974d45ef99ed3a09", size = 835237, upload-time = "2026-07-28T12:12:23.135Z" }, - { url = "https://files.pythonhosted.org/packages/2f/81/617d25684cffca3076093ea2dcc0bf5097201a0ed140ade807eab89513ad/djlint-1.43.1-cp314-cp314-win32.whl", hash = "sha256:87724d68d14d0ceaa603a5c589e8880a924e8ffaa2e100c203f03fb907714912", size = 611210, upload-time = "2026-07-28T12:12:24.487Z" }, - { url = "https://files.pythonhosted.org/packages/58/a9/a98e8da1d73e4d6fff2be97c991c40f06cdf3fc095c823464083f2841c8f/djlint-1.43.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6449da1c8740872f8e2900ad8cde55c8af4642b4cb0ee45d09dfe4a01a661f1", size = 694678, upload-time = "2026-07-28T12:12:25.617Z" }, - { url = "https://files.pythonhosted.org/packages/f6/10/8b6c911bee63c55a883acc9aff6b97e35d4832b124aa8d58402167d567b3/djlint-1.43.1-cp314-cp314-win_arm64.whl", hash = "sha256:dee34b1f287fbc61c5072aed186809cb8f4bb91e87f224864eb4cf2dab251ef7", size = 613601, upload-time = "2026-07-28T12:12:26.887Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e4/eb946ce7a1e4ac1082c874968cf2a4b4f469f7751cb50c868cddf89fc5c1/djlint-1.43.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b52eb2ef47675968da65e62a5eb02e5fd5587a62f3d3e34a4b1d22f715e7586e", size = 845911, upload-time = "2026-07-28T12:12:28.45Z" }, - { url = "https://files.pythonhosted.org/packages/09/9e/114ac7a2f58dab2536d4fbee23fd1836587d3f725be5f137e5598449992e/djlint-1.43.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22b1fe742e64ad160e78c10c6a6dd2f27db3bbdbda79f1dcb3aeaeef4cd8f9ac", size = 794607, upload-time = "2026-07-28T12:12:29.726Z" }, - { url = "https://files.pythonhosted.org/packages/af/8b/2ea8e76302b7a1970ac3a9d9285c6842bf646c7bfd59c5bc9a0ba7b5a2fb/djlint-1.43.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db54d6ea7f59f0e841140599dd401f2cb6166552d5f03e05335ab47a9876b6e5", size = 823071, upload-time = "2026-07-28T12:12:31.046Z" }, - { url = "https://files.pythonhosted.org/packages/77/07/02bd6e4c0dd9034d7f8bcf8d12984c9e69c814a1e5411a4a35000739e8ac/djlint-1.43.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b0e49567814edd16ed22153ed0c8e85f3ba32121e7951ce39a16b642b322a0b", size = 856617, upload-time = "2026-07-28T12:12:32.268Z" }, - { url = "https://files.pythonhosted.org/packages/35/f9/1dbc1bd3bffed8bd0ab1700768d3bbf60ccb54d6300f07aaaf405d8ab3a5/djlint-1.43.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:089eec2ad6ee64d102719aa88d5d63187b4ed4ef2d009665d6af7bf569efc53d", size = 832617, upload-time = "2026-07-28T12:12:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/f8/05/4294567ac0a522a17d5cd579360d31e35371dd86eab8e8d06653eecf3451/djlint-1.43.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a8abea595854009b04980f8aa825ad1de46e18eba1942a5f1ba7823457b0b9a0", size = 868736, upload-time = "2026-07-28T12:12:35.057Z" }, - { url = "https://files.pythonhosted.org/packages/50/74/6391d63f6f00113704c6111b0a2cda9e43e4db1c8d3b5500a6721e59651e/djlint-1.43.1-cp314-cp314t-win32.whl", hash = "sha256:ab979974bf603e454f601fc3be9629c63c79de827bcddf8062bab38494fd57ff", size = 645077, upload-time = "2026-07-28T12:12:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/48/49/b76ba6bd4b4dce830692cb3a7a35e8ff0088b8ea92b8235b6be7c40f91e4/djlint-1.43.1-cp314-cp314t-win_amd64.whl", hash = "sha256:e9261fc5013120be6083be2f19e4749a2e1bbc5c5de630022cb6a02a9c9364bf", size = 722866, upload-time = "2026-07-28T12:12:37.658Z" }, - { url = "https://files.pythonhosted.org/packages/74/21/9d3841df8efabeab89671c42f80dcef86caec1e13b4bd7e5ef485d04597d/djlint-1.43.1-cp314-cp314t-win_arm64.whl", hash = "sha256:122c374cd3c8a42614aeb212a4860d82c06bf3fe883b7f5074e018052709057e", size = 641584, upload-time = "2026-07-28T12:12:39.056Z" }, - { url = "https://files.pythonhosted.org/packages/15/5d/ba1038cb76184bb8b91bdbf127e3fec43b3ec2dea2e5d7c74090b9d8d7a8/djlint-1.43.1-py3-none-any.whl", hash = "sha256:7d5f44c6d22fba3b6c172233954c8323086576a37777927c4e7d99af2016ae2d", size = 92688, upload-time = "2026-07-28T12:12:40.443Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/62/33/7efcde4581d1295c1e64a75b6d1ac444b4c2557b668e73032070ff5fc081/djlint-1.43.2.tar.gz", hash = "sha256:5a783d9b419b0833be1a13f213c0ea5e4a86aa505bd5d159884a2a349d06f7e8", size = 79429, upload-time = "2026-08-01T18:11:35.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/d3/2d91ba6789a31d36c5a60b50b7ee0e438e49f0d5292906f1490548877f89/djlint-1.43.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b408ab06be47b5698246bacd5c52a5bb521004bf020101de3c97f05272c7187c", size = 798549, upload-time = "2026-08-01T18:10:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/48/8a/f0a709a49160373f8c1830c74f3430cecf4e73256b5d45c9cb651439ac7e/djlint-1.43.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f72b2fa7576c2d1dc354526200d24ab31497e58e873b7fd7f65e7b434b51c084", size = 746050, upload-time = "2026-08-01T18:10:50.269Z" }, + { url = "https://files.pythonhosted.org/packages/89/f7/cff8daea9afc14c6fbe69eaf4bb45241b6fd8a1a55332a1700e5521d9447/djlint-1.43.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0f786367a863981d474ddef25f4567f32ffe4615a36da2e8cdf5023e0454f0d", size = 785811, upload-time = "2026-08-01T18:10:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c5/af91d53a9a4c2d51816c6930fd42bf457af27c3cfdf24072863e65354230/djlint-1.43.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495be4c8c88edeb155c854ef1bc49085789ab963f833c527383fea4cacd98fcc", size = 826151, upload-time = "2026-08-01T18:10:52.835Z" }, + { url = "https://files.pythonhosted.org/packages/00/72/dbde12bd9f34abe414302ffa3415f6f3446a89ad38a08c34ff408fb82989/djlint-1.43.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:873d45307377c37dce8380e427988aebb35cf049e81d6eb93e2c0867a19a6936", size = 797484, upload-time = "2026-08-01T18:10:54.107Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/eceb231602c07a85082053ac1fad06d93282b2e48889a835d1c4d560010e/djlint-1.43.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd70b3d735b0df87fe8f03dd0336b423eb2f62409edbc82aa418463647711a58", size = 839468, upload-time = "2026-08-01T18:10:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f0/99a655e012f117c2756bde96bf2ce2ef5b8ca44a583c89d6f7ed2c98757c/djlint-1.43.2-cp312-cp312-win32.whl", hash = "sha256:c5efeafcb82a75d6cb181e3cb138eeba4683794d37187fdf7eb06df0d3d971c6", size = 604579, upload-time = "2026-08-01T18:10:56.702Z" }, + { url = "https://files.pythonhosted.org/packages/03/e4/b3026c993dc562485306ed7bd3ba2f16a6594ab1e3bbe3877d3113700eec/djlint-1.43.2-cp312-cp312-win_amd64.whl", hash = "sha256:f690e4f57e5a98dc2e21858ed5fdb9fd0b4a03194d27ec984b5ccfad8ad49e3c", size = 682885, upload-time = "2026-08-01T18:10:57.988Z" }, + { url = "https://files.pythonhosted.org/packages/1b/0f/bef91d7a15390efb655ec6ae6a2d4339d40ef09cea45ff8f83f97b4709a7/djlint-1.43.2-cp312-cp312-win_arm64.whl", hash = "sha256:7d417d05a91571fe89ef8771586b97b65fb706409d67e511dd3401b55b3a31ba", size = 601172, upload-time = "2026-08-01T18:10:59.27Z" }, + { url = "https://files.pythonhosted.org/packages/19/c9/8e8ccc6fe34e778365d643b1068f9de44ec70b9c5ba3f8bf4e7fe364b8a8/djlint-1.43.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ba0528252afefdff3158bae5e5ab388de991975cb46cca5ba9aade90b61cc76", size = 788894, upload-time = "2026-08-01T18:11:00.584Z" }, + { url = "https://files.pythonhosted.org/packages/70/b1/27d3fe64a9577c11d79b8a6f3e55948c82f71fbae43d70cc68bc39196d46/djlint-1.43.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:059244446da78b8fd73bfe2d176fc9d90f4588f418361128222fa334a06d7d0d", size = 738192, upload-time = "2026-08-01T18:11:01.829Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bb/fee48fa37c7ebdfa34b5bec62bd59e1424c615f78beff8788114666cf080/djlint-1.43.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3b253574e91c0add5715d9250eab20366a9dd77266d4ce562c64ba7ed560fc", size = 782208, upload-time = "2026-08-01T18:11:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/e5/cf/cd6d7513155862dfe82077b36f5b289a9072ce3842201cc1603fe43969bd/djlint-1.43.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:172f820ef5383f9fbb850303d31576ee9472d15326206d2e53d80afc1499ea86", size = 824374, upload-time = "2026-08-01T18:11:04.404Z" }, + { url = "https://files.pythonhosted.org/packages/36/65/a70bea9c9b18a874b4eff35bcd4606ee151608f29db3a170789b3c8e1252/djlint-1.43.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bceea92d58455664235c2cc95381c3775cdb7100d4803c35f2d3d0ce4b2ce66a", size = 794660, upload-time = "2026-08-01T18:11:05.597Z" }, + { url = "https://files.pythonhosted.org/packages/24/27/356e11b22ac6703b7c8d13f4505cb21add69ee6db0509e274f53a1cb8e09/djlint-1.43.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a9461bb2c4694b748fc89ec112503aa2296131e9ffe62b068338195f8de0feae", size = 837100, upload-time = "2026-08-01T18:11:06.962Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d5/407a18447980b42e48a54c53deb4b0d13e32238309377773bbfc951d9ef7/djlint-1.43.2-cp313-cp313-win32.whl", hash = "sha256:87bd05fa5e6e4bc9d2a0dc7820eedc3ae2a197e50ed8acec8c0f8460fdc28a79", size = 605109, upload-time = "2026-08-01T18:11:08.084Z" }, + { url = "https://files.pythonhosted.org/packages/07/c4/ed2a5a81584f6d6017c44d72be18bc4a70bc75e2652ce72b3c7abb6712f1/djlint-1.43.2-cp313-cp313-win_amd64.whl", hash = "sha256:33fc19aee2145c55d971cbf4b21f6d298b673102716f7ea4818c3b89859dd4dc", size = 683941, upload-time = "2026-08-01T18:11:09.205Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d8/1c7f3dc0df0b59ee2cab442f9936add181f50396413bab5ca366d6601d56/djlint-1.43.2-cp313-cp313-win_arm64.whl", hash = "sha256:25317887d2f00ffc9a0dec1122c2305555d2642a2b31d4341e9472960aa941ea", size = 602018, upload-time = "2026-08-01T18:11:10.268Z" }, + { url = "https://files.pythonhosted.org/packages/04/e9/4baeab9fcb940d192e40ea9ea34395e105bc86df8a1737f15d46105c2d92/djlint-1.43.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ed20fa2ff45cc3e6a7ee10762d1c55adb44726651b909d0fed9ef229cf6b5b88", size = 783339, upload-time = "2026-08-01T18:11:11.674Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b9/46de92b11323bf77983e6a53da2dca45f8ee0f32c2b479be260d6f39fe03/djlint-1.43.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c0838196bf64cb91c9af2b3b767f881e9f202dbfb0b78d2c99b96a77f483abe", size = 738422, upload-time = "2026-08-01T18:11:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/34/ea/51383c99d94dff790a5587e9011dd856e8a765d45f07d55b08cda61f942a/djlint-1.43.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bab0add0c5f8e173ca88156ad47942b96b26cfe2c9eefeca0e2885358b9b7d0e", size = 791625, upload-time = "2026-08-01T18:11:14.321Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/ad185ad8631fbf398c92a922661367dedb8cfbd9b95f688c86ee733a5387/djlint-1.43.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07d21ea1f7255746d6560acf7b2f8c6075bc0fd671d65bc8cfc04c212d7e4278", size = 823617, upload-time = "2026-08-01T18:11:15.524Z" }, + { url = "https://files.pythonhosted.org/packages/e9/47/f9b16f3959288b44497f5770a8d44e6ac7ac94c09c2d7304610bd594c73e/djlint-1.43.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7b867dd145267bac965412e39a0ac29bd08b4ed7c12d47d70a522e76dd8ce9", size = 800945, upload-time = "2026-08-01T18:11:17.068Z" }, + { url = "https://files.pythonhosted.org/packages/13/be/a638305e2d187247e615652037053ccfa5ffc1b44b78468dfc9e92e69996/djlint-1.43.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4c1257246d9cfa203883b00b7a954d1d553e11d3e8aaeecf9eb7ec8526448a91", size = 836916, upload-time = "2026-08-01T18:11:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/cc/eb/3fd34919552f12711d2edcfdb1e8fe599632ef018e4f44157f3918ba3d8e/djlint-1.43.2-cp314-cp314-win32.whl", hash = "sha256:a41bec381a167ac07de9924debc8a6a863749a789ba5af2c5ce43b1a7d9cca8e", size = 611954, upload-time = "2026-08-01T18:11:19.931Z" }, + { url = "https://files.pythonhosted.org/packages/da/4f/9327d4e9f0553e5ecac0e918fd260e1ada0569c601d5bec9f49675b4f72f/djlint-1.43.2-cp314-cp314-win_amd64.whl", hash = "sha256:0552ec07a7f857bcbf10cf924e6d53b75595994bf7c7f7c159f71193e6378bc3", size = 695877, upload-time = "2026-08-01T18:11:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/64/f0/7f2c9f7ab49f768da213cd1ee82008dad7220617b6dc6e0b6f58667e953d/djlint-1.43.2-cp314-cp314-win_arm64.whl", hash = "sha256:f3850e29bc99741f34e7a013443f6272756a1f4ee43f24a5192d6d51d40b88d7", size = 614738, upload-time = "2026-08-01T18:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/07/69/6f8f0d31f2e67f6b34af485f270f6ef3b1c9bcbb1412811566d55e99037d/djlint-1.43.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3b5414a8677757669a8ab2c90a288dfc19091ffb48eb2b7f14430e2321392e3a", size = 847765, upload-time = "2026-08-01T18:11:23.654Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f8/ea398c5226d211ba05cb4c9c2b1cc12ce76850adc5663b22222ea8859ced/djlint-1.43.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7cfed64eace027bfc8c8e4f40fb7236681a6d22f0d341b6db0c3281227286f45", size = 795641, upload-time = "2026-08-01T18:11:24.954Z" }, + { url = "https://files.pythonhosted.org/packages/24/bd/dda3a5659aa4145d63d2463795c8c2c145d98a5445612c6a8c94b4762c14/djlint-1.43.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdc01c9a8a6747ca531ec0d68cb4932f1432781428dbebb88648316627dbbe3c", size = 824096, upload-time = "2026-08-01T18:11:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7f/04abfb3ba5d538dd7954812bc17e00cf0cc9a935bdb53b50f6dd427c8535/djlint-1.43.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:404bf179a80bc79dda9ed0c23378de86cdc16b52fcc0bd5c3c4de08645fe289e", size = 857819, upload-time = "2026-08-01T18:11:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/b0/db/0545f4b9abc72d6a583c07804856794aa12c053b0e76f483250a4be220a4/djlint-1.43.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9334fc05fee895e4df19a28470ae43eee04f079200c5130b183b1df2cfe657b9", size = 833564, upload-time = "2026-08-01T18:11:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/c455f7265ce325e6bba197b13019b832ddd22b1fbda5f6aac51afd737989/djlint-1.43.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1877288f95d3cf6952148cba1775f16203f18b4b4a50903697a31971f9b03e5", size = 870083, upload-time = "2026-08-01T18:11:29.837Z" }, + { url = "https://files.pythonhosted.org/packages/03/2f/78dfdfa4a23301a6a194983fa9dfdadd3d024e5fab917e567259c380a1c2/djlint-1.43.2-cp314-cp314t-win32.whl", hash = "sha256:e2f7a085e7791224a4b4e6810b8f49c73488bceaf3c7cd41bfd6d6c69d775fb3", size = 646299, upload-time = "2026-08-01T18:11:30.953Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e5/7fe1b6e62207af3f27bc023a6a7306c2207639266a9f85c00cba98853477/djlint-1.43.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9c66502e108d4641e70d37b1e1d5b74048e55f4d6e4d28d3fdb95b2c36b5476b", size = 724390, upload-time = "2026-08-01T18:11:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/04/2c/6e22bf4d4429069ba3a4b1e93e709b5d25b95387b4f29faef060c439959a/djlint-1.43.2-cp314-cp314t-win_arm64.whl", hash = "sha256:9df0871c77722707439fe341a4a48e230ca00e1b8287cb4f69a354ebca89b8d1", size = 642444, upload-time = "2026-08-01T18:11:33.305Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/094e5357615e1297c2fa4e3efecafabb36fbe462b4f7c0da216219bc8d82/djlint-1.43.2-py3-none-any.whl", hash = "sha256:e62ae32100f897992f19ac58ff0b67e8d8a13427ffadff327744c56f95ded5eb", size = 92962, upload-time = "2026-08-01T18:11:34.53Z" }, ] [[package]] @@ -1986,11 +1986,11 @@ wheels = [ [[package]] name = "markdown" -version = "3.10.2" +version = "3.10.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, ] [[package]] @@ -2525,43 +2525,43 @@ wheels = [ [[package]] name = "pikepdf" -version = "10.10.0" +version = "10.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lxml" }, { name = "packaging" }, { name = "pillow" }, ] -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" }, +sdist = { url = "https://files.pythonhosted.org/packages/81/f4/786a14218d80727e357d935ed94830090a8bc28eb4a7732ed1b4352f0b43/pikepdf-10.11.0.tar.gz", hash = "sha256:4bebc59ac74411064e7c97a4036174023e22ccbecd22b78750ab76551eb38988", size = 23905547, upload-time = "2026-07-31T08:33:36.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ff/ddfa7fbe048064805228de88d71c73e643020ff1134003ea78b3781a7b5b/pikepdf-10.11.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:ff3bb790373f8799a33f4c36ec955074d34e9b163652dc837263970a93a46152", size = 4464203, upload-time = "2026-07-31T08:32:40.772Z" }, + { url = "https://files.pythonhosted.org/packages/d7/85/47610c84a0925ca149245ba19c0cbbc8bf95430c250eae71e818f7ae3615/pikepdf-10.11.0-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:305e701bcb5c05009e431ec71399e9cc08313baf0f4974f2267db94de087c129", size = 4784008, upload-time = "2026-07-31T08:32:42.683Z" }, + { url = "https://files.pythonhosted.org/packages/b9/56/98d288f064b9f1dee4a103206f8a48a9d5b134fc69a269fb0eaa5501b016/pikepdf-10.11.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1952d7cd75236a18fc85b5a115298635f8cbb6d7d96784af4c490536076b47e4", size = 2033519, upload-time = "2026-07-31T08:32:44.563Z" }, + { url = "https://files.pythonhosted.org/packages/1b/64/dcf45d018a56bf1f91499290c2b9c27f6a3b4abefb60c00bf8b1d4917112/pikepdf-10.11.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:950ed9258afdc618dbcd15b3d12ed37b33a8e7441211e9a04988cbd6a1e0f187", size = 2226785, upload-time = "2026-07-31T08:32:47.004Z" }, + { url = "https://files.pythonhosted.org/packages/40/88/891e54e6e6ed8eca0fe2da6e4a22ced87aae8017d2600297f68c5343f419/pikepdf-10.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4730e06083e391f6301c7e4faa7d150982d7697fcc35c9ea15d50ed1bb416a91", size = 3671669, upload-time = "2026-07-31T08:32:48.798Z" }, + { url = "https://files.pythonhosted.org/packages/ba/03/dccd2bd8bffb6d06339fe0d2e4bcc1d84915d2038c45727f52b10ffd11c7/pikepdf-10.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:22e2d6b61dff4ea8c05a579a70403b88f1d9544f9ba036d8083eeaff064c3534", size = 3870153, upload-time = "2026-07-31T08:32:50.657Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/52ce523f8610e693d51f6eee9888052b9c8b5f60904ddbcf43c7013f41c7/pikepdf-10.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:4c79661ef7e4f0e0a8555dc1be19cc4ae6ffcc3627a07223e01421d26970a6a0", size = 3329363, upload-time = "2026-07-31T08:32:52.434Z" }, + { url = "https://files.pythonhosted.org/packages/9c/55/ec20b468bece1d9d87d97720241bba3aec32ab063495bbb4020e4eef7e16/pikepdf-10.11.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:cd801c8734cd2be03849c199fad5bd74312970ff6e08f2c35138f3906b6b69a9", size = 4464204, upload-time = "2026-07-31T08:32:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/ab/be/526e308f231b72d56cb688575c76d6d43390227f27edc7a4d242f742c472/pikepdf-10.11.0-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:14db784bf380a53bfe0de315aa5e7e9373122988fba1a35966b1a06e53b4835e", size = 4783950, upload-time = "2026-07-31T08:32:56.708Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f8/49fd21c7c60183175608bc88c6d935cb57fd91a008db6b976a80f4a60e5e/pikepdf-10.11.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c39b07540d3d3024f2a056a636e93cad0b9d3f7f511109b0fe6538fe2d3e9c1", size = 2033458, upload-time = "2026-07-31T08:32:58.653Z" }, + { url = "https://files.pythonhosted.org/packages/18/ad/4c631586c2d784c7de954f44d1be94eaccf3aac40a58525622654130ee3f/pikepdf-10.11.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a035f17a959a551e62a42f015ea5f1699a3c2875b4c186cf5fd8bb0db45605b9", size = 2226641, upload-time = "2026-07-31T08:33:00.434Z" }, + { url = "https://files.pythonhosted.org/packages/51/64/f413d834a611af4e2f58202898f0f015935dd77347282e163d80d9b65bb3/pikepdf-10.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49f7f4ef9820205f05254c446e7d069cf9175f78830445f3719edda6c59a6766", size = 3671616, upload-time = "2026-07-31T08:33:02.163Z" }, + { url = "https://files.pythonhosted.org/packages/57/2a/275ad3d343f3f58176913233bdbbd67881b490b733b552db30d2f96bb21c/pikepdf-10.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5335bc453b6f567f582b43d3840ccfdc69689d8194c47f43f59acc264bb6da5a", size = 3869862, upload-time = "2026-07-31T08:33:04.154Z" }, + { url = "https://files.pythonhosted.org/packages/13/a2/8cd5136654bdbf26f44aec5c01db30fce3a481a5998883fdf113dda4549e/pikepdf-10.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:d1e7ca51dae20a1bea0c724a52c380a91e8fc7742b5e98e4aceb8b096aa6c391", size = 3329295, upload-time = "2026-07-31T08:33:06.388Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/02e930d1ab584c33e8a07b5bd87441b67a8feaf129716bbb82d25a1c739b/pikepdf-10.11.0-cp314-abi3-macosx_14_0_arm64.whl", hash = "sha256:5bfbeda5dd259d0b465ee047d361fcea53eb2019a64cf410519ae969e77796df", size = 4464362, upload-time = "2026-07-31T08:33:08.516Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0b/05f1fa2357723dceaa18fbed6de6181c9c7fbcf4a68265c8812e97b3d72e/pikepdf-10.11.0-cp314-abi3-macosx_15_0_x86_64.whl", hash = "sha256:5bbc18c0186c25a150137998b56079ac6aed4b6a5f4621acd1b88760426f6cdf", size = 4784135, upload-time = "2026-07-31T08:33:10.583Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f4/b2c1bf7256a0ade79ce0af4a19835d80ab4f6b7daced455c0efcd8f467c6/pikepdf-10.11.0-cp314-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:856d82bcca70b850cde34a0cdd8356f71e36837e98d24c43e5c2d9abe7b8a598", size = 2031352, upload-time = "2026-07-31T08:33:12.337Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f9/ca351aa3fa93dc77ddd726eef14209cd5d972feb5dacb168a0f387703772/pikepdf-10.11.0-cp314-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ef5b8da7decf393e1134893e0266672d58abf3828241f3f0fc558c3f54e2c2f", size = 2224552, upload-time = "2026-07-31T08:33:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/e9/22/cabe87ad99cce6ff6581343fd378cf1377c2cecda65520ff63d960c4fe4e/pikepdf-10.11.0-cp314-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fe7eba2eb80eeb93c62e52039e51b329d58f984225fafde83fa5be2a925cdc96", size = 3669306, upload-time = "2026-07-31T08:33:15.85Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bf/a22508b1a9973128d023c9c4d65d0fa2db635e9a850df89168914c854fd3/pikepdf-10.11.0-cp314-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1f791b621cfffba5e9feb2d7323a6c9344505e55150924670e17b15c03067350", size = 3867189, upload-time = "2026-07-31T08:33:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/05/25/e4497a41fa87cf3a6bbf2ebfba9d685fddcf3e5144071a7712025af54bc6/pikepdf-10.11.0-cp314-abi3-win_amd64.whl", hash = "sha256:e8805ef426e7b79707abe5536cc89d4ecc5c3d0778f66ac191f6696568042835", size = 3422949, upload-time = "2026-07-31T08:33:19.915Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a8/542d8fbd32d746f99561ebabeb1769a386827b3d8dacf25b56dfa94c9f99/pikepdf-10.11.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:82b188d829ac00e036182c70e2c10d82accd49d64f4ae60e3d13207e0ed78fe4", size = 4469912, upload-time = "2026-07-31T08:33:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/ecc4a12d6f8e9ccaefee6510e9e847f45ea5d1a495d8697e663516613075/pikepdf-10.11.0-cp314-cp314t-macosx_15_0_x86_64.whl", hash = "sha256:888f6fe890c71f7d5650e002f992e7dcb92db224e89d5ed62efe71df7bfcee94", size = 4790760, upload-time = "2026-07-31T08:33:23.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/24c94369953d9adcb3e72265b9a06fae1b26f9231bd38653d4232a666f72/pikepdf-10.11.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31a52619eb627a0281a8425c7c2b9164b277a175b1929ba7496a63d3ca518b8c", size = 2037817, upload-time = "2026-07-31T08:33:25.631Z" }, + { url = "https://files.pythonhosted.org/packages/ab/17/015a3c9a3384eaa9c6bf231d31e4559188e55c45780f1805941823e0dc3c/pikepdf-10.11.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:832302313a461cc4448b2e5e11fdbd2f3b775798f02f58614a53d2788fda1e37", size = 2229789, upload-time = "2026-07-31T08:33:27.39Z" }, + { url = "https://files.pythonhosted.org/packages/6c/85/0389e514fcd121111d1f93659162285a3dc468b79e54d6210e4d13a4009c/pikepdf-10.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:94c7e095310dcd427579b7fbd95bca6e305ee56ea05b9941385347884d51a861", size = 3675762, upload-time = "2026-07-31T08:33:29.429Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/755d8c681b7eb843a6c91bb3bb29e3c5466014f8321f4f8dbe1cf6b51aad/pikepdf-10.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9bea62aae8ca4abac78058f836de4e5aa8b55cd1b7e6b8e1f1e765a5b14f190d", size = 3874853, upload-time = "2026-07-31T08:33:31.893Z" }, + { url = "https://files.pythonhosted.org/packages/b6/79/2426c7174d93da05da93f7054f1d0e5827431f2c56e6eb3380c1effb013f/pikepdf-10.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c0e56ac4fd10844c85effe5362b7740002d03c2f9f0647029304588ba4115def", size = 3455128, upload-time = "2026-07-31T08:33:33.72Z" }, ] [[package]] @@ -3748,27 +3748,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, - { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, - { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, - { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, - { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, - { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, - { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, - { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, - { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, - { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, - { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, ] [[package]] @@ -4107,27 +4107,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.64" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/aa/14c9965d3b173105692473897cc89c34cd91241368b2044e43167e1c17ff/ty-0.0.64.tar.gz", hash = "sha256:d12ddbb05f15158bb518af619378b385486450def95fb06f8ab98037febe9f2c", size = 6350966, upload-time = "2026-07-27T18:32:45.403Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/4c/c54937e4ff3fa7b34a99ea3387ec766bf0ad98dc8df8d792e89b388e658e/ty-0.0.64-py3-none-linux_armv6l.whl", hash = "sha256:3830a6675ab43635ced1c4c557f380ac4a49e9414e03975e4e4e8db644c64944", size = 12118357, upload-time = "2026-07-27T18:32:08.702Z" }, - { url = "https://files.pythonhosted.org/packages/ce/aa/a839ee2bc78e943d079e6abe199a97b4eeffb7e5c9a57326d69de452186a/ty-0.0.64-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3ff07d7bc32a2135f58afe57393789a32ea2fed1a66216129a8e535e76043903", size = 11790882, upload-time = "2026-07-27T18:32:10.997Z" }, - { url = "https://files.pythonhosted.org/packages/4c/de/19f14357888a7198438926303753cf749428e3d62e8980ff1e9a72a78402/ty-0.0.64-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4f6d1c7f897cca05d12bacbf1435150d5ffa496099515aa6ed303c8b29e1d0bb", size = 11317394, upload-time = "2026-07-27T18:32:13.162Z" }, - { url = "https://files.pythonhosted.org/packages/08/2f/f54462300535ab99b551eda733177be2eef5dbc2997d3fdb357c4ddd760a/ty-0.0.64-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:138d6c37ad4bf8583aa7a9b29d90954151d7856f9910a03eae3eb34b34c57215", size = 11863042, upload-time = "2026-07-27T18:32:15.307Z" }, - { url = "https://files.pythonhosted.org/packages/5e/95/dbecf745520ebe8bd7b02fc55eee6441c9be312ebf6addce605eb52740dd/ty-0.0.64-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ed9719d1b7b66fb8efe073d860208a44c40af1f6cd5c2364aa9b323a1e579b4", size = 11910730, upload-time = "2026-07-27T18:32:17.467Z" }, - { url = "https://files.pythonhosted.org/packages/3b/26/12cfd40028e51ceed7b3cb645281c61c02eb64ff9fb0c09231d65c30ff25/ty-0.0.64-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68b23e5169e2137b5f1de7169ab0cebecab6c8eda374c34c1f6394308f58242", size = 12631936, upload-time = "2026-07-27T18:32:19.533Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d0/65ffc2b0a686347193c6f98e9421a7fc2a96fc3cd0b1001cf7cf284baff9/ty-0.0.64-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f41cb07d89d32626fcaf3ed4d262778fcb28b2628b6ed1e172cd7b18820668d8", size = 13171049, upload-time = "2026-07-27T18:32:22.026Z" }, - { url = "https://files.pythonhosted.org/packages/f6/a4/975a5961842dcd6fa60f0770a102c0bba7509da909ab652919bfcdcd4fc7/ty-0.0.64-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5f24e1504ab9e212f92356b82fe088fdeb3a39f9a2f4ff25e505d2e1d0db9056", size = 12826438, upload-time = "2026-07-27T18:32:24.178Z" }, - { url = "https://files.pythonhosted.org/packages/af/ef/dfb9b7f9bcc032d3b540b0d1f55f532a336e2fb41b1bd539c05ae81a151a/ty-0.0.64-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86db830cb914bb33bb8247b66ccea58de4496c2391bd42658aba744b439f3290", size = 12440880, upload-time = "2026-07-27T18:32:26.341Z" }, - { url = "https://files.pythonhosted.org/packages/ee/d0/bedac20505e8a8f5501ad73d7d15d8e421a563fef59993909a23036929a4/ty-0.0.64-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:652ee3d6d03bea76cd2fe8949c78bb5970394ebd7c5fd270e9518c0dee1b931d", size = 12782439, upload-time = "2026-07-27T18:32:28.642Z" }, - { url = "https://files.pythonhosted.org/packages/79/d5/795733f13ceff1378f08b3de0c49d0f518df220ed856b0dfac869f3b7c81/ty-0.0.64-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4838768295774a86e95f9ec5633e739d016adbbb69dcbdc62f3549578a11f624", size = 11814821, upload-time = "2026-07-27T18:32:30.632Z" }, - { url = "https://files.pythonhosted.org/packages/52/3e/9d99cd1e1831003434f508ed9f258a56543194afc3bbe051eba2545fa676/ty-0.0.64-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5a3d700669868599edf39ce5125196682f15a8880cc8c669bc2a83b99984d99b", size = 11928678, upload-time = "2026-07-27T18:32:32.888Z" }, - { url = "https://files.pythonhosted.org/packages/b8/3d/448f49a3503fb119a34348a5714bb92001f252fadbbb12d645f2e744b557/ty-0.0.64-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b161f0a82a8e2f2432db3bf7702b4d3924fa9486ba0014f6710a160fc157df0d", size = 12202249, upload-time = "2026-07-27T18:32:34.905Z" }, - { url = "https://files.pythonhosted.org/packages/dd/9b/75768e562cec990d189dc05807ae72890b20ffbd1e1f43597bb98db63c60/ty-0.0.64-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:39b9dd42908df47c2dc57dda87e656fab97097ffd2618474bbdea986af0d6a9d", size = 12548817, upload-time = "2026-07-27T18:32:36.995Z" }, - { url = "https://files.pythonhosted.org/packages/34/89/44cc276ea6ca0245495014758ffeadde1798fb0b5840c9cf36d8d2ed3250/ty-0.0.64-py3-none-win32.whl", hash = "sha256:d0676ab0e0935795e5843baa28dd5e366dc343d7c0945a996df9eab8e0644885", size = 11545474, upload-time = "2026-07-27T18:32:39.389Z" }, - { url = "https://files.pythonhosted.org/packages/01/7e/d1c8a871a38d17c8f168b9a6975f6247f7660f8334e517656a5e4b4a4858/ty-0.0.64-py3-none-win_amd64.whl", hash = "sha256:dcb9bd31f54097e362b776c26ab4564d4564cdd1355cb883481167a26c03cc3f", size = 12542987, upload-time = "2026-07-27T18:32:41.412Z" }, - { url = "https://files.pythonhosted.org/packages/35/4d/6d18640d0204cacd69abbaca95ad6a34c6d7e9169e9051d0117b17b827ec/ty-0.0.64-py3-none-win_arm64.whl", hash = "sha256:82cc34c1ad9a8feb6059aef193bebcecc656e548f6fab3d518bcd8b57d198d39", size = 11899263, upload-time = "2026-07-27T18:32:43.366Z" }, +version = "0.0.65" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cf561927e8e9ab5c1892a833b664aa9cd6f051a75f6280c66d8047246bda/ty-0.0.65.tar.gz", hash = "sha256:b7134bffcc00b715fa8291e84d845782ced810a998dc1f7f11d71c85c4046325", size = 6460098, upload-time = "2026-07-29T18:31:03.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/4e/71e2d325d2b53a1afad81624ad076b2ede413213fc4a18cb05b78c568571/ty-0.0.65-py3-none-linux_armv6l.whl", hash = "sha256:dc556c9f05408bef4c4ef02b2cc382e4e5f797b4b20d64410289848f0d76705f", size = 12298466, upload-time = "2026-07-29T18:30:12.744Z" }, + { url = "https://files.pythonhosted.org/packages/57/77/fec8f29647c55794efa430a7f365e44f5ce7ffb6459d9445a87fac569bec/ty-0.0.65-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:29d2e0d34cc0a28a17ef0cf81135c5ebabc3562131f9079138ba5e7bae0f56bd", size = 11942421, upload-time = "2026-07-29T18:30:16.076Z" }, + { url = "https://files.pythonhosted.org/packages/13/09/7f3766aef9dc627e2698cf4e3e59cf53389dcae3812040d33c1aa931230f/ty-0.0.65-py3-none-macosx_11_0_arm64.whl", hash = "sha256:685f49a9312bbf69d5b65bbb66384fed1f927403ea030c217b9289092d7e46c4", size = 11451922, upload-time = "2026-07-29T18:30:19.155Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/1a77cd50e0befb50f55b8bf9bd3ed3eddf184bf28c61b56727039e0774fc/ty-0.0.65-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f564b5ebe78e2f3a8e7b8eacb1292eb88b7c0f3c8630671cfca31abc0709cd9", size = 11994999, upload-time = "2026-07-29T18:30:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/7b/feda16f3a4a0a99be27431e0c9598eeeec0db1eb2fec9a15976698209418/ty-0.0.65-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c983e156fe9e113fb56389e13d327b6b8549fe866de9b269684723a88e9b732d", size = 12090662, upload-time = "2026-07-29T18:30:24.93Z" }, + { url = "https://files.pythonhosted.org/packages/ed/3e/3f69bf9c9307dbdc0771719f65ce5b556e7bdeeaccbdd599d4f57866d801/ty-0.0.65-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3663b7396e8b1a9954e20e732de7ccb0192bf4118473069b4945920d6923921", size = 12822094, upload-time = "2026-07-29T18:30:28.012Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/8fa791b3bb503ee2b46ad81690cd1bdd54519582df6d805cee57fe143e85/ty-0.0.65-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:306ed01f29d6e108e98feb233dbbf5878a027603b71bd3743b343977933a9f16", size = 13357833, upload-time = "2026-07-29T18:30:31.122Z" }, + { url = "https://files.pythonhosted.org/packages/c1/73/4dda396a201e1dd0ed3594a9b48e559cb41c4bc048c6cd4c4d1b39eb4313/ty-0.0.65-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28bcfc8898c94f079a9100e684bcf312b6a64ad3a7d4ebb35a4591546030a2cd", size = 12977303, upload-time = "2026-07-29T18:30:33.944Z" }, + { url = "https://files.pythonhosted.org/packages/a5/26/c250c2c569adc53a8591716641388397bcb2a442e4a30b952ae81b50c0e0/ty-0.0.65-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a75bd0c245c38802a8f488378e74f92feb7dd33db7d63fbdd6fdf82791ba730", size = 12579338, upload-time = "2026-07-29T18:30:37.199Z" }, + { url = "https://files.pythonhosted.org/packages/d3/94/4a5647d44753ca218fc930d7e4d9bf468d0ed4a0ad4b3d57588bc1bbacf7/ty-0.0.65-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9e5e1bdea9662d2b5312b4e99f319f4e6e2ea427511b5fbc546141b79ec53f76", size = 12957731, upload-time = "2026-07-29T18:30:39.937Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/1e22fa11a1e0dfb20b1c7f3cbfd8170273aada2a82f9ecd3055275370c44/ty-0.0.65-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:03a88493d4842889f65280ae241e06b399d57eb3c63571054cad21a4c33b3b69", size = 11938625, upload-time = "2026-07-29T18:30:42.603Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0a/fe5f22ef62b193201bc5566762e22049762cd485bfafb5095a7050760054/ty-0.0.65-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:600b8bf6f4940cf7ffb2f43d3716faaf38dcb97cd8617c55771451bc0276408f", size = 12105592, upload-time = "2026-07-29T18:30:45.419Z" }, + { url = "https://files.pythonhosted.org/packages/76/fd/922b3a6e9d697452cdbb4b7e3f636868add5ec652154518a736e4364f3b7/ty-0.0.65-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c28007bc79d648c1ddaf1e65885d07baec48eb87240da442f608e4107c1b7d8", size = 12387335, upload-time = "2026-07-29T18:30:48.405Z" }, + { url = "https://files.pythonhosted.org/packages/77/22/a1a08ebc84c083db2fb55e3b5cd186db0c067692f4921146f601360231e2/ty-0.0.65-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c852da96091ad22361e6586b7c7ba98e1334dcd4d8ffb67e47f4fb673de33f77", size = 12682710, upload-time = "2026-07-29T18:30:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/eaaa410a25bbdea19722109b5422380a0e211b3afcf3071d15953ddbd5db/ty-0.0.65-py3-none-win32.whl", hash = "sha256:cf529d538f1403b14b0511e6ec3cdb95d3d974adabf24cc76cedc533368c3edc", size = 11692341, upload-time = "2026-07-29T18:30:54.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0f/6d48f206dce9d7e53fe3b5ea0f0ab5800dd9d2365b2b48f736783436c43f/ty-0.0.65-py3-none-win_amd64.whl", hash = "sha256:234a321e33c7cbbfbd67bfa0b01b685dd9c21f1841781a21e5ca1fa0b25f1d5d", size = 12729355, upload-time = "2026-07-29T18:30:57.275Z" }, + { url = "https://files.pythonhosted.org/packages/96/aa/7446f7725e303cf78e058c893af1f0552b9451895454908706f4c6c3494b/ty-0.0.65-py3-none-win_arm64.whl", hash = "sha256:b9424be1ec56d93ff18609fb1c0a0a2283fe1282cd6d1c7604f97d73b94d61f2", size = 12051375, upload-time = "2026-07-29T18:31:00.579Z" }, ] [[package]]