From 33a0e6c987c4819fa234971b43cb76eb8e512f69 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Fri, 9 Oct 2026 15:04:59 -0700 Subject: [PATCH 1/3] CLAUDE.md: document the C2PA soft-binding snapshot check in the gate Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 92dabab..2491652 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ Optional features and installation groups are documented in [`docs/installation. `maintain.sh` runs dependency freshness and security checks, Ruff, Pyright scoped to `src/`, and the parallel test suite. Full-project Pyright is not the project gate because the ML dependency graph can exhaust Node memory. -The gate does not pass `--ignore-unfixed` to uv-secure, so a transitive CVE with no released fix stops it before Ruff, Pyright and the tests run. This matches the global rule: run and report those three separately. Scanner failures are fatal too. Triaged blockers and the recheck procedure: `Known security-gate blocks` in [`docs/development.md`](docs/development.md). +The gate does not pass `--ignore-unfixed` to uv-secure, so a transitive CVE with no released fix stops it before Ruff, Pyright and the tests run. Scanner failures are fatal too. The gate also runs `scripts/sync_c2pa_soft_bindings.py --check`, which fails when the packaged C2PA soft-binding registry snapshot is stale; rerun it without `--check` to refresh the snapshot from upstream. Triaged blockers and the recheck procedure: `Known security-gate blocks` in [`docs/development.md`](docs/development.md). Command, gate, typing, and model-test invariants auto-load from [`.claude/rules/development.md`](.claude/rules/development.md). Environment recovery, CI behavior, and fixture policy live in [`docs/development.md`](docs/development.md). From 7e167c3c762af84a574eb331fd89ade1694183ed Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Mon, 19 Oct 2026 11:23:59 -0700 Subject: [PATCH 2/3] Fail closed on unreadable inputs, empty batches and failed strips The metadata scanners read raw bytes, so random bytes, an empty file or a truncated PNG or MP4 printed the same "No AI metadata found" or "AI metadata stripped" a real file gets, with exit 0. `metadata.unreadable_reason` is now the one readability probe: `identify`, `metadata` and every `video` command exit 1 on such input, and `batch` and `video batch` fail its row. `strip_and_verify` refuses it before writing anything. A `batch` or `video batch` directory with no supported file exits 2 and creates no output directory. `visible` and `erase` strip and verify on a staged sibling and publish nothing when a marker survives, as the library `remove_visible` now does; the diffusion writer raises on a surviving marker. An invisible engine that cannot start exits 1 without a traceback, and the MCP server reports a child that could not start. NotebookLM and Gemini slide-deck templates are recorded as not planned. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/development.md | 4 +- docs/cli.md | 5 +- docs/known-limitations.md | 5 +- docs/mcp-server.md | 5 +- docs/module-internals.md | 28 +- docs/python-api.md | 12 +- docs/verification-plan.md | 4 +- docs/watermarking-landscape.md | 4 +- .../assets/visible-mark-candidates/README.md | 2 +- skills/remove-ai-watermarks/SKILL.md | 8 +- .../references/commands.md | 4 +- src/remove_ai_watermarks/_internal/ebml.py | 14 +- src/remove_ai_watermarks/_internal/utils.py | 20 ++ .../_internal/watermark_remover.py | 7 +- src/remove_ai_watermarks/api.py | 35 +- src/remove_ai_watermarks/cli.py | 122 ++++--- src/remove_ai_watermarks/mcp_server.py | 11 +- src/remove_ai_watermarks/metadata.py | 98 +++++- src/remove_ai_watermarks/video.py | 13 +- tests/test_agent_skill.py | 2 +- tests/test_cli.py | 19 +- tests/test_fail_closed.py | 311 ++++++++++++++++++ tests/test_mcp_server.py | 2 +- tests/test_metadata.py | 17 +- 24 files changed, 636 insertions(+), 116 deletions(-) create mode 100644 tests/test_fail_closed.py diff --git a/.claude/rules/development.md b/.claude/rules/development.md index cb36882..905dfb6 100644 --- a/.claude/rules/development.md +++ b/.claude/rules/development.md @@ -17,7 +17,7 @@ description: Command contracts, project gate, typing boundaries, model-adjacent Every single-image command declares `source` with `dir_okay=False`; `batch` declares its directory with `file_okay=False`. Keep `tests/test_cli_robustness.py::TestDirectoryInputIsRejected` as the regression guard. -Exit-code and no-signal behavior is a public contract. Read the command-line section of [`../../docs/module-internals.md`](../../docs/module-internals.md) before changing it. Keep [`../../skills/remove-ai-watermarks/`](../../skills/remove-ai-watermarks/) in the same change when command routing, extras, exit codes, mark keys, or the intended-use boundary move. +Exit-code and no-signal behavior is a public contract. Read the command-line section of [`../../docs/module-internals.md`](../../docs/module-internals.md) before changing it. The dev `.venv` always carries the pixel stack, so it cannot show what the default install does. To check a no-signal or fail-closed change there, build a throwaway venv outside the repository (`uv venv /tmp/bare && VIRTUAL_ENV=/tmp/bare uv pip install .`) and run `/tmp/bare/bin/remove-ai-watermarks` against it. Keep [`../../skills/remove-ai-watermarks/`](../../skills/remove-ai-watermarks/) in the same change when command routing, extras, exit codes, mark keys, or the intended-use boundary move. `tests/test_agent_skill.py` enforces that by comparing the skill's prose against the live Click tree. Guard the SET, not the instances: per-flag comparisons only check what the skill already documents, so `--vendor`, `--pipeline auto` and then the whole `classify` and `verify-openai-synthid` commands each shipped with a green suite. `test_every_cli_command_is_named_somewhere_in_the_skill` closes that; raise `MIN_CLI_VERSION` in the skill's probe whenever a reference names a command, flag or value the previous release lacked, or the probe tells an agent its CLI is current right before it types something that build rejects. @@ -92,7 +92,7 @@ procedure in `docs/development.md`; do not revive the old text-matching bypass. Boundary modules for cv2, Torch, and Diffusers may carry narrow per-file relaxations for unknown third-party types. Keep pure-logic files strict, preserve the local piexif stub, and fix real errors before widening a pragma. -From a worktree, `uv run` imports the package from the MAIN checkout -- that is where the editable install points. A script measuring a worktree's edit must insert that worktree's `src` at `sys.path[0]` and assert `module.__file__` resolves inside it, or it silently compares unmodified code against itself. +From a worktree without its own `.venv`, `uv run` imports the package from the MAIN checkout, because that is where the editable install points; a worktree with its own `.venv` imports its own `src`. Either way, a script measuring a worktree's edit must assert `module.__file__` resolves inside that worktree (insert its `src` at `sys.path[0]` when it does not), or it silently compares unmodified code against itself. ## Research harnesses stay out of this repository diff --git a/docs/cli.md b/docs/cli.md index ff9ee7a..a414493 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -705,8 +705,9 @@ the tool contract are in the [MCP server guide](mcp-server.md). The CLI uses nonzero exit codes for meaningful incomplete outcomes, including no detected target on commands that would otherwise regenerate or create a -misleading unchanged result, processing errors, and a required invisible step -that could not run. +misleading unchanged result, a batch directory with nothing to process, +processing errors, an input that is not a readable image or video, and a +required invisible step that could not run. Scripts should check the process exit code and the output path. The detailed per-command contract is maintained in diff --git a/docs/known-limitations.md b/docs/known-limitations.md index dd08ab3..3a59eac 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -621,8 +621,9 @@ stream safely. raising. User facing callers must use `strip_and_verify` and inspect its surviving marker mapping before reporting success. `strip_and_verify` recovers when `image_io` can still decode the raster by normalizing the container and -checking again. A truly undecodable file still reports the surviving markers. -The CLI uses this verified path. +checking again. A truly undecodable file raises `ValueError` before anything is +written (since 2026-10-18; it used to be copied through with its surviving +markers). The CLI uses this verified path. ### Sixteen bit PNG metadata stripping diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 9812432..d28298b 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -58,7 +58,10 @@ writes over an input: an `output` equal to the source, or an `output_dir` equal to the input directory, is refused before anything runs. Exit codes keep their CLI meaning. `0` and `2` are results, not tool errors, and -`2` means no supported signal was found, which is unknown rather than clean. +`2` means no supported signal was found, or a `batch` directory held nothing to +process, which is unknown rather than clean. An +input that is not a readable image or video exits `1`, so `identify` on it is a +tool error even though the command still printed a report. Click also exits `2` on a usage error such as a missing file; its `Usage:` line on stderr marks that case as a tool error. Every other nonzero exit code is returned with `isError: true` and the command's stderr. diff --git a/docs/module-internals.md b/docs/module-internals.md index c3bdc91..6298238 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -50,7 +50,28 @@ Important contracts: It decodes the source before the scrub gate and exits `1` when it cannot, because the gate reads undecodable bytes as "no target" and would otherwise report the no-signal code for a file it never read (fixed 2026-10-17). -- The two no-signal conditions currently share exit code `2`. +- The two no-signal conditions currently share exit code `2`, and so does a + `batch` or `video batch` directory with no supported file, which writes + nothing (decided 2026-10-18). +- An input that is not a readable container exits `1` from `identify`, + `metadata` and every `video` command, and fails its row in `batch` and `video + batch` (fixed 2026-10-18). The metadata scanners read raw bytes, so random + bytes, an empty file or a truncated PNG or MP4 used to print the same "No AI + metadata found" or "AI metadata stripped" a real file gets, with exit `0`. + `metadata.unreadable_reason` is the one probe: rasters must decode in Pillow or + OpenCV, ISOBMFF boxes and EBML elements must tile the file, and a RIFF file must + hold its declared size. `strip_and_verify` and `video._video_source` ask it, and + `identify` prints its report first, so an AI signal read from a truncated file + is still shown. Absent optional detectors are not unreadable input: without the + pixel stack `identify` keeps exit `0` and its caveat (decided 2026-10-18). + Regression coverage: `tests/test_fail_closed.py`. +- `visible` and `erase` strip and verify AI metadata on a staged sibling and exit + `1` with nothing published when a marker survives; they used to swallow a failed + strip unless `--verbose` was set. The library `remove_visible` does the same and + raises `MetadataStripIncomplete`, as `remove_all` already did, and the diffusion + writer behind `invisible` raises when its own strip leaves a marker. + `_internal.utils.staged_sibling` is the shared stage: it never pre-creates the + name, so the published file keeps the default permissions. - A quiet `metadata --check` and a successful `metadata --remove` (same for `video metadata`) end by repeating the `identify` limit: the pixel channel is untouched and a watermark such as SynthID has no local decoder once its @@ -241,7 +262,8 @@ as `workflow` and `prompt` uses the same structural walk; removal blanks the complete key and value in place. This preserves every box size, `stco`/`co64` offset, encoded stream byte apart from the Kling SEI tag below, and source-sized memory bound. -Publication is atomic, and a malformed top-level walk is copied unchanged. A +Publication is atomic, and a malformed top-level walk is copied unchanged, +although `strip_and_verify` refuses such a source before the stripper runs. A generic `AIGC` key whose value has no TC260 field is ignored. App-export JSON in a keyed or keyless metadata item is blanked the same way. @@ -822,7 +844,7 @@ Key contracts: written output for surviving markers. If the metadata-preserving decoder rejected the container but `image_io` can still decode its raster, `strip_and_verify` normalizes that raster and scans again. A truly undecodable - file keeps the surviving-marker result. + file raises `ValueError` before anything is written. Detection and removal must stay in parity. A new marker is incomplete until the scanner can find it, the remover can reach every supported placement, and a diff --git a/docs/python-api.md b/docs/python-api.md index 69cbc14..ff6751c 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -79,7 +79,8 @@ For a path input, `remove_visible` and `remove_visible_detailed`: - reads metadata provenance for the default `auto` sensitivity; - preserves a separate alpha channel; - writes the output when an output path is supplied; -- strips AI metadata from the written output by default; +- strips AI metadata from the written output by default, and raises + `MetadataStripIncomplete` without publishing it when a marker survives; - preserves the original bytes for a same-format no-op copy. ```python @@ -226,8 +227,9 @@ The complete field set, with the shipped defaults: and not to this table, because this seam had already drifted by eight fields before anything checked it. -If AI metadata survives the strip, `remove_all` raises `MetadataStripIncomplete` -**before** writing anything: an AI-readable output is worse than no output. +If AI metadata survives the strip, `remove_all` and a path-input `remove_visible` +raise `MetadataStripIncomplete` **before** publishing anything: an AI-readable +output is worse than no output. `remove_batch` runs one mode over a directory and never lets a single bad file end the run: @@ -525,7 +527,9 @@ It checks the written output and returns `(output_path, surviving_markers)`. When the first strip leaves markers in a malformed but raster-decodable image, it normalizes the container through `image_io` and checks again. That recovery path preserves the pixels but drops standard metadata. Treat a nonempty -`surviving_markers` mapping as a failure. +`surviving_markers` mapping as a failure. A source that is not a readable image, +video or audio container raises `ValueError` before anything is written; +`metadata.unreadable_reason(path)` returns the same reason without stripping. `remove_ai_metadata` is the lower level fail-safe transformer. It may copy an undecodable input through unchanged, so its return alone must not be presented diff --git a/docs/verification-plan.md b/docs/verification-plan.md index 6fd704f..87ca6fe 100644 --- a/docs/verification-plan.md +++ b/docs/verification-plan.md @@ -92,8 +92,8 @@ the row count is host-dependent rather than the fixed 68 recorded here before. E (~500 images spanning format x provenance x aspect ratio), asserting exit-code semantics rather than just absence of crash. -Known trap to encode: **exit 2 is triply overloaded** (no-visible-mark, no-invisible-signal, -Click usage error). A wrapper cannot distinguish them without parsing stderr. Either the +Known trap to encode: **exit 2 is overloaded four ways** (no-visible-mark, no-invisible-signal, +empty batch directory, Click usage error). A wrapper cannot distinguish them without parsing stderr. Either the sweep asserts on stderr, or the codes get split -- the latter is the better fix. #### Coverage method diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index 0d761d7..5c3cbc0 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -139,8 +139,8 @@ against primary sources before adding jurisdiction-specific claims. is added to generated images and videos "to further help you identify AI-generated content", and AI Pro and Ultra subscribers can turn it off. That makes it an AI-generation label under the [project scope](legal-and-safety.md#out-of-scope). No -template is registered, because no publication-cleared export has been captured; -slide decks export as PDF or PPTX, which the project does not process. +template is planned (decided 2026-10-18), so the mark stays undetected; slide +decks export as PDF or PPTX, which the project does not process. **Google Veo video marks use two incompatible visible designs.** Public raw clips verify the current four-point diamond and the legacy bottom-right `Veo` diff --git a/scripts/assets/visible-mark-candidates/README.md b/scripts/assets/visible-mark-candidates/README.md index dcc5830..cb40958 100644 --- a/scripts/assets/visible-mark-candidates/README.md +++ b/scripts/assets/visible-mark-candidates/README.md @@ -22,7 +22,7 @@ from the provider's canonical name, the provider is named separately. | --- | --- | --- | --- | | `samsung_en_alpha.png` | Samsung Galaxy AI label, English locale ("AI-generated content", bottom-left) | 5 corpus files | POS 0.11-0.30 vs NEG max 0.40 (binary); tophat/gray tried, no separation. The registered Italian engine scores 0.18-0.31 on the same files -- same layout class, wrong glyph template. | | `gemini_text_alpha.png` | "Generated with Gemini" text label (bottom-right; the registered gemini mark is the sparkle icon) | 3 corpus files | POS 0.07-0.22 vs NEG max 0.32; coverage gate finds the blob (0.29-0.35) but the Arial silhouette misses Google's letterforms. | -| `notebooklm_alpha.png` | NotebookLM wordmark (bottom-right) | 12 corpus files | locate geometry not yet fitted; POS max 0.12. | +| `notebooklm_alpha.png` | NotebookLM wordmark (bottom-right; the product is now Gemini Notebook) | 12 corpus files | parked: a template is not planned (decided 2026-10-18). Last measured POS max 0.12, locate geometry never fitted. | | `mindvideo_alpha.png` | MindVideo.AI (top-right) | 11 corpus files | POS 0.29-0.32 vs NEG max 0.30 -- borderline overlap, not shippable. | | `higgsfield_alpha.png` | `HIGGSFIELD AI` wordmark (Higgsfield AI, bottom-right; the boxed `AI` variant shares the cohort) | 5 wordmark files (16 in the boxed-AI OCR cluster) | POS max 0.26 vs NEG max 0.22 -- no separation; the mark may be two-part (wordmark + boxed AI) and needs a composed template. | | `jianying_alpha.png` | `剪映AI` wordmark (Jianying / 剪映, the China product in the CapCut family; bottom-right) | 2 corpus files | POS 0.29 vs NEG max 0.35. | diff --git a/skills/remove-ai-watermarks/SKILL.md b/skills/remove-ai-watermarks/SKILL.md index 080628a..de29ceb 100644 --- a/skills/remove-ai-watermarks/SKILL.md +++ b/skills/remove-ai-watermarks/SKILL.md @@ -157,8 +157,8 @@ Read the process exit code and whether `-o` exists. | Exit | Meaning | What to tell the user | | --- | --- | --- | | 0 | Requested work completed | The output path. Do not add "this is clean". | -| 2 | No targeted signal | Unknown, not clean. No new file from `visible`. | -| 1 | Error or partial `all` | Quote the error. Say which stage ran, if any. | +| 2 | No targeted signal, or a `batch` directory with no supported file | Unknown, not clean. No new file from `visible`. | +| 1 | Error, unreadable input, or partial `all` | Quote the error. Say which stage ran, if any. | Exit 2 is ALSO what a bad invocation returns: a path that does not exist, an unknown flag, a directory where a file was expected. Read the output before @@ -167,7 +167,9 @@ means the command never ran, so fix it and rerun; only a command that reached the image can say "no targeted signal". `identify` with no signal reports origin unknown. Never rephrase that as "no -watermark" or "this is clean". +watermark" or "this is clean". When `identify` or `metadata` exits 1 with +`cannot read`, the file is damaged or not an image or video: any report above +that line is incomplete, so say the file could not be read. `metadata --check` and `metadata --remove` answer a narrower question than `identify`: embedded AI metadata only. Both print that this is not a clean diff --git a/skills/remove-ai-watermarks/references/commands.md b/skills/remove-ai-watermarks/references/commands.md index f4e1938..6e10f6f 100644 --- a/skills/remove-ai-watermarks/references/commands.md +++ b/skills/remove-ai-watermarks/references/commands.md @@ -150,8 +150,8 @@ commands above directly. | Code | Meaning | | --- | --- | | 0 | Requested work completed | -| 2 | No targeted signal (`visible` / no-signal `invisible`) | -| 1 | Hard failure, or `all` skipped a required invisible stage | +| 2 | No targeted signal (`visible` / no-signal `invisible`), or a `batch` directory with no supported file | +| 1 | Hard failure, an input that is not a readable image or video (`identify` and `metadata` included), or `all` skipped a required invisible stage | A written file plus exit `1` from `all` is a partial result. diff --git a/src/remove_ai_watermarks/_internal/ebml.py b/src/remove_ai_watermarks/_internal/ebml.py index d5a2c21..fd303dd 100644 --- a/src/remove_ai_watermarks/_internal/ebml.py +++ b/src/remove_ai_watermarks/_internal/ebml.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from remove_ai_watermarks.metadata import MAX_TC260_VALUE_BYTES, parse_tc260_aigc_json -_EBML_MAGIC = b"\x1aE\xdf\xa3" +EBML_MAGIC = b"\x1aE\xdf\xa3" _SEGMENT_ID = 0x18538067 _TAGS_ID = 0x1254C367 _TAG_ID = 0x7373 @@ -95,6 +95,16 @@ def _iter_elements( pos = element_end +def iter_top_level_ends(stream: BinaryIO, size: int) -> Iterator[int]: + """End offset of each valid top-level element; stops early where a header fails. + + An element whose declared size runs past ``size`` fails its header, so a truncated + file yields a last end short of ``size``. An unknown-size Segment runs to ``size``. + """ + for _element_id, _payload_start, element_end in _iter_elements(stream, 0, size): + yield element_end + + def _read_bounded( stream: BinaryIO, start: int, @@ -133,7 +143,7 @@ def tc260_aigc_payloads(path: str | Path) -> tuple[bytes, ...]: found: list[bytes] = [] try: with open(path, "rb") as stream: - if stream.read(4) != _EBML_MAGIC: + if stream.read(4) != EBML_MAGIC: return () stream.seek(0, 2) file_size = stream.tell() diff --git a/src/remove_ai_watermarks/_internal/utils.py b/src/remove_ai_watermarks/_internal/utils.py index 63164dd..04ef256 100644 --- a/src/remove_ai_watermarks/_internal/utils.py +++ b/src/remove_ai_watermarks/_internal/utils.py @@ -2,11 +2,15 @@ from __future__ import annotations +import contextlib +import os +import secrets from typing import TYPE_CHECKING from remove_ai_watermarks._internal.constants import JPEG_SUFFIXES, SUPPORTED_FORMATS if TYPE_CHECKING: + from collections.abc import Generator from pathlib import Path @@ -22,3 +26,19 @@ def get_image_format(file_path: Path) -> str: inputs therefore use its PNG fallback, matching the established API contract. """ return "JPEG" if file_path.suffix.casefold() in JPEG_SUFFIXES else "PNG" + + +@contextlib.contextmanager +def staged_sibling(output: Path) -> Generator[Path]: + """Yield an unused sibling of ``output``, published over it only if the block succeeds. + + A strip that leaves AI metadata behind raises inside the block, so nothing reaches + ``output``. The name is not pre-created (``mkstemp`` would), so the writer gives the + published file the default permissions rather than ``mkstemp``'s 0600. + """ + staged = output.with_name(f".{output.stem}-{secrets.token_hex(8)}{output.suffix}") + try: + yield staged + os.replace(staged, output) + finally: + staged.unlink(missing_ok=True) diff --git a/src/remove_ai_watermarks/_internal/watermark_remover.py b/src/remove_ai_watermarks/_internal/watermark_remover.py index 7c949a9..06a5ef2 100644 --- a/src/remove_ai_watermarks/_internal/watermark_remover.py +++ b/src/remove_ai_watermarks/_internal/watermark_remover.py @@ -217,9 +217,12 @@ class WatermarkRemover: bgr = np.ascontiguousarray(np.asarray(image.convert("RGB"))[:, :, ::-1]) if not image_io.imwrite(str(output_path), bgr, display_tags_from=display_tags_from): image.save(output_path) - from remove_ai_watermarks.metadata import remove_ai_metadata + from remove_ai_watermarks.metadata import strip_and_verify - remove_ai_metadata(output_path, output_path, keep_standard=True) + # Verified, so a marker that survives fails the run instead of shipping with it. + _, leftover = strip_and_verify(output_path, output_path, keep_standard=True) + if leftover: + raise RuntimeError(f"AI metadata survived the strip: {', '.join(sorted(leftover))}") def remove_watermark( self, diff --git a/src/remove_ai_watermarks/api.py b/src/remove_ai_watermarks/api.py index 31fbda5..39f7ec4 100644 --- a/src/remove_ai_watermarks/api.py +++ b/src/remove_ai_watermarks/api.py @@ -132,33 +132,36 @@ def _write_visible_result( if not removed and not write_noop: return + import shutil + from remove_ai_watermarks import image_io + from remove_ai_watermarks._internal.utils import staged_sibling out_path = Path(output) out_path.parent.mkdir(parents=True, exist_ok=True) source_path = loaded.path - if not removed and source_path is not None and source_path.suffix.lower() == out_path.suffix.lower(): - # Copy the ORIGINAL bytes instead of lossily re-encoding a no-op. An in-place - # call needs no copy and would otherwise raise shutil.SameFileError. - if source_path.resolve() != out_path.resolve(): - import shutil - - shutil.copyfile(source_path, out_path) - else: + # Staged, so a strip that leaves AI metadata behind publishes nothing -- the + # MetadataStripIncomplete contract. + with staged_sibling(out_path) as staged: + if not removed and source_path is not None and source_path.suffix.lower() == out_path.suffix.lower(): + # Copy the ORIGINAL bytes instead of lossily re-encoding a no-op. + shutil.copyfile(source_path, staged) # imwrite is contractually NON-RAISING, so this bool is the only signal the file # was not created. Unchecked, the metadata strip below ran on a nonexistent path # and surfaced as a confusing "cannot read image " naming the OUTPUT path # (Tier E, 2026-07-20). Raise here so a library caller and the CLI both get an # accurate message about the write. - if not image_io.write_bgr_with_alpha( - out_path, result, loaded.alpha, display_tags_from=source_path, orientation_applied=False + elif not image_io.write_bgr_with_alpha( + staged, result, loaded.alpha, display_tags_from=source_path, orientation_applied=False ): raise OSError(f"failed to write output (is the destination writable?): {out_path}") - if strip_metadata: - from remove_ai_watermarks import metadata + if strip_metadata: + from remove_ai_watermarks.metadata import strip_and_verify - metadata.remove_ai_metadata(out_path, out_path) + _, leftover = strip_and_verify(staged, staged) + if leftover: + raise MetadataStripIncomplete(set(leftover)) def remove_visible( @@ -676,7 +679,9 @@ def remove_batch( from remove_ai_watermarks.optional_deps import pixels_available src_dir, out_dir = Path(directory), Path(output_dir) - out_dir.mkdir(parents=True, exist_ok=True) + sources = sorted(p for p in src_dir.iterdir() if is_supported_format(p)) + if sources: # an empty run leaves no empty output directory behind + out_dir.mkdir(parents=True, exist_ok=True) def say(path: Path, stage: str, detail: str) -> None: if progress is not None: @@ -685,7 +690,7 @@ def remove_batch( unavailable: list[Path] = [] errors: list[tuple[Path, str]] = [] items: list[BatchItemResult] = [] - for img_path in sorted(p for p in src_dir.iterdir() if is_supported_format(p)): + for img_path in sources: out_path = out_dir / img_path.name try: result = _run_batch_one(img_path, out_path, mode, backend, sensitivity, invisible, force, engine, say) diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index c8a93f3..b1af397 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -125,6 +125,27 @@ def _validate_image(path: Path) -> Path: return path +def _unreadable_input_exit(source: Path, *, after_report: bool, to_stderr: bool = False) -> None: + """Exit 1 when ``source`` is not a readable container; return otherwise. + + The metadata scanners read raw bytes, so on bytes that are not an image or video + they find nothing, and "nothing" used to print as the same no-signal answer a + real file gets, with exit 0. ``after_report`` says whether a partial report was + already printed above. ``to_stderr`` keeps a ``--json`` stdout parseable. + """ + from remove_ai_watermarks.metadata import unreadable_reason + + if (reason := unreadable_reason(source)) is None: + return + scanned = "The scan above is incomplete" if after_report else "Nothing was scanned" + message = f"Error: cannot read {source.name}: {reason}. {scanned}, not a no-signal result." + if to_stderr: + click.echo(message, err=True) + else: + console.print(f" {message}") + raise SystemExit(1) + + def _resolved_strength_for_display( source: Path, strength: float | None, @@ -427,6 +448,8 @@ def _write_output_or_exit( bgr: NDArray[Any], alpha: NDArray[Any] | None, display_source: Path | None = None, + *, + strip_metadata: bool = False, ) -> None: """Write the final image, or fail with a readable error instead of a traceback. @@ -438,11 +461,29 @@ def _write_output_or_exit( Regression: `tests/test_cli_robustness.py::TestFailedWriteIsReported`. `display_source` is the file whose decode produced `bgr`; its ICC profile and EXIF - orientation ride along into the re-encoded output (issue #98).""" + orientation ride along into the re-encoded output (issue #98). + + ``strip_metadata`` strips and verifies AI metadata on a staged copy before it is + published, so a marker that survives exits 1 with nothing written. Both commands + using this used to swallow a failed strip unless ``--verbose`` was set.""" + from remove_ai_watermarks._internal.utils import staged_sibling + from remove_ai_watermarks.metadata import strip_and_verify + output.parent.mkdir(parents=True, exist_ok=True) - if not image_io.write_bgr_with_alpha(output, bgr, alpha, display_tags_from=display_source): - console.print(f" Error: failed to write output (is the destination writable?): {output}") - raise SystemExit(1) + with staged_sibling(output) as staged: + if not image_io.write_bgr_with_alpha(staged, bgr, alpha, display_tags_from=display_source): + console.print(f" Error: failed to write output (is the destination writable?): {output}") + raise SystemExit(1) + if not strip_metadata: + return + try: + _, leftover = strip_and_verify(staged, staged) + except (OSError, RuntimeError, ValueError) as e: + console.print(f" Error: failed to strip AI metadata from {output}: {e}") + raise SystemExit(1) from e + if leftover: + console.print(f" Error: AI metadata survived in {output}: {', '.join(sorted(leftover))}") + raise SystemExit(1) def _no_visible_mark_exit(source: Path) -> NoReturn: @@ -511,6 +552,10 @@ def _no_visible_mark_exit(source: Path) -> NoReturn: # instead of treating an unchanged image as a completed removal. EXIT_NO_INVISIBLE_SIGNAL = 2 +# Also 2: a batch directory with no supported file processed nothing, which is a +# no-signal outcome a wrapper must not read as a cleaned directory. +EXIT_NOTHING_PROCESSED = EXIT_NO_INVISIBLE_SIGNAL + def _no_invisible_signal_exit(source: Path) -> NoReturn: """Explain why the diffusion scrub was skipped, then exit non-zero. @@ -636,7 +681,6 @@ def _run_visible_auto( def _run_visible_explicit( - ctx: click.Context, source: Path, output: Path, *, @@ -685,15 +729,7 @@ def _run_visible_explicit( raise SystemExit(1) from e elapsed = time.monotonic() - t0 - _write_output_or_exit(output, result, alpha, display_source=source) - if strip_metadata: - try: - from remove_ai_watermarks.metadata import remove_ai_metadata - - remove_ai_metadata(output, output) - except Exception as e: - if ctx.obj.get("verbose"): - console.print(f" Warning: Failed to strip metadata: {e}") + _write_output_or_exit(output, result, alpha, display_source=source, strip_metadata=strip_metadata) size_kb = output.stat().st_size / 1024 console.print(f" Saved: {output} ({size_kb:.0f} KB, {elapsed:.2f}s)") @@ -713,10 +749,8 @@ def _run_visible_explicit( @_visible_backend_option @_visible_sensitivity_option @click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.") -@click.pass_context @_pixels_required def cmd_visible( - ctx: click.Context, source: Path, output: Path | None, detect: bool, @@ -754,7 +788,6 @@ def cmd_visible( return _run_visible_explicit( - ctx, source, output, detect=detect, @@ -795,10 +828,8 @@ def _parse_region(spec: str) -> tuple[int, int, int, int]: @click.option("--inpaint-method", type=click.Choice(["telea", "ns"]), default="telea", help="cv2 inpaint method.") @click.option("--dilate", type=int, default=3, help="Grow the box by this many px before inpainting.") @click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.") -@click.pass_context @_pixels_required def cmd_erase( - ctx: click.Context, source: Path, regions: tuple[str, ...], output: Path | None, @@ -839,16 +870,7 @@ def cmd_erase( raise SystemExit(1) from e elapsed = time.monotonic() - t0 - _write_output_or_exit(output, result, alpha, display_source=source) - - if strip_metadata: - try: - from remove_ai_watermarks.metadata import remove_ai_metadata - - remove_ai_metadata(output, output) - except Exception as e: - if ctx.obj.get("verbose"): - console.print(f" Warning: Failed to strip metadata: {e}") + _write_output_or_exit(output, result, alpha, display_source=source, strip_metadata=strip_metadata) size_kb = output.stat().st_size / 1024 console.print(f" Erased {len(boxes)} region(s) -> {output} ({size_kb:.0f} KB, {elapsed:.2f}s)") @@ -937,13 +959,17 @@ def cmd_invisible( def progress_cb(msg: str) -> None: console.print(f" {msg}") - engine = InvisibleEngine( - pipeline=pipeline, - hf_token=hf_token, - progress_callback=progress_cb, - controlnet_conditioning_scale=controlnet_scale, - cpu_offload=cpu_offload, - ) + try: + engine = InvisibleEngine( + pipeline=pipeline, + hf_token=hf_token, + progress_callback=progress_cb, + controlnet_conditioning_scale=controlnet_scale, + cpu_offload=cpu_offload, + ) + except (OSError, RuntimeError, ValueError) as exc: # e.g. no CUDA device + console.print(f" Error: {exc}") + raise SystemExit(1) from exc # Detect the SynthID vendor from the ORIGINAL (before processing strips C2PA) so the # displayed and executed strength agree on the vendor-adaptive default. An explicit @@ -1052,6 +1078,7 @@ def cmd_metadata( # accepts video/audio containers, so the image-format warning would misfire. # click's `exists=True` on the argument already enforces the file exists. _banner() + _unreadable_input_exit(source, after_report=False) if check or (not remove): has_ai = has_ai_metadata(source) @@ -1064,14 +1091,13 @@ def cmd_metadata( # Remove try: out, leftover = strip_and_verify(source, output, keep_standard=keep_standard) - except (OSError, ValueError) as e: # unreadable / truncated / non-image (PIL raises OSError subclasses) + except (OSError, RuntimeError, ValueError) as e: # unreadable input, or ffmpeg absent or failing console.print(f" Error: cannot process {source.name}: {e}") raise SystemExit(1) from e if leftover: console.print(f" FAILED: {len(leftover)} AI metadata marker(s) survived in {out}") console.print(f" still present: {', '.join(sorted(leftover))}") - console.print(" the file could not be decoded, so it was copied through unchanged") raise SystemExit(1) console.print(f" AI metadata stripped -> {out}") _print_metadata_not_a_clean_verdict() @@ -1440,6 +1466,9 @@ def cmd_video_batch( except (OSError, RuntimeError, ValueError) as e: raise click.ClickException(str(e)) from e + if not result.items: + console.print(f" No supported videos found in {directory}; nothing was processed") + raise SystemExit(EXIT_NOTHING_PROCESSED) for item in result.items: if item.error is not None: console.print(f" FAILED {item.source.name}: {item.error}") @@ -1488,6 +1517,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo if as_json: click.echo(json.dumps(asdict(report), default=str, indent=2)) + _unreadable_input_exit(source, after_report=True, to_stderr=True) return _banner() @@ -1534,6 +1564,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo console.print("\n Caveats:") for c in report.caveats: console.print(f" - {c}") + _unreadable_input_exit(source, after_report=True) # ── Metadata-free photo classification (opt-in, never a tail of identify) ── @@ -1824,13 +1855,15 @@ def cmd_batch( if output_dir is None: output_dir = directory.parent / (directory.name + "_clean") - output_dir.mkdir(parents=True, exist_ok=True) images = sorted(p for p in directory.iterdir() if is_supported_format(p)) if not images: - console.print(f"No supported images found in {directory}") - return + # Nothing processed is the no-signal code, not success: a wrapper must not read + # an empty run as a cleaned directory. + console.print(f"No supported images found in {directory}; nothing was processed") + raise SystemExit(EXIT_NOTHING_PROCESSED) + output_dir.mkdir(parents=True, exist_ok=True) console.print(f" Found {len(images)} images in {directory}") console.print(f" Output -> {output_dir}") @@ -1859,6 +1892,11 @@ def cmd_batch( if ctx.obj.get("verbose"): console.print(f" {img.name}: {stage}{f' {detail}' if detail else ''}") + try: + engine = _batch_engine(mode, invisible_options) + except (OSError, RuntimeError, ValueError) as e: # e.g. no CUDA device + console.print(f" Error: {e}") + raise SystemExit(1) from e summary = api_remove_batch( directory, output_dir, @@ -1867,7 +1905,7 @@ def cmd_batch( sensitivity=_parse_sensitivity(sensitivity), invisible=invisible_options, force=force, - engine=_batch_engine(mode, invisible_options), + engine=engine, progress=on_progress, ) diff --git a/src/remove_ai_watermarks/mcp_server.py b/src/remove_ai_watermarks/mcp_server.py index 2236960..e55834c 100644 --- a/src/remove_ai_watermarks/mcp_server.py +++ b/src/remove_ai_watermarks/mcp_server.py @@ -207,7 +207,11 @@ def call_tool(name: str, arguments: dict[str, Any], runner: Runner = run_cli) -> args, output = _COMMANDS[name](_validated(name, arguments)) except ToolInputError as exc: return {"content": [{"type": "text", "text": f"Invalid arguments: {exc}"}], "isError": True} - completed = runner(args) + try: + completed = runner(args) + except OSError as exc: # the child never started, so there is no exit code to relay + log.warning("CLI command could not start: %s: %s", args, exc) + return {"content": [{"type": "text", "text": f"The command could not start: {exc}"}], "isError": True} code = completed.returncode text = completed.stdout.strip() stderr = completed.stderr.strip()[-4000:] @@ -219,13 +223,14 @@ def call_tool(name: str, arguments: dict[str, Any], runner: Runner = run_cli) -> structured["stdout"] = text if stderr: structured["stderr"] = stderr - # The CLI's own exit 2 means no supported signal, a result. Click also exits 2 on + # The CLI's own exit 2 means no supported signal, or a batch directory with nothing + # to process, both results. Click also exits 2 on # a usage error, such as a missing file, and prints its usage line on stderr. no_signal = code == 2 and "Usage:" not in stderr is_error = code != 0 and not no_signal summary = f"exit code {code}" if no_signal: - summary += " (no supported signal found; this is unknown, not proof the file is clean)" + summary += " (no supported signal found, or nothing to process; this is unknown, not proof of a clean file)" body = "\n\n".join(part for part in (summary, text, stderr if is_error else "") if part) return {"content": [{"type": "text", "text": body}], "structuredContent": structured, "isError": is_error} diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index 7ed335a..c846c4f 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -1856,6 +1856,92 @@ def _sniff_image_format(head: bytes) -> str | None: return None +_JXL_CONTAINER_MAGIC = b"\x00\x00\x00\x0cJXL \r\n\x87\n" +# Accepted on their magic alone: no local parser walks them, and the ffmpeg strip path +# fails on its own when one is unreadable. JPEG XL codestream, FLV, ID3, FLAC, Ogg. +_MAGIC_ONLY: tuple[bytes, ...] = (b"\xff\x0a", b"FLV", b"ID3", b"fLaC", b"OggS") +_PILLOW_RASTERS = frozenset({"png", "jpeg", "webp", "gif", "bmp", "tiff"}) + + +def _tiling_reason(kind: str, ends: Iterable[int], size: int) -> str | None: + """None when the last top-level element ends at EOF; a truncation leaves a gap.""" + end = max(ends, default=0) # top-level end offsets only increase + if end == size: + return None + return f"the {kind} structure stops at byte {end} of {size} (truncated or corrupt)" + + +def _pixel_stack_decodes(path: Path) -> bool: + """Whether the optional pixel stack decodes ``path``; False when it is absent.""" + from remove_ai_watermarks.optional_deps import pixels_available + + if not pixels_available(): + return False + from remove_ai_watermarks import image_io + + return image_io.imread(path) is not None + + +@functools.lru_cache(maxsize=8) +def _unreadable_reason_cached(path_str: str, _mtime_ns: int, size: int) -> str | None: + from pathlib import Path as _Path + + from PIL import Image + + from remove_ai_watermarks._internal.ebml import EBML_MAGIC, iter_top_level_ends + from remove_ai_watermarks._internal.isobmff import iter_file_boxes + from remove_ai_watermarks.forensic_metadata import sniff_format + + path = _Path(path_str) + if size == 0: + return "the file is empty" + with path.open("rb") as stream: + head = stream.read(32) + kind = sniff_format(head) + if kind.startswith("isobmff:") or head.startswith(_JXL_CONTAINER_MAGIC): + return _tiling_reason("ISOBMFF box", (end for _s, end, _t, _p in iter_file_boxes(stream, 0, size)), size) + if head.startswith(EBML_MAGIC): + return _tiling_reason("EBML element", iter_top_level_ends(stream, size), size) + if kind in _PILLOW_RASTERS: + try: + with Image.open(path) as image: + image.load() + except Image.DecompressionBombError: + return None # readable, merely too large for a precautionary full decode + except Exception as exc: + # The pixel stack decodes some rasters Pillow rejects (a PNG with a bad eXIf + # CRC), and strip_and_verify recovers those through it, so they are readable. + return None if _pixel_stack_decodes(path) else f"the image cannot be decoded: {exc}" + return None + if head.startswith(b"RIFF") and len(head) >= 8: + declared = 8 + struct.unpack_from(" 1 and head[0] == 0xFF and head[1] & 0xE0 == 0xE0 + if head.startswith(_MAGIC_ONLY) or mpeg_audio_sync: + return None + return "the content is not a recognized image, video or audio container" + + +def unreadable_reason(path: Path) -> str | None: + """Why ``path`` is not a readable image, video or audio container, or None. + + The metadata scanners read raw bytes, so on a file that is not a container at all + they find nothing and say so, which is the same answer a real file without metadata + gets. Callers that report "no metadata" or "stripped" ask this first, so an + unreadable input fails instead of reading as clean. Rasters must fully decode in + Pillow or, when it is installed, the pixel stack; ISOBMFF boxes and EBML elements + must tile the file, which is what a truncation breaks; a RIFF file must hold its + declared size. The formats in ``_MAGIC_ONLY`` and MPEG audio frames have no local + walker and pass on their magic. Memoized on (path, mtime, size): one command asks + several times about the same input. + """ + try: + stat = path.stat() + return _unreadable_reason_cached(str(path), stat.st_mtime_ns, stat.st_size) + except OSError as exc: + return f"cannot be read: {exc}" + + def strip_and_verify( source_path: Path, output_path: Path | None = None, @@ -1866,10 +1952,12 @@ def strip_and_verify( :func:`remove_ai_metadata` is deliberately fail-safe: a file PIL cannot decode is copied through UNCHANGED rather than crashing a caller, and the path it returns is - indistinguishable from a real strip. Any caller that reports an outcome to a user - therefore cannot tell a no-op from a success. A Samsung C2PA compatibility case - exposed this when `metadata --remove` reported success while the output still read - as AI. + indistinguishable from a real strip. This wrapper is what a caller reporting an + outcome uses instead: it refuses a source :func:`unreadable_reason` rejects with + ``ValueError`` before writing anything, because a copy-through of bytes nothing can + parse may carry no marker a re-scan sees, and then re-scans what it wrote. A + Samsung C2PA compatibility case exposed the original gap when `metadata --remove` + reported success while the output still read as AI. If markers survive but OpenCV can still decode the raster, the verified path normalizes the container through :mod:`image_io` and scans once more. This @@ -1878,6 +1966,8 @@ def strip_and_verify( Returns ``(output_path, surviving_markers)``; an empty mapping means a real strip. """ + if (reason := unreadable_reason(source_path)) is not None: + raise ValueError(f"cannot read {source_path.name}: {reason}") out = remove_ai_metadata(source_path, output_path, keep_standard=keep_standard) remaining = get_ai_metadata(out) if not remaining: diff --git a/src/remove_ai_watermarks/video.py b/src/remove_ai_watermarks/video.py index 79ffb6b..d58418a 100644 --- a/src/remove_ai_watermarks/video.py +++ b/src/remove_ai_watermarks/video.py @@ -32,7 +32,6 @@ _EBML_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".webm", ".mkv"}) _RIFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".avi"}) _FLV_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".flv"}) _REGENERATED_VIDEO_EXTENSIONS: frozenset[str] = _ISOBMFF_VIDEO_EXTENSIONS -_EBML_MAGIC = b"\x1aE\xdf\xa3" def _require_video_runtime() -> None: @@ -237,17 +236,23 @@ def _video_source(source: str | Path) -> Path: if path.suffix.lower() not in VIDEO_EXTENSIONS: supported = ", ".join(sorted(VIDEO_EXTENSIONS)) raise ValueError(f"Unsupported video format {path.suffix or ''}; expected one of: {supported}") + from remove_ai_watermarks._internal.ebml import EBML_MAGIC + with path.open("rb") as stream: head = stream.read(12) suffix = path.suffix.lower() matches_container = ( (suffix in _ISOBMFF_VIDEO_EXTENSIONS and len(head) >= 8 and head[4:8] == b"ftyp") - or (suffix in _EBML_VIDEO_EXTENSIONS and head.startswith(_EBML_MAGIC)) + or (suffix in _EBML_VIDEO_EXTENSIONS and head.startswith(EBML_MAGIC)) or (suffix in _RIFF_VIDEO_EXTENSIONS and len(head) >= 12 and head[:4] == b"RIFF" and head[8:12] == b"AVI ") or (suffix in _FLV_VIDEO_EXTENSIONS and head.startswith(b"FLV")) ) if not matches_container: raise ValueError(f"Video content does not match its {suffix} extension: {path}") + from remove_ai_watermarks.metadata import unreadable_reason + + if (reason := unreadable_reason(path)) is not None: + raise ValueError(f"Cannot read video {path}: {reason}") return path @@ -714,13 +719,13 @@ def remove_video_batch( ) if output_path.resolve() == directory_path.resolve(): raise ValueError("Video batch output directory must differ from the source directory") - output_path.mkdir(parents=True, exist_ok=True) - sources = tuple( path for path in sorted(directory_path.iterdir(), key=lambda candidate: candidate.name.lower()) if path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS ) + if sources: + output_path.mkdir(parents=True, exist_ok=True) items: list[VideoBatchItem] = [] invisible_runtime: VideoVaeRuntime | None = None invisible_runtime_error: str | None = None diff --git a/tests/test_agent_skill.py b/tests/test_agent_skill.py index a52f883..653b6f2 100644 --- a/tests/test_agent_skill.py +++ b/tests/test_agent_skill.py @@ -347,7 +347,7 @@ def test_missing_pixel_stack_names_the_extra_instead_of_a_traceback( source = tmp_path / "image.png" source.write_bytes((ROOT / "data" / "fixtures" / "provenance" / "chatgpt-1.png").read_bytes()) clip = tmp_path / "clip.mp4" - clip.write_bytes(b"\x00\x00\x00\x18ftypmp42") + clip.write_bytes(b"\x00\x00\x00\x10ftypmp42\x00\x00\x00\x00") # one whole box, so the input reads substitutions = { "SOURCE": str(source), "CLIP": str(clip), diff --git a/tests/test_cli.py b/tests/test_cli.py index 0211614..be0e12d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -867,7 +867,7 @@ class TestBatchCommand: empty_dir = tmp_path / "empty" empty_dir.mkdir() result = runner.invoke(main, ["batch", str(empty_dir)]) - assert result.exit_code == 0 + assert result.exit_code == 2 # nothing processed is the no-signal code (2026-10-18) assert "No supported images" in result.output def test_batch_visible_mode(self, runner, tmp_path): @@ -1309,10 +1309,10 @@ def test_visible_backend_runtime_error_exits_cleanly(runner, tmp_path, monkeypat @pytest.mark.parametrize("cmd", [["metadata", "--remove"], ["visible", "--backend", "cv2"]]) def test_unreadable_input_exits_cleanly(runner, tmp_path, name, content, cmd): """Regression: a corrupt / empty / non-image file (real prod uploads include ~0.2% - truncated files) must NEVER leak a raw PIL/OSError/ValueError traceback. `metadata - --remove` is fail-safe -- an undecodable file is copied through unchanged (exit 0), - a strip that cannot parse the file is a no-op, not a crash; `visible` must decode to - remove a mark, so it is a clean error (exit 1). Found by the runtime mode fuzz.""" + truncated files) must NEVER leak a raw PIL/OSError/ValueError traceback, and both + commands fail with a clean exit 1. `metadata --remove` used to copy the bytes + through and exit 0, which read as a completed strip; since 2026-10-18 it writes + nothing (`tests/test_fail_closed.py`). Found by the runtime mode fuzz.""" bad = tmp_path / name bad.write_bytes(content) out = tmp_path / "out.png" @@ -1320,12 +1320,9 @@ def test_unreadable_input_exits_cleanly(runner, tmp_path, name, content, cmd): assert result.exception is None or isinstance(result.exception, SystemExit), ( f"leaked a raw traceback: {result.exception!r}" ) - if cmd[0] == "metadata": - assert result.exit_code == 0, result.output # fail-safe copy-through - assert out.read_bytes() == content # input passed through unchanged - else: - assert result.exit_code == 1, result.output # cannot remove a mark from unreadable input - assert "Error" in result.output + assert result.exit_code == 1, result.output + assert "Error" in result.output + assert not out.exists() @pytest.mark.parametrize( diff --git a/tests/test_fail_closed.py b/tests/test_fail_closed.py new file mode 100644 index 0000000..f04fdfa --- /dev/null +++ b/tests/test_fail_closed.py @@ -0,0 +1,311 @@ +"""Every command fails closed: an unreadable input or a skipped step never exits 0. + +Found 2026-10-18 by a sweep that ran each command over random bytes, an empty file +and a truncated copy of a tracked fixture. The pixel commands already refused all +three. The metadata-only paths did not: they scan raw bytes, a scan of bytes that +are not an image finds nothing, and "nothing" printed as "No AI metadata found" +with exit 0, the same answer a real file without metadata gets. `metadata +--remove` then copied the garbage through and reported "AI metadata stripped". +A truncated MP4 without its `moov` box got the same treatment from the video +metadata commands. The rule pinned here: the exit code of a scan that could not +read its input is never the exit code of a scan that read it and found nothing. + +Absent OPTIONAL detectors are not in this set. `identify` on the default install, +which has no pixel stack, still answers with exit 0 and a caveat naming what did +not run (decided 2026-10-18); see `test_optional_deps.py`. +""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +import numpy as np +import pytest +from click.testing import CliRunner + +from remove_ai_watermarks import image_io, metadata +from remove_ai_watermarks._internal.constants import SUPPORTED_FORMATS +from remove_ai_watermarks.cli import main +from remove_ai_watermarks.video import VIDEO_EXTENSIONS + +ROOT = Path(__file__).resolve().parents[1] +C2PA_PNG = ROOT / "data/fixtures/provenance/ideogram-c2pa.png" +MP4 = ROOT / "data/fixtures/provenance/runway-gen45-video.mp4" + + +def _unreadable_images(tmp_path: Path) -> dict[str, Path]: + rng = np.random.default_rng(0) + garbage = tmp_path / "garbage.png" + garbage.write_bytes(rng.integers(0, 256, 4000, dtype=np.uint8).tobytes()) + empty = tmp_path / "empty.png" + empty.write_bytes(b"") + truncated = tmp_path / "truncated.png" + truncated.write_bytes(C2PA_PNG.read_bytes()[:300]) + return {"garbage": garbage, "empty": empty, "truncated": truncated} + + +@pytest.fixture +def clean_png(tmp_path: Path) -> Path: + path = tmp_path / "clean.png" + gradient = np.tile(np.linspace(0, 255, 64, dtype=np.uint8), (64, 1)) + assert image_io.imwrite(path, np.dstack([gradient, gradient.T, gradient])) + return path + + +@pytest.fixture +def truncated_mp4(tmp_path: Path) -> Path: + """The ftyp box survives, so the suffix sniff passes; the box walk does not.""" + path = tmp_path / "truncated.mp4" + path.write_bytes(MP4.read_bytes()[:3000]) + return path + + +@pytest.fixture +def cuda_refused(monkeypatch): + """The invisible engine is installed but cannot start, as on a host without CUDA.""" + import remove_ai_watermarks.invisible_engine as invisible_engine + + def refuse(*_args, **_kwargs): + raise ValueError("Invisible-watermark removal is CUDA-only") + + monkeypatch.setattr(invisible_engine, "is_available", lambda: True) + monkeypatch.setattr(invisible_engine, "InvisibleEngine", refuse) + + +def _invoke(args: list[str]): + return CliRunner().invoke(main, args) + + +class TestUnreadableImageInputs: + @pytest.mark.parametrize("kind", ["garbage", "empty", "truncated"]) + @pytest.mark.parametrize("flags", [[], ["--no-visible"], ["--json"]]) + def test_identify_exits_nonzero(self, tmp_path, kind, flags): + result = _invoke(["identify", *flags, str(_unreadable_images(tmp_path)[kind])]) + assert result.exit_code == 1, result.output + assert isinstance(result.exception, SystemExit) + + def test_identify_keeps_exit_zero_on_a_readable_file(self, clean_png): + result = _invoke(["identify", str(clean_png)]) + assert result.exit_code == 0, result.output + + @pytest.mark.parametrize("kind", ["garbage", "empty", "truncated"]) + def test_metadata_check_does_not_report_no_metadata(self, tmp_path, kind): + result = _invoke(["metadata", "--check", str(_unreadable_images(tmp_path)[kind])]) + assert result.exit_code == 1, result.output + assert "No AI metadata found" not in result.output + assert "Nothing was scanned" in result.output, "the probe runs before any scan" + + @pytest.mark.parametrize("kind", ["garbage", "empty", "truncated"]) + def test_metadata_remove_writes_nothing_and_exits_nonzero(self, tmp_path, kind): + out = tmp_path / "out.png" + result = _invoke(["metadata", "--remove", str(_unreadable_images(tmp_path)[kind]), "-o", str(out)]) + assert result.exit_code == 1, result.output + assert "stripped" not in result.output + assert not out.exists() + + def test_metadata_remove_keeps_working_on_a_readable_file(self, clean_png, tmp_path): + result = _invoke(["metadata", "--remove", str(clean_png), "-o", str(tmp_path / "out.png")]) + assert result.exit_code == 0, result.output + + def test_batch_metadata_counts_an_unreadable_file_as_failed(self, tmp_path, clean_png): + indir = tmp_path / "in" + indir.mkdir() + shutil.copy(clean_png, indir / "clean.png") + shutil.copy(_unreadable_images(tmp_path)["garbage"], indir / "garbage.png") + result = _invoke(["batch", str(indir), "--mode", "metadata", "-o", str(tmp_path / "out")]) + assert result.exit_code == 1, result.output + assert "1 processed" in result.output + assert "1 errors" in result.output + + +class TestTruncatedVideo: + @pytest.mark.parametrize( + "args", + [ + ["video", "identify", "--no-visible"], + ["video", "metadata", "--check"], + ["video", "metadata", "--remove"], + ["metadata", "--check"], + ], + ) + def test_metadata_paths_exit_nonzero(self, truncated_mp4, args): + result = _invoke([*args, str(truncated_mp4)]) + assert result.exit_code == 1, result.output + assert "No AI metadata found" not in result.output + assert "stripped" not in result.output + + def test_video_batch_metadata_counts_it_as_failed(self, tmp_path, truncated_mp4): + indir = tmp_path / "in" + indir.mkdir() + shutil.copy(truncated_mp4, indir / "truncated.mp4") + result = _invoke(["video", "batch", str(indir), "--mode", "metadata", "-o", str(tmp_path / "out")]) + assert result.exit_code == 1, result.output + assert "0 processed, 1 failed" in result.output + + +class TestReadabilityProbe: + """The probe must pass every tracked fixture, or the fix trades one lie for another.""" + + @pytest.mark.parametrize( + "path", + sorted(p for p in (ROOT / "data/fixtures").rglob("*") if p.is_file() and p.suffix.lower() != ".md"), + ids=lambda p: str(p.relative_to(ROOT / "data/fixtures")), + ) + def test_every_tracked_fixture_is_readable(self, path): + if path.suffix.lower() not in SUPPORTED_FORMATS | VIDEO_EXTENSIONS: + pytest.skip("not a container the metadata commands accept") + assert metadata.unreadable_reason(path) is None + + def test_unreadable_inputs_name_a_reason(self, tmp_path, truncated_mp4): + for path in [*_unreadable_images(tmp_path).values(), truncated_mp4]: + assert metadata.unreadable_reason(path), path + + def test_a_png_cut_inside_its_pixel_data_is_unreadable(self, tmp_path): + """The header parses, so only a full decode sees the missing IDAT bytes.""" + path = tmp_path / "cut.png" + data = (ROOT / "data/fixtures/provenance/chatgpt-1.png").read_bytes() + path.write_bytes(data[: len(data) * 3 // 5]) + assert metadata.unreadable_reason(path) + + def test_a_trailing_partial_box_is_unreadable(self, tmp_path): + """Cut inside the LAST box, so ftyp and moov both survive and only the walk sees it.""" + data = MP4.read_bytes() + path = tmp_path / "cut.mp4" + path.write_bytes(data[:-100]) + assert metadata.unreadable_reason(path) + + def test_a_truncated_ebml_segment_is_unreadable(self, tmp_path): + """A Segment that declares more bytes than the file holds, as a cut WebM does.""" + path = tmp_path / "x.webm" + ebml_header = b"\x1a\x45\xdf\xa3\x80" # empty EBML header element + segment = b"\x18\x53\x80\x67\x88" + bytes(4) # declares 8 payload bytes, holds 4 + path.write_bytes(ebml_header + segment) + assert metadata.unreadable_reason(path) + path.write_bytes(ebml_header + segment + bytes(4)) + assert metadata.unreadable_reason(path) is None + + def test_a_truncated_riff_is_unreadable(self, tmp_path): + path = tmp_path / "x.wav" + path.write_bytes(b"RIFF" + (100).to_bytes(4, "little") + b"WAVE" + bytes(20)) + assert metadata.unreadable_reason(path) + path.write_bytes(b"RIFF" + (100).to_bytes(4, "little") + b"WAVE" + bytes(96)) + assert metadata.unreadable_reason(path) is None + + +class TestSkippedStepsAreFailures: + @pytest.mark.parametrize( + "args", + [ + ["visible", "--mark", "doubao", "--no-detect"], + ["erase", "--region", "2,2,10,10"], + ], + ) + def test_a_metadata_strip_that_left_markers_is_not_success(self, tmp_path, clean_png, monkeypatch, args): + """Both commands used to swallow the strip and report only under --verbose.""" + monkeypatch.setattr(metadata, "get_ai_metadata", lambda _path: {"c2pa": "survived"}) + out = tmp_path / "out.png" + result = _invoke([*args, str(clean_png), "-o", str(out)]) + assert result.exit_code == 1, result.output + assert "survived" in result.output + assert not out.exists(), "an output that kept its AI metadata was published" + + @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits") + def test_a_staged_output_keeps_the_default_permissions(self, tmp_path, clean_png): + """The staged sibling is not pre-created, so it is not mkstemp's 0600.""" + out = tmp_path / "out.png" + reference = tmp_path / "reference" + reference.write_bytes(b"") + result = _invoke(["erase", "--region", "2,2,10,10", str(clean_png), "-o", str(out)]) + assert result.exit_code == 0, result.output + assert out.stat().st_mode & 0o777 == reference.stat().st_mode & 0o777 + + def test_the_library_visible_strip_is_verified(self, tmp_path, clean_png, monkeypatch): + from remove_ai_watermarks.api import MetadataStripIncomplete, remove_visible + + monkeypatch.setattr(metadata, "get_ai_metadata", lambda _path: {"c2pa": "survived"}) + with pytest.raises(MetadataStripIncomplete): + remove_visible(clean_png, tmp_path / "out.png") + assert list(tmp_path.iterdir()) == [clean_png], "the staged output leaked" + + def test_the_invisible_output_strip_is_verified(self, tmp_path, monkeypatch): + """The diffusion writer used to strip without re-scanning what it wrote.""" + from PIL import Image + + from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover + + monkeypatch.setattr(metadata, "get_ai_metadata", lambda _path: {"c2pa": "survived"}) + remover = object.__new__(WatermarkRemover) # the writer needs no model state + with pytest.raises(RuntimeError, match="survived"): + remover._write_output(Image.new("RGB", (16, 16)), tmp_path / "out.png") + + def test_metadata_remove_reports_a_missing_ffmpeg_without_a_traceback(self, tmp_path, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda _name: None) + source = tmp_path / "x.webm" + source.write_bytes(b"\x1a\x45\xdf\xa3\x80") # an empty EBML header tiles the file + result = _invoke(["metadata", "--remove", str(source), "-o", str(tmp_path / "out.webm")]) + assert result.exit_code == 1, result.output + assert isinstance(result.exception, SystemExit), result.exception + assert "ffmpeg" in result.output + + @pytest.mark.usefixtures("cuda_refused") + def test_invisible_reports_an_engine_that_cannot_start(self, clean_png): + result = _invoke(["invisible", "--force", str(clean_png)]) + assert result.exit_code == 1, result.output + assert isinstance(result.exception, SystemExit), result.exception + assert "CUDA-only" in result.output + + @pytest.mark.usefixtures("cuda_refused") + def test_batch_reports_an_engine_that_cannot_start(self, tmp_path, clean_png): + indir = tmp_path / "in" + indir.mkdir() + shutil.copy(clean_png, indir / "clean.png") + result = _invoke(["batch", str(indir), "--mode", "invisible", "-o", str(tmp_path / "out")]) + assert result.exit_code == 1, result.output + assert isinstance(result.exception, SystemExit), result.exception + assert "CUDA-only" in result.output + + +class TestEmptyBatch: + """Nothing processed is the no-signal code, not success (decided 2026-10-18).""" + + def test_image_batch(self, tmp_path): + indir = tmp_path / "in" + indir.mkdir() + (indir / "notes.txt").write_text("not an image") + result = _invoke(["batch", str(indir), "-o", str(tmp_path / "out")]) + assert result.exit_code == 2, result.output + assert not (tmp_path / "out").exists() + + def test_video_batch(self, tmp_path): + indir = tmp_path / "in" + indir.mkdir() + result = _invoke(["video", "batch", str(indir), "--mode", "metadata", "-o", str(tmp_path / "out")]) + assert result.exit_code == 2, result.output + assert not (tmp_path / "out").exists() + + +def test_mcp_reports_a_child_that_could_not_start(): + from remove_ai_watermarks import mcp_server + + def broken(_args): + raise OSError("exec format error") + + result = mcp_server.call_tool("identify", {"path": "x.png"}, runner=broken) + assert result["isError"] is True + assert "exec format error" in result["content"][0]["text"] + + +def test_mcp_does_not_call_an_empty_batch_a_scanned_file(): + """An empty batch directory exits 2 like no-signal, but nothing was read.""" + import subprocess + + from remove_ai_watermarks import mcp_server + + def empty_batch(args): + return subprocess.CompletedProcess(args, 2, stdout="No supported images found", stderr="") + + result = mcp_server.call_tool("batch", {"directory": "in"}, runner=empty_batch) + assert result["isError"] is False + assert "nothing to process" in result["content"][0]["text"] diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 6d4b0af..eacc1b6 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -185,7 +185,7 @@ def test_only_failures_are_tool_errors(returncode: int, stderr: str, is_error: b assert result["isError"] is is_error assert result["structuredContent"]["exit_code"] == returncode - assert ("not proof the file is clean" in result["content"][0]["text"]) is (returncode == 2 and not is_error) + assert ("not proof of a clean file" in result["content"][0]["text"]) is (returncode == 2 and not is_error) if is_error: assert stderr.splitlines()[-1] in result["content"][0]["text"] diff --git a/tests/test_metadata.py b/tests/test_metadata.py index e45364b..8bdd0d5 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -334,17 +334,20 @@ class TestHasAiMetadata: assert after is not None assert np.array_equal(after, before) - def test_strip_and_verify_reports_markers_in_undecodable_copy_through(self, tmp_path: Path): - """A truly undecodable file keeps the established fail-safe result.""" + def test_strip_and_verify_refuses_an_undecodable_file(self, tmp_path: Path): + """A truly undecodable file raises before anything is written. + + It used to be copied through and reported with its surviving markers, but a + copy-through of bytes no scanner can parse may carry nothing a re-scan sees, + and then it verified as a real strip (`tests/test_fail_closed.py`). + """ src = _corrupt_c2pa_png(tmp_path / "source.png") src.write_bytes(src.read_bytes()[: src.stat().st_size // 2]) out = tmp_path / "copy.png" - result, remaining = strip_and_verify(src, out) - - assert result == out - assert "c2pa_manifest" in remaining - assert out.read_bytes() == src.read_bytes() + with pytest.raises(ValueError, match=r"cannot read source\.png"): + strip_and_verify(src, out) + assert not out.exists() class TestC2paMarkerIn: From ace71128b9c7a4b90225d694a003e3d1d5d185cd Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Mon, 19 Oct 2026 12:16:10 -0700 Subject: [PATCH 3/3] Bump aiohttp to 3.14.5 for GHSA-5wr8-gcqf-gqf3, GHSA-2g87-pp6c-29x5 and GHSA-494j-54xm-rwp3 The CI lockfile scan failed on all three advisories against 3.14.3; each is fixed in 3.14.4. Co-Authored-By: Claude Opus 5.5 --- uv.lock | 205 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 103 insertions(+), 102 deletions(-) diff --git a/uv.lock b/uv.lock index 05abec7..5b7ec39 100644 --- a/uv.lock +++ b/uv.lock @@ -56,7 +56,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.3" +version = "3.14.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -68,108 +68,109 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/4c/bdccd81e9ee225b69c60e7766c9a5b05364f118f4d383713b89a682d772d/aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178", size = 8078052, upload-time = "2026-10-11T01:05:12.408Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, - { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, - { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, - { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, - { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, - { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, - { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, - { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, - { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, - { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, - { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, - { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, - { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, - { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, - { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, - { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, - { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, - { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, - { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, - { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, - { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, - { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, - { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, - { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, - { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, - { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, - { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, - { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, - { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, - { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, - { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, - { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, - { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, - { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, - { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, - { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, - { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, - { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, - { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, - { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, - { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, - { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, - { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, - { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, - { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, - { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, - { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, - { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, - { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, - { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, - { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, - { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, - { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, - { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, - { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, - { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, - { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, - { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, - { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, - { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, - { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, - { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, - { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, - { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, - { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, - { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, - { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, - { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, - { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, - { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, - { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, - { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, - { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, - { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, - { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, - { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, - { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/8997f18890a92c79f77fcfbb4f78ca17c2d4ab109e9eb6d31b4e9de194a0/aiohttp-3.14.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d51db97c96384fbfcaf8f4c65922183a68b94f891c3c10c862ef5f6df2adbb1f", size = 832358, upload-time = "2026-10-11T00:59:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/8e/42/084651e9efb5cadb99265f786df60f2a7b353cead7bfd2c87003cb4867ad/aiohttp-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ae53924aa853a7a2ca20ed4142c7c6b56338e4d4cd999e2980075b9efc2e257a", size = 559636, upload-time = "2026-10-11T00:59:43.677Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/92838944e601f0fe195fd3f3ada37e29fbee3d19bfd323fe15937ec79d36/aiohttp-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2c473a355f9239efcb72c92d5abfd8fcdb0cc78c8e9af607e72ca12dbb36593", size = 552588, upload-time = "2026-10-11T00:59:45.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/dd9b95c5c20ceae1b47738e656bd79e9883e3117a9cc8b143901eb604e9a/aiohttp-3.14.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e8fa6644e541fcd7e02430588c7fc93b602c1778ea0bc345505db76b61cfb4", size = 1928224, upload-time = "2026-10-11T00:59:47.294Z" }, + { url = "https://files.pythonhosted.org/packages/c6/47/70010cd2ba8746968d53d433ff328c33f06a66c99da5f3de759c64a6b2eb/aiohttp-3.14.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:579f97d5120f2971876d2ddca2968135f6944d00c44c3a6590ad7d86ca9b403f", size = 1877674, upload-time = "2026-10-11T00:59:49.472Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a1/b026f071dbd87f0bdba86b92d47e46f3899e3ad7143b6e0fe7e7887090b4/aiohttp-3.14.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:24409db442e2fb6e766bc7f3943851a8381dec3098140e43bb2e843b79e31b12", size = 1985850, upload-time = "2026-10-11T00:59:51.296Z" }, + { url = "https://files.pythonhosted.org/packages/36/d1/f7b6f6f8c3cb5a71b53baeddc2e70c224c28b12a23a55c42994c504019b7/aiohttp-3.14.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e8f97c0488ffda3082766ac0f2c8150a9a58c4d05788330e479cfd449b37939", size = 2105471, upload-time = "2026-10-11T00:59:53.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/2a2b22953de15c6c8a80debf26db3047e4e8a5a5db6dd7c2c6c01eec0605/aiohttp-3.14.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50a195903119008fe9cc68710535eb37f556ffffd6a7759afe70a2c145587045", size = 1929915, upload-time = "2026-10-11T00:59:55.609Z" }, + { url = "https://files.pythonhosted.org/packages/33/ad/f80e8d33933d0eacd0217efa3b7fb32c9f48ed480ed0db53cf9948bf474f/aiohttp-3.14.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0133c3c3b54a0bf1e71fa5c1ad95c93f07fd54e24ef1fe182f5122e1573d2bf1", size = 1773832, upload-time = "2026-10-11T00:59:57.611Z" }, + { url = "https://files.pythonhosted.org/packages/04/a4/0273d239f3e3bb69438f209c64c82e1f98dc60d5db264795e41f7dd05dcb/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c172db893e516e1358e65a95ee20b7ce7173963eefe318b6ab2a2220688b999e", size = 1901001, upload-time = "2026-10-11T01:00:00.605Z" }, + { url = "https://files.pythonhosted.org/packages/16/ed/7dd439d26c654645bc34ca3c9a822fc11c1c51801fed1cefdfe1cc41c1f6/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f2a7966bda23dd85051f1661ce0ace38d6890e05ec6c357ecae9d2479cba377e", size = 1907218, upload-time = "2026-10-11T01:00:02.699Z" }, + { url = "https://files.pythonhosted.org/packages/32/dd/86249c3b8248562a17fd3e22ee0c164378d65766f01aceef62cf52783710/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4887d130a7bbfed3a85493bb5a25e5b5b558d40c1d986dd16970d2bb26d63793", size = 1958650, upload-time = "2026-10-11T01:00:04.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0b/ca4d53d68f683ce195807fd0aeb063bf6d1c6b39343fff234c9524e8d5a9/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:225c579c23b68b343cccea27a7e06e3bd8ec23a09c30b427eb3f1e4ca6239b20", size = 1760057, upload-time = "2026-10-11T01:00:06.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/42/005437ffd7fa56c2ce3347add40404b654a32754ce91f2e2a14b9e5ab2bc/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ab52d8f1fc1b64821c1fbad64a647ed6203627004059a6d1ed4f0858a1499703", size = 1978081, upload-time = "2026-10-11T01:00:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/3a5b66fe1b7b23d3c6524350817ed55de5feb7fbb4cccba7cbe044d118dd/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb131d775a1573c1aee66656bd78b023577bbdb6cb8349a07773bd4f73e68a6e", size = 1922954, upload-time = "2026-10-11T01:00:11.035Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/d08a554f281d42fd7c6b2b6dd7738f3e3a7a9f657ddd87e11ca2091cdfe3/aiohttp-3.14.5-cp311-cp311-win32.whl", hash = "sha256:e87046c8ff77a8decdb6a41d8ab25824b47531b2da933aeab0c1e21c7acff329", size = 488101, upload-time = "2026-10-11T01:00:12.933Z" }, + { url = "https://files.pythonhosted.org/packages/0f/15/52b5e65f02b33686ae49f1518548ae4f5a7adaf31d9d3c54fa12a143137b/aiohttp-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:6f275c11d1aa6d4c458e05a68be084efe3c55a113d99e3f46a318098e52948fc", size = 512575, upload-time = "2026-10-11T01:00:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b9/bb75012635f3defb0f7cbb7c4ae391e36bb9cb3fbdca3b9944e2ebebf743/aiohttp-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:b032a0023eb41d768ce77d83210ab2a3c389bc0b09313273c7e1eca48c10a755", size = 493179, upload-time = "2026-10-11T01:00:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/d5/94/6ba86efddcb616c811b40e6a0dfdd862738f647e4e3860a961075d5e9ed8/aiohttp-3.14.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450", size = 817499, upload-time = "2026-10-11T01:00:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/7bca6d84dabd228aa8eb4b7f9feac2586aaa9be5505d7d65bf287a615c43/aiohttp-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8", size = 549467, upload-time = "2026-10-11T01:00:20.854Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/11b89f45ca486252dd67dd5f3231fec04bf5518da39a95cb3997619f17fb/aiohttp-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2", size = 548067, upload-time = "2026-10-11T01:00:22.659Z" }, + { url = "https://files.pythonhosted.org/packages/22/ff/c6615806c14aab34f82b9424ccde8ce6e417315fd57ce1c5b4d4747888e1/aiohttp-3.14.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764", size = 1896245, upload-time = "2026-10-11T01:00:24.638Z" }, + { url = "https://files.pythonhosted.org/packages/39/b2/25a8c971ae6a92c8394d77e42422d7f38989e05cf41a5ceb92d73d67ab7e/aiohttp-3.14.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820", size = 1872180, upload-time = "2026-10-11T01:00:26.838Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d5/99f93ea36cc5205e47c1e5a803e087f2ad21b5430b5db2e942cb6e988a37/aiohttp-3.14.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835", size = 1937261, upload-time = "2026-10-11T01:00:28.74Z" }, + { url = "https://files.pythonhosted.org/packages/40/a6/9ac9c9e6695040bd73d2584a1b59a9388f6433c76d5294a7bf591e21ffe5/aiohttp-3.14.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02", size = 2064994, upload-time = "2026-10-11T01:00:30.623Z" }, + { url = "https://files.pythonhosted.org/packages/da/e4/aa172eb534b7f02f1f8ff1c3213347eaf3cf218db91a727c6863c22f1035/aiohttp-3.14.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584", size = 1919630, upload-time = "2026-10-11T01:00:32.548Z" }, + { url = "https://files.pythonhosted.org/packages/06/7d/4eedafc5bababa8932636c141e346806966eade12c0b7e5946d43bf8218b/aiohttp-3.14.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d", size = 1710016, upload-time = "2026-10-11T01:00:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/b2/94/eee018537ba19da0ceb2ac79cab83faed4ac49568e08376e2799043f2538/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30", size = 1858112, upload-time = "2026-10-11T01:00:36.581Z" }, + { url = "https://files.pythonhosted.org/packages/68/76/354653a306547238f3427283905972d796ba7c292ba9977abec9f2b6f260/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073", size = 1896371, upload-time = "2026-10-11T01:00:38.478Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ec/63e8c7136b570e356345ad3174e3820fdc973ea10712cb6c649bf875755a/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794", size = 1905836, upload-time = "2026-10-11T01:00:40.527Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/11365bda144b127928cd42533d0eff78a55613c9e28f81941bd6630ea887/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd", size = 1696534, upload-time = "2026-10-11T01:00:42.686Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3d/82df0461b18e00b2998f205c03e0d3010222478c43640aceb8e03dcd7e8f/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d", size = 1933970, upload-time = "2026-10-11T01:00:44.71Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/6ddfe0aacd931c17b53533336d97e9d11a98b96d6ae815a9da0b19f82ccf/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748", size = 1899501, upload-time = "2026-10-11T01:00:46.629Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8e/189bdd9ae4793059bb09f6dc880f211a6c6c7859cebcbf33912dbfab7dd7/aiohttp-3.14.5-cp312-cp312-win32.whl", hash = "sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1", size = 480370, upload-time = "2026-10-11T01:00:48.468Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ce/1f08114679d49655b30a6e0a29858375c94b82c1de1a0bd0a20c2fee8b02/aiohttp-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111", size = 506141, upload-time = "2026-10-11T01:00:50.272Z" }, + { url = "https://files.pythonhosted.org/packages/79/d4/c7b4f60b16a1b7e43249fa9031ae05e7e8c341ba4b7d1866f914dafeaa0e/aiohttp-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac", size = 488108, upload-time = "2026-10-11T01:00:52.321Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e1/2841e020ebb7aefae5513586193e011e06313d9a6bdbd296622afbbce204/aiohttp-3.14.5-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:9ad7e6aa38c20da1be697874349c4c273c8a03b7887169665081706398d0439a", size = 538932, upload-time = "2026-10-11T01:00:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a6/7fb8ea8fe96bcc7b7c7a36d10f021d99d01a8dc8a4b3f0ddacecfad9a80e/aiohttp-3.14.5-cp313-cp313-android_24_x86_64.whl", hash = "sha256:f59c7673465908cbe506117176156c127f29f917677afceada34957179221d91", size = 553329, upload-time = "2026-10-11T01:00:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/d056e3c27647dc25af1a592cf356245382ea7c808171b9dac7677afedfc8/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b5416552740edf07234cc9437d0706f2acb67b93c198670b1a68e1b2b587dec", size = 521367, upload-time = "2026-10-11T01:00:58.345Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e4/95226147e11d4db916fd1d495dcf85af8e3816e38333e42718241196e848/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:43351bdb5e4c3cb7d1772368e988534e869a74db7778079a83782c11c69535c7", size = 528322, upload-time = "2026-10-11T01:01:00.211Z" }, + { url = "https://files.pythonhosted.org/packages/17/cd/1d3c9192cafdb51cad62b2d3ded96cff9cc8af51893210aadd448a325389/aiohttp-3.14.5-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c8c4478bef6d57fcfda15dae461ea3c9f06aa7b257c58df3f2300174ccbb185a", size = 539874, upload-time = "2026-10-11T01:01:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0c/dfa33aecc7d4d1dc75e05248f5eac5a0edf4d09e7b44d93ab62529b0c1db/aiohttp-3.14.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c2c30484dd1417ef98b51021ffa2cc0d7f3c78918adaaaab7e70817335ab3e02", size = 813836, upload-time = "2026-10-11T01:01:04.01Z" }, + { url = "https://files.pythonhosted.org/packages/33/17/4a63738052d20567d55529d6daa1b9480d906fd52930fbcf6d3fbed618f0/aiohttp-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dab9ac5a67c8d1f070c00fa8fccb7cbd1b8dcc1a8d6b42f37540df9b3d4cc603", size = 548070, upload-time = "2026-10-11T01:01:06.035Z" }, + { url = "https://files.pythonhosted.org/packages/15/e5/b57e58695a757fd4c02497c033fced96a69c631b13866c43d336530c9670/aiohttp-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e1cc2bfaee8c214f06080a7c7d5772419b8a1108e8e5349236189811823fb02a", size = 545786, upload-time = "2026-10-11T01:01:07.817Z" }, + { url = "https://files.pythonhosted.org/packages/9a/68/8c2c67a3aedf46e00f3c42f04fbc6983de80d4ed5786151e33681ba45883/aiohttp-3.14.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74efb69332b85675b1eabd760a8cfc2e2cf42c60607c66f88014c1bdfb40942d", size = 1886492, upload-time = "2026-10-11T01:01:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4b/74aab5e8d28c62e8f795b4fe8f38cf5586fd264a9a27bd2141ef6490333d/aiohttp-3.14.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42b5e616946dbaf505e2bff18c9af2cd4ef9e7ef300ee58a6e951a5b7cf147ae", size = 1870057, upload-time = "2026-10-11T01:01:12.045Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f7/eafc3b1988302b1815d9fd4a21071be5c360d616c0a430d02fd92dc97688/aiohttp-3.14.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f9033b43f511f27547c557dcaba0177649e10a3725336ccd2cce0fdc1dc4850d", size = 1925119, upload-time = "2026-10-11T01:01:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/a1/04/78d8f294f74dd570f3898ff20402349fce524176045df98ba727d6846a68/aiohttp-3.14.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15a310d3c71398e3d7bfc93a1a73fbe664315cd9e9016b8efc1cff85eeab7155", size = 2053137, upload-time = "2026-10-11T01:01:16.344Z" }, + { url = "https://files.pythonhosted.org/packages/32/51/395d225ef36f5a50d8e548dcd3141bfdbcd31fb6eed859022c573d2c4d66/aiohttp-3.14.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ffa3a523a36d8628f98c06492ae16a31a23d14c0b4ec721757b477319f656d6", size = 1911038, upload-time = "2026-10-11T01:01:18.653Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a4/2aec1aa06d82e8a244843b5dae31d78061e5e76744270a86dd0ee051c889/aiohttp-3.14.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fb6a6e919bfb703227bc1ce6579281b84b1a2ba57deb9794dfdbec7dcd1e40c", size = 1702205, upload-time = "2026-10-11T01:01:20.904Z" }, + { url = "https://files.pythonhosted.org/packages/16/27/6051bfde7b6f418f70edd60d655fa426abb3355fa0981764739d87ecf160/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43e1b7994a8b038125f722bff07492ef501110722c2727c408995d9fb864c421", size = 1848588, upload-time = "2026-10-11T01:01:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/44/55efc06fc26c4e6e1c095f231b4c222bf2d86d64a8eebbc24b2bb5958ea8/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5f3e96071686755d9cd3600c3880183eb94b012178e92746d68101800f0ed8a3", size = 1887540, upload-time = "2026-10-11T01:01:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/dc/1502bfdc2a65760d386ac6a00090b0addaa8a3c9c60b3f8127fad3a9afb2/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:cd88b01f3d37b7a2a34f91d98f14720206f1ea3d540843fab2d649dd5fb91fec", size = 1895030, upload-time = "2026-10-11T01:01:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/93/7e/44174bb6288264418c9eec07a5e35180969c0d5a796c7af544db3cb8a33a/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ed8b64dc0c651c0f5a9c926777719770021251b8f336d97c4b80b660836ce1", size = 1693244, upload-time = "2026-10-11T01:01:29.39Z" }, + { url = "https://files.pythonhosted.org/packages/47/dd/b507d64e50db23888582fff08eda13998b12f9dea70c072edaac19218380/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a9918e58faf62ba2c7147927d06057aec78f42475aff5048047ec47e7265a600", size = 1926308, upload-time = "2026-10-11T01:01:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/5b51b4f63e3f2793151f4aea49c48fe1e00baeb7cec9c7a206de499f8de0/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:42f320d4a5b00b9af0bddcfec5407dc6f2d9816f006b2f79ebbaa31f16895df3", size = 1888406, upload-time = "2026-10-11T01:01:33.715Z" }, + { url = "https://files.pythonhosted.org/packages/ff/13/d5e818a5eaba9f822016727299f41c0e1075799f82a6433ec508a93ab867/aiohttp-3.14.5-cp313-cp313-win32.whl", hash = "sha256:3ae800a20947e2c2e53088047d021e6bf7d51560cc49f6a0737a1f79d2e3a13c", size = 479970, upload-time = "2026-10-11T01:01:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d0/8eca2c65aa467320990d78fb2005f38ed3588944280c39ff9deb6423fef1/aiohttp-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:d05e94cdfe0d15d0206f970722d2554780ce562787b21b218b275447f8751319", size = 505985, upload-time = "2026-10-11T01:01:37.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f8/4cdd65305d2fca14b886bea9ed2abb1fe726287872524e56e3d26692b47d/aiohttp-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:f001b571ead90ca1770f1e616db255351a1703317f20374c361ef22f12c06d09", size = 487629, upload-time = "2026-10-11T01:01:39.477Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/3184a1d34a8be665569eadb7e9e764b4629e4f3413e241cb2e4d6fecf3b3/aiohttp-3.14.5-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:939042d5cda21d41a6f512e7cc8b8e33a2aebff863352251da495fbd91b673b5", size = 540673, upload-time = "2026-10-11T01:01:41.354Z" }, + { url = "https://files.pythonhosted.org/packages/60/2a/d35f3ba4cf157b072e3b674bf9983047ca5ea5173c995d32d877e1191d36/aiohttp-3.14.5-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6da32b5ff3fd78d244e37300463434c7145162bfd2b6e9e915ab164da37f7343", size = 554087, upload-time = "2026-10-11T01:01:43.719Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d2/61a33880ca4eaca95a9c60ca3f6beed15555af1028652dfaac601627787b/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b438b73c38111818d0c9d6a5c2bfed8584c8e503a49ef085d70e874ec846738", size = 522613, upload-time = "2026-10-11T01:01:46.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/de579b299d2225dc2c6fd99d579d91f16c02a913fb5af9a3cf2fe9bd88ba/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:755933b107ea7a6a9ac916f635a70595a5b1a32fac10a8ff0b9f2ab88555550c", size = 529682, upload-time = "2026-10-11T01:01:48.477Z" }, + { url = "https://files.pythonhosted.org/packages/f4/4a/ddb923564e15e053b6e060b0036e1694dcadcb13aa476c5a87dcad20e336/aiohttp-3.14.5-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b3cc509327c7b27f6f4727a8830f4004f6df7766e179f2f4b8e54e65c0bec5d3", size = 540120, upload-time = "2026-10-11T01:01:50.474Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/a640fbecaa53727a5900b892bdbe17b5f3e8cc88903e22864fe41b654def/aiohttp-3.14.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7bd8ac754ebd6733a3e2a0dd1674c4d8ab086196803fd8dcd776f07b4e2607d9", size = 818795, upload-time = "2026-10-11T01:01:52.665Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b6/d52ca608859e271b5fa7944074802dc45f60e52c318a4ddc34edbf73586e/aiohttp-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e724a7b6091f0b1ac064f9d1b15ff9ec52e6033a86cdae649e5f086e32a3c0db", size = 549667, upload-time = "2026-10-11T01:01:54.65Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/05b8ac39a76ff4bca89f42a4c2471560c71f894ff6e4bc16158c951874e3/aiohttp-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c32e26310cc10e547f53cd13d39a369034f69dcb7d749d5cb0e5f67bc196b6ba", size = 548922, upload-time = "2026-10-11T01:01:56.547Z" }, + { url = "https://files.pythonhosted.org/packages/19/b0/5aa186d56ce2334dabe29b70bd99dc8ae926ee44184c0de64a53d415a4f3/aiohttp-3.14.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1eb8167961ec4dfcc8cb9dd50bd0ee72519f7ef496be95203e49e27b01618382", size = 1882422, upload-time = "2026-10-11T01:01:59.258Z" }, + { url = "https://files.pythonhosted.org/packages/db/f7/7d5c91bb9620db300c8ddb05337a9014301acb626223faff3abcb8ea47d7/aiohttp-3.14.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c1d60eafd9c7e8e74abd03a5b00df44e7febfe6d9b89b559c0a6551eef0699d4", size = 1843621, upload-time = "2026-10-11T01:02:01.417Z" }, + { url = "https://files.pythonhosted.org/packages/30/0a/b208953b96d8f24b75f6da704f508e6c5cf3022f52b60c61933df082e89c/aiohttp-3.14.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:137351bf20bbed9a65e839f4a4452ac377389bdb2f2857d2acffef38f5e9f2d1", size = 1927915, upload-time = "2026-10-11T01:02:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/f6/79/90ebcccb55e2d1e11a1fed581d83bb966e38fb35fb4b2577fdc980f8707a/aiohttp-3.14.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fba47bc2c3d7303c3d027c6cf4d07626c37b1314ac81f5820c31032e0ca1f677", size = 2036339, upload-time = "2026-10-11T01:02:06.046Z" }, + { url = "https://files.pythonhosted.org/packages/a6/66/55a8904b3a129fafdf94f9cc0a2e4ca09a3c914650be52355db7ad0bbdb6/aiohttp-3.14.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94684b879ac1d71e4238850c99b62dc1b28d9086b156a2555f082010b85a865c", size = 1888041, upload-time = "2026-10-11T01:02:08.384Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b8/96b25da7329a52e42c812b1e8b076386039ec4fc312afa043d173d8147fc/aiohttp-3.14.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56572c42e3ecd636de8d2c3dd54cf5fc939cb5c32eb56297f176a0d366fac622", size = 1703849, upload-time = "2026-10-11T01:02:10.903Z" }, + { url = "https://files.pythonhosted.org/packages/1b/43/fbf976e3ae4c038d6f5c84945ab2150298c2d71201674d4e53d158063e75/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a95529a92a446db351675f4aab518feaf5e99842f63f5dd17160c2b74f382db3", size = 1849703, upload-time = "2026-10-11T01:02:13.15Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7d/218e912f4c1d89bde7ac551409be57ad2f6121638e56942d395a7cb1fa58/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:3edbece0379b8b4aaa67619b8aa2399bb66fce372cd5911098a434ea77220aa0", size = 1852695, upload-time = "2026-10-11T01:02:15.295Z" }, + { url = "https://files.pythonhosted.org/packages/5a/42/252a1b9287e3b6e393a1c3bd1776f36af30f5f25f071bbf2b0cb7eba9116/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:56d9828f204331a5ca8850fcfe2bcce95a149f1f223f60cc7216e5524978e480", size = 1893493, upload-time = "2026-10-11T01:02:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/78/97/71cae83d5100556fad1521684f7cd1e3e578432644f850245ed3bd969310/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:20064a177a070d789ee64a50b01a9161d3468e989baacfc6c714aa685c4b332f", size = 1693468, upload-time = "2026-10-11T01:02:20.666Z" }, + { url = "https://files.pythonhosted.org/packages/1f/69/73d88e97a8b5f0ca7a946d0011c0de99fb188b1687c7948ecd0553dc5bf0/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:81c2b3dfd56c62bee6108e4852d5970b4cf9086390b6983f52b666e878c1f115", size = 1915398, upload-time = "2026-10-11T01:02:23.011Z" }, + { url = "https://files.pythonhosted.org/packages/05/f0/881644bcb15d4b258daea9b720a0af9dc4330496cc8d6ade9090cdd0cffc/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:09ec102b4b8c9a920275733bbc11fdbb615efe6f9231a06007c0218d336fb77a", size = 1871470, upload-time = "2026-10-11T01:02:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/7f/de/19d9ebbcce5aedaa3242d8a99ff8816a60bdb7629fb0084bf4a45bfd1f62/aiohttp-3.14.5-cp314-cp314-win32.whl", hash = "sha256:9c428eb2bd8817588d16a0ab898aa4eb5d141f896aa2b394cc79a4cf61d9a8e2", size = 485517, upload-time = "2026-10-11T01:02:27.579Z" }, + { url = "https://files.pythonhosted.org/packages/a9/74/8cdaf0e58c2588371670d5a9a8215bbb971d36940b6e5d051967dd05c07d/aiohttp-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:6f967dde489ca6a8c02d093ab245d2cbf50ccb5c36adf0188b17b0ca39d24b67", size = 511755, upload-time = "2026-10-11T01:02:29.685Z" }, + { url = "https://files.pythonhosted.org/packages/1a/6b/e0100e25502430a531c7cf1482a378d0b65bf728ab60c01ee270e56bc469/aiohttp-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:1d2d981b53dd09a319e3570ef8cc3bbc3ef86f5a7abef0f6b2bff3867db3a9e7", size = 495151, upload-time = "2026-10-11T01:02:32.163Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c2/ca2ead7b655688c53c03aeeb6e96e6851c9ff08d6be7b13802f53a6ae8fd/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9ce66feae6ac65327379460380549bf1b8df8e17c4e25df2a2bcf168272e3bed", size = 855396, upload-time = "2026-10-11T01:02:34.443Z" }, + { url = "https://files.pythonhosted.org/packages/a7/70/22206fea409255a240c926ce11de48de354ae2bb90ca44f709c05497585d/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:27c2322e03f66101acb09869ce1cf1efc04994ee95e1735b69827bf8c8b9d781", size = 567734, upload-time = "2026-10-11T01:02:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e5/79a36c118308b56f8667d67e05d2fb6dc638ab45985704cdb199631bedaa/aiohttp-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff75a7537413a86e7cafe98e0e1d6e3dc4b15c6349896e7d5c6b881bfdb6d550", size = 567379, upload-time = "2026-10-11T01:02:38.757Z" }, + { url = "https://files.pythonhosted.org/packages/63/a3/2ebec7dece3b1f02c30d2e484647f6f7b13952b1bb40a4cb285b849e8432/aiohttp-3.14.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c061aa954daaf57d2a4b8374f9fca621ef0e1b603584431c220c22458c59b6d", size = 2050686, upload-time = "2026-10-11T01:02:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d2/7e4d093db2f4450482652e7ef19a9e19919028f5135aa52bc4078c3beb80/aiohttp-3.14.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1612fa5857b37bf32e5c1eaeefb96e3b01e9c70679eec81f0934e8a600080863", size = 1915077, upload-time = "2026-10-11T01:02:43.563Z" }, + { url = "https://files.pythonhosted.org/packages/a6/88/bd40d09958442a0a1df67da81de496361d6e2afc04f9db2950d835da750b/aiohttp-3.14.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:adbeee7d6fd4cf5fe0aece2fb3edc4243615d3180430ba8149d01a90670cac99", size = 2027204, upload-time = "2026-10-11T01:02:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/3a601c1f8d3103c1a60ea981f20924855da9f575d2007fc38f8898b792fb/aiohttp-3.14.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b2966998927d7bed9db12c0a4647b0c7b179755878fc9c357fe1ffd3e3b0c1a5", size = 2149520, upload-time = "2026-10-11T01:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/22/d0/4e41bfe1b1ce1cb6f6d2e59fa7a88ef5cf92c402b2d07e9018778ba9edbc/aiohttp-3.14.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e317e0fb6b16212c881d2205a7d87414c29acd69320b3aa6dce9d9c7b86fe4f", size = 2002039, upload-time = "2026-10-11T01:02:51.293Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/72067019545c502b881b031153c437752ecef48d7d213bc0218ccebb4bfb/aiohttp-3.14.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50343c1757b4b6f6708eeaf24534b32f19dfb99fb1b762c00420867a62fc81e0", size = 1786164, upload-time = "2026-10-11T01:02:53.533Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fe/7741efd6119bfb7a00827fe6f7b84b4409de58d888ae21adc5a9a6824992/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:083673c7a94c3ea035caaa5ca04288bdb44887abfe1f5ba23294e6a4b03efd2d", size = 1970689, upload-time = "2026-10-11T01:02:55.888Z" }, + { url = "https://files.pythonhosted.org/packages/43/e7/342a13bf67f34d269bf2f7e870ecd72b99c832cc6f0a271a9210c0ebfb84/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2528cb4c6b92008c76ac9ac6298624069bb2db91ff4929905512d1d84485f658", size = 1951967, upload-time = "2026-10-11T01:02:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d4/fdb3b27340617e5e64df18a70fa89097778652ea5c7c7e2f79def76999c3/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b1b8ece1e71132d2afba4dbc0c3d62c766e25165990b25db1196c04969eb3d84", size = 1968511, upload-time = "2026-10-11T01:03:00.883Z" }, + { url = "https://files.pythonhosted.org/packages/83/b2/e8f88298de78d1a951f36f9f966d38ec6ed1d4721303ba02064a545e8aa6/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fce9523df31cea6284f3e2c479876750d7687cf671d7b25d32b19effc0e86441", size = 1778497, upload-time = "2026-10-11T01:03:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/22/68/9ccdb93d664345c546be7f34480b921774d8c0d98f47e70d7e03b115d475/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:09e0eb18c7e0c8777e2f9149de63799195b9b3ca1b5c81ba6f32f2c6b8628210", size = 1999676, upload-time = "2026-10-11T01:03:05.829Z" }, + { url = "https://files.pythonhosted.org/packages/57/4a/a33cfa6dcb00e94194ae4fe710432ca4ed111e016356b4ba4d2f4c3a124c/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6774814fd5c338e72ee0da5cbb9432816df450e69c019f72b5d29bdec2a1792d", size = 1963437, upload-time = "2026-10-11T01:03:08.196Z" }, + { url = "https://files.pythonhosted.org/packages/f4/20/eacbecfea3b5c3dcbfc9b023e3a5460f43e5dda16d06a2877ebe7184c3f3/aiohttp-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:33f706574e32c6e694f352a856e05caf18f7f2c871b3e87b41c55ea452b409ab", size = 503973, upload-time = "2026-10-11T01:03:10.481Z" }, + { url = "https://files.pythonhosted.org/packages/ff/78/18eec294f6c8c5dc845dcf6d730a0147d8d0f17e86138a7bdb85e43a30fa/aiohttp-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5ba14a839fbe87cf7c12a6b5661c05f324a296eb8363141edb3944ba63d4c9d3", size = 531190, upload-time = "2026-10-11T01:03:12.716Z" }, + { url = "https://files.pythonhosted.org/packages/9d/39/e53f8169acc85271ebd12b5b32ad7f1541b35639ccbe0f49034c64785d10/aiohttp-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:1061b364556e8172e8d46b0b183adeeb73e8c42d30ebc745591e1bd89acad52e", size = 510360, upload-time = "2026-10-11T01:03:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" }, ] [[package]]