From b0f7fc2eab0e2258a53856eea8ac11efa9b127cf Mon Sep 17 00:00:00 2001 From: jdmnk <5247767+jdmnk@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:28:50 +0000 Subject: [PATCH 1/3] Maintain Codex image backend compatibility and harden CLI reliability --- .github/workflows/ci.yml | 12 +- CHANGELOG.md | 26 + CONTRIBUTING.md | 16 +- README.md | 338 +++----- audit/MAINTENANCE.md | 72 ++ audit/REPORT.md | 172 ++++ audit/evidence/live-images.json | 42 + .../evidence/maintenance-dependency-scan.json | 4 + audit/evidence/maintenance-live-images.json | 29 + audit/evidence/maintenance-live-tests.txt | 12 + audit/evidence/maintenance-offline-tests.txt | 3 + audit/evidence/native-request-summary.json | 40 + audit/evidence/outdated-packages.json | 1 + audit/evidence/pytest.txt | 132 +++ audit/evidence/pytest.xml | 85 ++ audit/evidence/request-event-summary.json | 14 + audit/evidence/upgraded-scan-summary.json | 4 + audit/evidence/vulnerability-summary.json | 208 +++++ codex_imagegen_cli/__init__.py | 2 +- codex_imagegen_cli/cli.py | 756 ++++++++++++----- pyproject.toml | 19 +- tests/conftest.py | 19 + tests/test_cli.py | 46 +- tests/test_controls.py | 112 +++ tests/test_live.py | 107 +++ tests/test_maintenance.py | 503 +++++++++++ tests/test_regressions.py | 218 +++++ uv.lock | 796 ++++++++++-------- 28 files changed, 2971 insertions(+), 817 deletions(-) create mode 100644 audit/MAINTENANCE.md create mode 100644 audit/REPORT.md create mode 100644 audit/evidence/live-images.json create mode 100644 audit/evidence/maintenance-dependency-scan.json create mode 100644 audit/evidence/maintenance-live-images.json create mode 100644 audit/evidence/maintenance-live-tests.txt create mode 100644 audit/evidence/maintenance-offline-tests.txt create mode 100644 audit/evidence/native-request-summary.json create mode 100644 audit/evidence/outdated-packages.json create mode 100644 audit/evidence/pytest.txt create mode 100644 audit/evidence/pytest.xml create mode 100644 audit/evidence/request-event-summary.json create mode 100644 audit/evidence/upgraded-scan-summary.json create mode 100644 audit/evidence/vulnerability-summary.json create mode 100644 tests/conftest.py create mode 100644 tests/test_controls.py create mode 100644 tests/test_live.py create mode 100644 tests/test_maintenance.py create mode 100644 tests/test_regressions.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a17505..9b0f069 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,17 +15,17 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.14"] + python-version: ["3.10", "3.12", "3.14"] steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Install uv uses: astral-sh/setup-uv@v7 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} @@ -36,13 +36,15 @@ jobs: run: uv sync --frozen --dev - name: Lint - run: uv run ruff check . + run: | + uv run ruff check . + uv run ruff format --check . - name: Test run: uv run pytest - name: Build package - run: uv run python -m build + run: uv run python -m build --installer uv - name: Check package metadata run: uv run python -m twine check dist/* diff --git a/CHANGELOG.md b/CHANGELOG.md index bb278f8..a546855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ All notable changes to this project are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project uses semantic versioning before its first stable release. +## [0.2.0] - 2026-09-15 + +### Changed + +- Native Codex image generation/edit endpoints are now the default, following the Codex request format. The backend controls the actual image model. +- The original streamed backend remains available with `--backend responses`. Reasoning `--model`/`--profile` apply only to that backend and do not select the image generator. +- Remove the experimental `--image-model` flag and `CODEX_IMAGEGEN_IMAGE_MODEL` override: live requests also accepted a nonexistent model name without returning model identity, so model selection could not be verified. +- Explicit dimensions are validated against image-size constraints. Actual dimensions are reported; `--size-policy warn|error` controls mismatches without resizing. +- Unsupported transparency on the default path is rejected before contacting the backend. +- Refresh Pillow, the publishing toolchain and all locked dependencies; constrain Hatchling to the tested minor release and update CI actions/Python coverage. + +### Fixed + +- Preserve raw JWT ID tokens during refresh; write credentials atomically with restricted POSIX permissions, coordinate imagegen refreshes and detect external credential changes. +- Validate and atomically save complete PNG/WebP outputs; protect existing files on failures and publish each successful path immediately. +- Apply EXIF orientation before edit-input compaction and reject corrupt inputs. +- Parse TOML model/profile settings correctly; explain file-only credential support. +- Normalize batch inputs, reject non-string prompts and escaped outputs, and honor continuation for per-job validation failures. +- Handle CRLF and split UTF-8 in SSE, terminal response results, truncated transport reads, HTTP 429/Retry-After and rate limits after authentication refresh. +- Validate positive finite timeouts and present expected filesystem failures as CLI errors. + +### Added + +- Promote audit regressions to the normal suite and add native-backend, concurrency, persistence, output and retry coverage. +- Add opt-in live generation/edit compatibility tests that never refresh shared credentials. + ## [0.1.0] - 2026-06-01 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43b3b8a..5561299 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Contributions are welcome when they improve the CLI, Codex-auth image backend be ```bash uv lock --check -uv sync --dev +uv sync --frozen --dev uv run pytest uv run ruff check . ``` @@ -21,10 +21,20 @@ Before opening a PR, run: uv lock --check uv run pytest uv run ruff check . -uv run python -m build +uv run python -m build --installer uv uv run python -m twine check dist/* ``` ## Project Boundaries -`codex-imagegen-cli` mirrors the stable Codex ChatGPT image-generation path from openai/codex. It should not require `OPENAI_API_KEY`, shell out to `codex exec`, print auth tokens, or depend on a locally installed Codex skill. +`codex-imagegen-cli` tracks the private Codex ChatGPT image-generation paths from openai/codex. It should not require `OPENAI_API_KEY`, shell out to `codex exec`, print auth tokens, or depend on a locally installed Codex skill. + +## Compatibility coverage + +Use Python 3.10, 3.12 and 3.14 for release checks. Normal tests must never read real credentials or perform network requests. Codex login compatibility tests use synthetic tokens in a temporary Codex home. + +Run `CODEX_IMAGEGEN_LIVE_TEST=1 uv run pytest -m live -v` only when you intend to spend account usage on three image requests. The check disables credential refresh and backend retries. No server is needed. + +When updating dependencies, run `uv lock --upgrade`, the tests, Ruff lint/format checks, and both artifact checks. The build-system Hatchling range is deliberate: update it alongside a compatible Twine/Packaging toolchain. Review published advisories for locked runtime and development dependencies. + +`--model`/`--reasoning-model` controls only the Responses reasoning model. Do not expose an image-model selector without evidence that the backend honors it and reports model identity. Do not silently retry a failed image through a different backend or resize an output to hide a backend mismatch. diff --git a/README.md b/README.md index 2408391..efd82fa 100644 --- a/README.md +++ b/README.md @@ -1,325 +1,243 @@ -
- # 🎨 codex-imagegen-cli -**Generate and edit images from your terminal using [Codex](https://github.com/openai/codex)'s built-in image tools — no API key needed.** - -Codex (OpenAI's CLI coding agent) has image generation capabilities built in. This CLI exposes them as a standalone scriptable tool, reusing your existing Codex ChatGPT login. Supports `generate`, `edit`, and `batch` modes out of the box. +Generate and edit images from your terminal using your existing **Codex ChatGPT login**. No OpenAI API key is required. -[![Python](https://img.shields.io/badge/python-3.10%2B-blue?logo=python&logoColor=white)](https://www.python.org/) -[![License](https://img.shields.io/badge/license-Apache%202.0-green)](LICENSE) -[![Status](https://img.shields.io/badge/status-alpha-orange)](CHANGELOG.md) -[![uv](https://img.shields.io/badge/uv-ready-purple?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyem0tMSAxNHYtNEg3bDUtOHY0aDRsLTUgOHoiLz48L3N2Zz4=)](https://github.com/astral-sh/uv) +The CLI supports `generate`, `edit`, style references, sequential JSONL batches, and PNG/WebP output. It calls Codex's authenticated backend directly. This is an **unofficial, private backend integration**, whose availability and behavior can change. -
+## Install ---- +Requirements: Python 3.10+, [uv](https://docs.astral.sh/uv/), and file-based Codex ChatGPT authentication. -```text -$ codex-imagegen generate \ - --prompt "A 32x32 retro RPG pixel icon of an iron helmet, no text" \ - --out output/icons/iron-helmet.png +```bash +git clone https://github.com/jdmnk/codex-imagegen-cli.git +cd codex-imagegen-cli +uv sync --frozen --dev +uv run codex-imagegen --help ``` -Uses your existing **Codex ChatGPT login** — no `OPENAI_API_KEY` required. +To install a user command from this checkout: -## Why this exists +```bash +uv tool install -e . +``` + +Both `codex-imagegen` and `codex-imagegen-cli` are entry points for the same command. -[Codex](https://github.com/openai/codex) (OpenAI's CLI coding agent) has image generation built in — but it's prompt-driven and agentic, not a scriptable command. There is no `codex image generate` subcommand, no `--size` or `--quality` flags, and the documented automation primitive (`codex exec`) emits assistant text, not image bytes. +### Authentication -This CLI fills that gap by calling Codex's backend directly, using your existing ChatGPT subscription auth. No `OPENAI_API_KEY` needed, as it's not API billing - just the same image generation Codex uses internally, exposed as a scriptable tool for `generate`, `edit`, and `batch` workflows with clear options for **model**, **size**, **quality**, **background**, and **output**. +The CLI reads `$CODEX_HOME/auth.json` or `~/.codex/auth.json`. Override this with `--codex-home PATH` or `--auth-file PATH`. -## Requirements +Codex may store credentials in an OS keyring or only in memory. This CLI supports **file-based ChatGPT credentials only**. If needed, set this in your Codex `config.toml` before logging in: -- Python 3.10+ -- `uv` -- Codex logged in with ChatGPT auth +```toml +cli_auth_credentials_store = "file" +``` -Run Codex login once if needed: +Then run: ```bash codex login ``` -Choose the ChatGPT login flow. The CLI reads `$CODEX_HOME/auth.json` or `~/.codex/auth.json`, refreshes tokens when needed, and never prints token values. - -## First Run +For a headless machine, Codex also supports `codex login --device-auth`. See [Codex authentication](https://learn.chatgpt.com/docs/auth). -Use the CLI from a local checkout: +API-key-only sessions, keyrings, ephemeral credentials, and enterprise access-token authentication are not implemented. An API key in your environment does not change this CLI to API billing. Treat `auth.json` as a secret; never commit it or include it in bug reports. -```bash -git clone https://github.com/jdmnk/codex-imagegen-cli.git -cd codex-imagegen-cli -uv sync --dev -``` +Token refresh preserves Codex's raw JWT format and writes the file atomically with owner-only permissions on POSIX. A separate lock coordinates imagegen processes. Codex itself does not share that lock: the CLI detects observed external auth changes and refuses to overwrite them, but cannot guarantee coordination with every external writer. -Verify the command: +## Quick start ```bash -uv run codex-imagegen --help -uv run codex-imagegen --version +uv run codex-imagegen generate \ + --prompt "A retro RPG pixel icon of an iron helmet, no text" \ + --out output/iron-helmet.png ``` -Run a dry run: +Inspect the planned request without reading credentials or contacting the backend: ```bash uv run codex-imagegen generate \ - --prompt "A 32x32 retro RPG pixel icon of an iron helmet, no text" \ - --out output/icons/iron-helmet.png \ + --prompt "A studio photo of a mug" \ + --out output/mug.png \ --dry-run ``` -To install it as a user command from the checkout: - -```bash -uv tool install -e . -``` - ## Generate ```bash codex-imagegen generate \ - --prompt "A 32x32 retro RPG pixel icon of a steel longsword with a blue grip, no text" \ - --out output/icons/steel-longsword.png -``` - -Request multiple images: - -```bash -codex-imagegen generate \ - --prompt "A 32x32 retro RPG pixel icon of a bronze shield with a red gem, no text" \ - --out output/icons/bronze-shield.png \ - --n 3 -``` - -Multiple outputs are written as `bronze-shield-1.png`, `bronze-shield-2.png`, and so on. - -Write WebP directly by using a `.webp` output path: - -```bash -codex-imagegen generate \ - --prompt "A 32x32 retro RPG pixel icon of a bronze shield with a red gem, no text" \ - --out output/icons/bronze-shield.webp \ + --prompt "A bronze shield icon with a red gem, no text" \ + --out output/shield.webp \ + --n 3 \ --webp-quality 82 ``` -## Edit - -```bash -codex-imagegen edit \ - --image input/icons/iron-sword.png \ - --prompt "Turn this into a fire-enchanted sword icon while keeping the same retro RPG pixel-art style, silhouette, and no text" \ - --out output/icons/fire-sword.png -``` +This makes one request per output, sequentially, writing `shield-1.webp`, `shield-2.webp`, and `shield-3.webp`. -Edit accepts one to five `--image` inputs, matching the current Codex image tool limit. +Successful paths are printed to stdout as soon as each file is saved. Progress, actual dimensions and warnings go to stderr. If a later output fails, earlier files remain and their paths have already been printed. Existing files are protected unless `--force` is set. -### Style Reference Edits - -Use `--style-image` when you want to keep the idea/content from one image but render it in the style of another image. +## Edit and style references ```bash codex-imagegen edit \ - --image input/profile.png \ - --style-image input/style-reference.png \ - --out output/profile-in-style.webp + --image input/sword.png \ + --prompt "Make this a fire-enchanted sword; preserve its silhouette" \ + --out output/fire-sword.png ``` -In this mode, `--image` is the content image and `--style-image` is only the style reference. The CLI writes a clear edit instruction for Codex: preserve the subject, identity, composition, proportions, and important details from `--image`; borrow the medium, rendering approach, palette, lighting, texture, finish, and mood from `--style-image`; do not copy the style reference's subject or scene. - -`--prompt` is optional with `--style-image`. If you include it, it is treated as extra guidance: +Use one to five `--image` inputs. For a style reference: ```bash codex-imagegen edit \ --image input/profile.png \ - --style-image input/editorial-lighting.png \ - --prompt "keep the background bright and professional" \ - --out output/profile-editorial.webp + --style-image input/editorial-style.png \ + --prompt "Keep the background bright" \ + --out output/profile.webp ``` -The style image counts toward the same one-to-five edit image limit. +With `--style-image`, the prompt is optional extra guidance. Content images are sent first, and the style reference is sent last. The generated instruction preserves the first image's subject and composition and borrows visual style from the final image. The style reference counts toward the five-image limit. -## Input And Output WebP +Input images are decoded, corrected for EXIF orientation, resized to a maximum edge of 1536 pixels and encoded as WebP at quality 90. Original files are not changed. Use `--input-max-edge 0` to disable resizing; WebP encoding still occurs. Invalid input images fail locally instead of uploading undecodable original bytes. -The CLI uses WebP in two different places: +PNG output is validated before saving. WebP output is converted locally with Pillow. Files are published atomically after validation; a failed conversion or rejected size cannot truncate an existing output. -- **Input WebP for edits.** When you run `edit`, local input images are resized if needed and converted to WebP before they are sent to Codex. This makes large PNG/JPEG files much smaller in the request, which helps avoid broken uploads and reduces pressure on Codex's input-image quota. Your original file is not modified. -- **Output WebP for saved files.** Codex returns PNG image bytes. If `--out` ends in `.webp`, or you set `--output-format webp`, the CLI converts the returned PNG to WebP locally before saving it. +## Size, quality and background -Example: +- `--size auto` is the default. Explicit `WIDTHxHEIGHT` values require dimensions divisible by 16, no edge above 3840, an aspect ratio between 1:3 and 3:1, and 655,360–8,294,400 pixels. Examples: `1024x1024`, `1536x1024`, `2048x2048`, `3840x2160`. +- **Requested dimensions are not guaranteed.** The authenticated backend has returned different sizes even for explicit requests. The CLI reports the decoded dimensions. +- `--size-policy warn` (default) warns and saves the backend's original dimensions. +- `--size-policy error` fails without writing that output if explicit dimensions differ. The generation has already occurred and may have consumed usage. Existing output is preserved even with `--force`. No automatic resizing or cropping occurs. +- `--quality auto|low|medium|high` is passed to the backend. +- `--background auto|opaque` works with the current default path. Explicit transparent backgrounds are unsupported on the tested Codex image backend, so the CLI rejects `--background transparent` locally. +- `--output-format auto|png|webp` controls local output. `auto` chooses WebP for `.webp` paths, otherwise PNG. An explicit format overrides the extension. + +For a script that requires exact dimensions: ```bash -codex-imagegen edit \ - --image input/profile.png \ - --prompt "make it more polished" \ - --out output/profile.webp +codex-imagegen generate \ + --prompt "A clean landscape illustration" \ + --size 1536x1024 \ + --size-policy error \ + --out output/landscape.png ``` -In that command, `input/profile.png` is compacted to WebP for the request, and `output/profile.webp` is saved as WebP. - ## Batch -Batch mode runs JSONL jobs sequentially. +Create a JSONL file with one prompt string or job object per line: -```bash -mkdir -p tmp/imagegen -cat > tmp/imagegen/jobs.jsonl <<'EOF' -{"prompt":"A 32x32 retro RPG pixel icon of an iron helmet, no text","out":"iron-helmet.png"} -{"prompt":"A 32x32 retro RPG pixel icon of leather boots with tiny silver buckles, no text","out":"leather-boots.png"} -{"prompt":"Make this shield look ice-enchanted while keeping the same retro RPG pixel-art style","images":["input/icons/wooden-shield.png"],"mode":"edit","out":"ice-shield.png"} -EOF - -codex-imagegen batch \ - --input tmp/imagegen/jobs.jsonl \ - --out-dir output/imagegen/batch +```jsonl +"An iron helmet icon" +{"prompt":"A bronze shield icon","out":"shield.webp"} +{"prompt":"Make this shield icy","images":["input/shield.png"],"mode":"edit","out":"ice-shield.png"} ``` -Each line can be either a JSON string prompt or an object with: - -- `prompt`: required text prompt -- `out`: optional output filename, resolved under `--out-dir` -- `images`: optional list of image paths for edit jobs -- `mode`: optional `generate` or `edit`; defaults to `edit` when images are present +```bash +codex-imagegen batch --input jobs.jsonl --out-dir output/batch +``` -## Useful Flags +Job fields: -- `--prompt-file prompt.txt`: read the prompt from a file -- `--force`: allow replacing existing output files -- `--cd PATH`: base directory for resolving relative paths -- `--auth-file PATH`: read a specific Codex `auth.json` -- `--codex-home PATH`: read auth from another Codex home directory -- `--model MODEL`: override the Codex reasoning model used by the direct hosted image tool. -- `--base-url URL`: override the Codex backend URL for development -- `--background auto|transparent|opaque`: direct `background` parameter. -- `--quality auto|low|medium|high`: direct `quality` parameter. -- `--size auto|1024x1024|1536x1024|1024x1536`: direct `size` parameter. -- `--style-image PATH`: edit mode only. Use this image as the style reference; `--image` stays the content image and `--prompt` becomes optional extra guidance. -- `--output-format auto|png|webp`: output file format. Default: infer from `--out`; `.webp` writes WebP, everything else writes PNG. -- `--webp-quality 1..100`: WebP encoder quality. Default: `85`. -- `--input-max-edge PIXELS`: resize edit input images before upload so the longest edge is at most this value. Default: `1536`; use `0` to disable resizing. -- `--input-webp-quality 1..100`: WebP quality for compacted edit input images. Default: `90`. -- `--n COUNT`: request multiple output images. The CLI runs one hosted image request per output. -- `--dry-run`: print the request shape without reading auth or contacting the backend +- `prompt`: required non-empty string. +- `out`: optional non-empty filename/path under `--out-dir`; paths escaping that directory are rejected. +- `images`: optional list of paths; `null` is treated as an empty list. +- `mode`: `generate` or `edit`; inferred from whether images are provided when omitted. -## Backend Path +Relative input-image paths are resolved against `--cd` or the working directory. Blank lines and lines starting with `#` are ignored. Whole-file JSON and basic schema errors are rejected before execution. Per-job mode/path/backend failures are reported and later jobs continue, unless `--fail-fast` is set. Any failed job produces exit status 1. Batch-wide image settings apply to all jobs. -The CLI calls Codex directly through the enabled hosted image-generation path: +## Backends and reasoning-model selection -`POST https://chatgpt.com/backend-api/codex/responses` +### Native images — default in 0.2.0 -The request forces the hosted `image_generation` tool and sends exact image options as tool parameters: +`--backend native` follows the native image request format used by Codex 0.154.0: -```json -{ - "model": "gpt-5.5", - "input": [ - { - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "A studio photo of a mug"}] - } - ], - "tools": [ - { - "type": "image_generation", - "output_format": "png", - "size": "1536x1024", - "quality": "high", - "background": "opaque" - } - ], - "tool_choice": {"type": "image_generation"}, - "stream": true -} +```text +POST https://chatgpt.com/backend-api/codex/images/generations +POST https://chatgpt.com/backend-api/codex/images/edits ``` -Edit requests add input images to the user message: +Generation request: ```json { - "content": [ - {"type": "input_text", "text": "Make it blue"}, - {"type": "input_image", "image_url": "data:image/webp;base64,..."} - ], - "tools": [{"type": "image_generation"}] + "model": "gpt-image-2", + "prompt": "A studio photo of a mug", + "size": "auto", + "quality": "auto", + "background": "auto" } ``` -With `--style-image`, the content image is sent first and the style image is sent last. The text instruction labels that final image as style-only, so Codex has a clearer separation between "what to keep" and "what visual style to borrow." +Edits add `images: [{"image_url": "data:image/webp;base64,..."}]`. Responses contain `data[].b64_json`. The fixed `model: "gpt-image-2"` field follows the Codex request format; it does not verify which image model produced the output. The backend controls the image model, and the CLI exposes no image-model selector. Reasoning-model settings in Codex config are not used for native images. -The accepted image preference values are: +In September 15, 2026 subscription-backend probes, both GPT Image 2.5 names and a deliberately nonexistent model name returned images without reporting model identity. Successful requests therefore do not establish model selection or GPT Image 2.5 availability. This CLI uses Codex subscription access only. -- `size`: `auto`, `1024x1024` square, `1536x1024` landscape, or `1024x1536` portrait -- `quality`: `auto`, `low`, `medium`, or `high` -- `background`: `auto`, `transparent`, or `opaque` +### Responses — explicit compatibility mode -`n` is handled by the CLI by running one hosted image request per output path. -For edit jobs, repeated outputs may wait for the per-minute input-image quota window before retrying; if the bucket stays full, retries back off progressively. -Edit input images are compacted locally to WebP before upload by default. This keeps request bodies smaller and reduces pressure on the input-image quota. -Codex returns PNG bytes; when the requested output is WebP, the CLI converts the PNG locally with Pillow. +The original route remains available: -## How It Works +```bash +codex-imagegen generate \ + --backend responses \ + --model gpt-5.5 \ + --prompt "A studio photo of a mug" \ + --out output/mug.png +``` -`codex-imagegen-cli` reuses your normal Codex login. On each run it: +It posts a forced `image_generation` tool request to `/backend-api/codex/responses` and reads streamed results. `--model` (alias `--reasoning-model`) selects the reasoning model on this backend; it does not select the image generator. Precedence is the flag, `CODEX_IMAGEGEN_MODEL`, the selected Codex profile's model, the top-level Codex model, then `gpt-5.5`. Use `--profile NAME` to override the top-level `profile` selection in `config.toml`. -- reads Codex auth from `$CODEX_HOME/auth.json` or `~/.codex/auth.json` -- refreshes the ChatGPT access token when needed -- compacts edit input images to WebP before upload -- sends a direct Codex `/responses` request with an exact hosted `image_generation` tool by default -- streams the `image_generation_call.result` PNG bytes -- writes PNG output directly, or converts locally to WebP when requested +There is no automatic fallback between backends: retrying a request through another route could generate a duplicate image. Model access varies by account. -Dry runs only print the planned request and output paths; they do not read auth or contact Codex. +### Migration from 0.1.0 -## Important Note +Existing basic generate/edit/batch commands now use native images. Commands specifying a reasoning `--model` or `--profile` must add `--backend responses`. The experimental `--image-model` flag has been removed, and `CODEX_IMAGEGEN_IMAGE_MODEL` has no effect; the backend controls the image model. Explicit transparency on the default path now fails locally. Corrupt images, invalid batch fields and escaped output paths are rejected. Size mismatches now produce warnings, with optional strict failure. -This project relies on Codex's current authenticated app behavior, not a public OpenAI Images API contract. +## Other options and errors -A few things worth knowing: +- `--prompt-file PATH`: UTF-8 prompt file, mutually exclusive with `--prompt`. +- `--cd PATH`: base directory for relative input/output paths. +- `--auth-file PATH`, `--codex-home PATH`: credential location overrides. +- `--timeout SECONDS`: positive finite HTTP socket timeout, default 300. This is not a whole-command deadline; retry waits and multiple images add time. +- `--input-max-edge PIXELS`, `--input-webp-quality 1..100`: edit input compaction settings. +- `--webp-quality 1..100`: local WebP output quality, default 85. +- `--base-url URL` / `CODEX_IMAGEGEN_BASE_URL`: backend override for development. Credentials are sent to that URL; use only a trusted backend. +- `CODEX_IMAGEGEN_REFRESH_URL`: trusted development override for the OAuth endpoint. +- `CODEX_IMAGEGEN_CODEX_VERSION`: override the version header when Codex is not available locally. -- **Subscription only.** The built-in Codex image generation path is gated to ChatGPT auth (Plus, Pro, Business, Edu, Enterprise). It is not available on the Free plan and does not work with an `OPENAI_API_KEY` session — that key routes to the Images API instead, under separate billing. -- **Usage limits apply.** Image generations consume your Codex account's included limits, roughly 3–5× faster than a comparable non-image turn. Edit jobs also consume an input-image quota; if that per-minute bucket is full, the CLI waits and retries. -- **No stable API contract.** Codex internals and account policies can change. The CLI may stop working or behave differently after a Codex update, and OpenAI has not documented this as a supported automation surface. +HTTP 429 and streamed rate limits use bounded retries (at most four), including bounded `Retry-After` delays and longer input-image quota waits. One 401 refresh is allowed per output. Known billing/quota/user errors are not retried. Transport interruptions and ambiguous server failures are surfaced without automatic replay because an image may already have been generated. Exit codes are 0 for success, 1 for runtime/job failure, 2 for argument parsing errors, and 130 for interruption. -We will try to keep this project updated as long as this usage remains possible and allowed. +Generation consumes the account's Codex usage limits. Account access and quota policies can change. This tool is not a supported public OpenAI API contract. -## Development +## Development and compatibility checks ```bash uv lock --check -uv sync --dev +uv sync --frozen --dev uv run pytest uv run ruff check . +uv run ruff format --check . +uv run python -m build --installer uv +uv run python -m twine check dist/* ``` -Build and validate release artifacts: +The build requires `uv` on PATH. Build dependencies are constrained to the tested Hatchling minor release, and the publishing toolchain supports its metadata format. CI covers Python 3.10, 3.12 and 3.14. Offline tests use synthetic credentials. If a Codex binary is installed, a test also checks that the refreshed synthetic login file is readable by Codex. + +Opt-in live compatibility checks make **three image requests** (native generation/edit and Responses generation), consume account usage and never refresh your shared credentials: ```bash -uv run python -m build -uv run python -m twine check dist/* +CODEX_IMAGEGEN_LIVE_TEST=1 uv run pytest -m live -v ``` -## Update And Uninstall +Outputs use pytest's temporary directory. Without the environment variable, live tests are skipped. No server or browser is started. See [CONTRIBUTING.md](CONTRIBUTING.md) and [CHANGELOG.md](CHANGELOG.md). -Update a source install from the checkout: +## Update, uninstall and security ```bash git pull uv tool install --reinstall -e . ``` -Uninstall the CLI: - ```bash uv tool uninstall codex-imagegen-cli ``` -## Security - -Report security issues privately through GitHub Security Advisories when available. See [SECURITY.md](SECURITY.md) for scope and response expectations. - -## Project Status - -`0.1.0` is an alpha source-install release. The CLI command shape, JSONL batch format, and Codex backend behavior may change before a stable release. - -See [CONTRIBUTING.md](CONTRIBUTING.md), [CHANGELOG.md](CHANGELOG.md), and [LICENSE](LICENSE). +Report security issues privately as described in [SECURITY.md](SECURITY.md). Version `0.2.0` remains an alpha source-install release. See [LICENSE](LICENSE). diff --git a/audit/MAINTENANCE.md b/audit/MAINTENANCE.md new file mode 100644 index 0000000..6cd0fbf --- /dev/null +++ b/audit/MAINTENANCE.md @@ -0,0 +1,72 @@ +# Maintenance completed — 0.2.0 + +Implemented the recommendations from the [September 15 audit](REPORT.md) in the local checkout. The historical audit describes `a739870`; its defects and failing-test results are not the status of this updated tree. + +## Changes + +- **Authentication:** retain raw ID-token JWT strings; validate refresh responses; use atomic owner-only credential writes; serialize refreshes between imagegen processes; reuse credentials refreshed by another process; refuse observed external auth changes, including logout. Refresh failures do not echo OAuth response bodies. +- **Dependencies/releases:** update all locked dependencies, require Pillow 12.3.0 or newer, update Build/Twine/Ruff/Pytest, constrain Hatchling to the tested 1.32 minor series, and update GitHub checkout/setup-python actions. Modernize imports/type annotations and make Ruff rules explicit. +- **Backend:** default to the current native `images/generations` and `images/edits` paths. Keep `gpt-image-2` as a fixed request-format compatibility field, without claiming it identifies the actual output model. Keep the legacy Responses path available explicitly, with reasoning-model selection only. The backend controls the image model. +- **Image options/output:** accept valid custom dimensions; report decoded dimensions; warn on size mismatch or reject with `--size-policy error`; reject known-unsupported transparency locally. Strictly decode/validate PNG results before atomic PNG/WebP publication, preserve existing files on failures and print successful paths immediately. +- **Input/configuration/batches:** apply EXIF orientation before resizing; reject corrupt images; parse TOML profiles correctly; normalize nullable image lists and require string prompts; handle per-job validation failures consistently; keep batch outputs under their output directory. +- **Transport/errors:** parse CRLF and split UTF-8 streams; accept terminal response results; close streams promptly; wrap incomplete reads; unify 401/429 and streamed rate-limit handling; bound Retry-After/backoff; reject non-positive/non-finite timeouts and report expected filesystem errors cleanly. +- **Tests/docs:** promote all audit probes into the normal suite, isolate offline tests from real credentials/configuration, add concurrency and native-backend tests, add opt-in live checks, update the README/migration instructions/changelog, and bump the package to 0.2.0. + +## Validation + +| Check | Result | +|---|---| +| Python 3.10.21 | 98 passed, 2 live tests skipped | +| Python 3.12.3 | 98 passed, 2 live tests skipped | +| Python 3.14.7 | 98 passed, 2 live tests skipped | +| Installed Codex 0.154.0 reads refreshed synthetic auth | Pass | +| Concurrent imagegen refresh / external auth change / failed write checks | Pass | +| Opt-in live compatibility | 2 tests passed, making 3 image requests | +| Native generation / edit to WebP | Pass | +| Legacy Responses generation | Pass | +| Ruff lint and formatting | Pass | +| Lockfile consistency | Pass | +| Clean wheel and source archive builds / Twine metadata validation | Pass | +| Isolated Python 3.10 wheel install and both command entry points | Pass | +| Locked dependency audit | No known vulnerabilities reported | + +The live tests took approximately 52 seconds. All three generated files were 1254×1254 despite requests for 1024×1024; the new mismatch warning behaved correctly. See [live test log](evidence/maintenance-live-tests.txt), [image metadata](evidence/maintenance-live-images.json), [dependency scan summary](evidence/maintenance-dependency-scan.json), and generated samples in [output/maintenance](../output/maintenance). + +The GitHub workflow configuration was updated; hosted CI has not run because these changes have not been pushed. Local checks exercise the configured Python versions and build/lint/test commands. + +## Using the update + +```bash +uv sync --frozen --dev +uv run codex-imagegen generate --prompt "A blue circle on white" --out output/circle.png +``` + +Use `--size-policy error` when a size mismatch must fail without writing output. Existing commands specifying a reasoning `--model` must add `--backend responses`. The backend controls the image model; the CLI does not expose an image-model selector. + +```bash +uv run codex-imagegen generate --backend responses --model gpt-5.5 \ + --prompt "A blue circle on white" --out output/legacy-circle.png +``` + +To refresh a local user-command installation: + +```bash +uv tool install --reinstall -e . +``` + +## Remaining platform limitations + +These are documented constraints, rather than outstanding audit fixes: + +- Backend access and exact image options remain controlled by an undocumented service and account entitlements. The tool does not silently resize or switch backends. +- Keyring/ephemeral/API-key/enterprise-token authentication is not implemented; file-based ChatGPT auth is required. +- The imagegen refresh lock cannot fully coordinate with Codex or other external writers that do not share it. Observable changes are checked before publication. +- The live check disables real credential refresh. Compatibility of the stored format is tested against actual Codex using fake tokens; real OAuth refresh was not exercised. +- Windows/macOS-specific behavior was not run on this Linux VPS. CI presently covers Linux with three Python versions. +- No hosted PRs were merged, no release was published, and no server/browser/watcher was started. + +## Follow-up: remove unverified image-model selection + +Subscription-backend probes with `gpt-image-2.5-sunburst`, `gpt-image-2.5-flare`, and a deliberately nonexistent model name all returned images without reporting model identity. These results cannot establish which model ran or whether the requested name was honored. Removed the experimental `--image-model` flag and `CODEX_IMAGEGEN_IMAGE_MODEL` override; retained the default native request format. Clarified reasoning-model help and transparency errors, and added regressions for rejected flags and an ineffective legacy environment override. No public API backend was added. + +Follow-up validation: 98 offline tests passed on Python 3.10, 3.12, and 3.14, with 2 live cases skipped on each. Live generation was not repeated for this removal because the default request payload is unchanged. diff --git a/audit/REPORT.md b/audit/REPORT.md new file mode 100644 index 0000000..fe96b85 --- /dev/null +++ b/audit/REPORT.md @@ -0,0 +1,172 @@ +# Codex Imagegen CLI audit — 2026-09-15 + +> Historical findings at `a739870`. Maintenance in 0.2.0 addresses these findings; see [maintenance validation](MAINTENANCE.md). + +## Verdict + +**Basic generation and editing still work, but the project needs updates before it is dependable for unattended use.** The most serious confirmed defect is token refresh writing a login-file format that current Codex cannot read. Other priorities are vulnerable locked dependencies, broken release metadata validation, and image options that do not match actual backend behavior. + +Repository: [jdmnk/codex-imagegen-cli](https://github.com/jdmnk/codex-imagegen-cli). No local checkout was present; this audit cloned it to `/home/jure/codex-imagegen-cli`. + +Audited `main` at `a739870aa9d600cfd0c382b6c06f38d0b1f5108b`, last source commit June 9, 2026, package version `0.1.0`. Installed Codex is `0.154.0`, also the [latest published stable release](https://github.com/openai/codex/releases/tag/rust-v0.154.0) when checked. Implementation files and the original lockfile were left unchanged. This directory contains audit tests and evidence only. + +## Test results + +| Check | Result | +|---|---| +| `uv lock --check` and frozen installation | Pass | +| Original tests on Python 3.10.21 | 23 passed | +| Original tests on Python 3.12.3 | 23 passed | +| Original tests on Python 3.14.7 | 23 passed | +| Locked Ruff 0.15.15 | Pass on all three runtimes | +| Added positive controls | 6 passed: SSE transport, numbered outputs, overwrite protection, batch continuation/fail-fast, auth-free dry run | +| Added regression probes | 14 failed, reproducing the behaviors described below | +| Source/wheel builds | Pass on complete Python runtimes | +| Locked Twine checks on freshly built artifacts | **Fail: metadata version 2.5 is rejected** | +| Wheel installed in a separate environment | Pass; both command entry points and dry run work | +| Dependency scan, original lock | Known advisories in Pillow and development-only Cryptography | +| Refreshed dependencies in separate copy | 23 tests pass, artifact checks pass, no known dependency advisories; new Ruff reports 38 lint findings | + +The combined run contains **29 passes and 14 failures**. The failures are audit probes, not failures in the original 23-test suite. See [full output](evidence/pytest.txt), [JUnit results](evidence/pytest.xml), [regression probes](../tests/test_regressions.py), and [positive controls](../tests/test_controls.py). Audit probes are outside the repository's default `testpaths`, so a plain `uv run pytest` continues to run its original suite. + +### Live backend tests + +Tests used existing ChatGPT access credentials with token refresh disabled. Temporary credential files were deleted after use. No real login refresh was performed. + +| Request | Result | +|---|---| +| Existing CLI, configured `gpt-6-astra`, blue circle, requested 1024×1024 | Succeeded in 18.9s; PNG **1254×1254** | +| Existing CLI, explicit fallback `gpt-5.5`, green triangle, requested 1024×1024 | Succeeded in 17.2s; PNG **1347×1167** | +| Existing CLI, edit blue circle to red, WebP output | Succeeded in 20.6s; valid WebP **1254×1254**, visually correct | +| Existing CLI, explicit transparent background | **HTTP 400:** `Transparent background is not supported for this model.` | +| Current Codex native generation endpoint, `gpt-image-2`, requested 1024×1024 | Succeeded in 17.1s; JSON response and decoded PNG both say **1254×1254** | + +Generated/edit previews were visually inspected. Samples are in [output/audit](../output/audit); [image measurements](evidence/live-images.json), [transparent-request evidence](evidence/request-event-summary.json), and [native endpoint evidence](evidence/native-request-summary.json) contain no credentials or base64 image bodies. + +**Scope:** one account on Linux; these tests establish present access here, not access for every subscription. Live multi-image batch/style-transfer, every quality/size combination, native edits, macOS/Windows keyrings, and real token-refresh/concurrency were not exercised. Offline tests cover multiple outputs, style-reference request construction and batch behavior. No server, browser or watcher was started. + +## Priority 1 — Fix shared Codex login corruption on refresh + +Location: [`_refresh_auth`, cli.py:428](../codex_imagegen_cli/cli.py#L428), particularly the assignment to `tokens["id_token"]`. + +When OAuth returns a new ID token, the CLI stores a decoded dictionary in `auth.json`. Current Codex requires that field to remain the raw JWT **string**. This can break ordinary Codex authentication after imagegen refreshes its shared credentials. + +Reproduced against the actual installed Codex with synthetic credentials in an isolated temporary home: + +```text +Before imagegen refresh: codex login status -> exit 0, Logged in using ChatGPT +After imagegen refresh: codex login status -> exit 1 +Error checking login status: invalid type: map, expected a string at line 4 column 16 +``` + +**Update:** persist the original returned JWT string; decode claims only in memory. Add a compatibility test that loads the resulting file with current Codex. The existing refresh test does not return `id_token`, and its auth fixture already uses the wrong dictionary representation, so it misses this defect. Also improve credential persistence with restricted permissions and atomic writes, and consider concurrent refresh coordination; those latter failure modes were inspected but not reproduced. + +Upstream contract: [Codex 0.154.0 token serialization](https://github.com/openai/codex/blob/rust-v0.154.0/codex-rs/login/src/token_data.rs#L163). + +## Priority 1 — Refresh dependencies and repair release validation + +Locations: [pyproject.toml](../pyproject.toml), [uv.lock](../uv.lock). + +The locked runtime dependency **Pillow 12.2.0** has **13 distinct advisory groups**, fixed by 12.3.0. One relevant example affects opening crafted image files by filename, a path this CLI uses: [Pillow McIdas memory-read advisory](https://github.com/python-pillow/Pillow/security/advisories/GHSA-62p4-gmf7-7g93). Not every flagged Pillow issue is reachable through this CLI, and exploitability was not tested. + +The development dependency chain also includes **Cryptography 48.0.0**, with **4 distinct advisory groups**. It is pulled in by publishing/keyring tooling, not the CLI's runtime HTTPS implementation. [Maintainer advisory for bundled OpenSSL](https://github.com/pyca/cryptography/security/advisories/GHSA-537c-gmf6-5ccf). The scanner emitted 32 raw records; duplicates reduce this to **17 distinct advisories across two packages**. See [deduplicated evidence](evidence/vulnerability-summary.json). + +Fresh isolated builds resolve the unbounded build requirement to **Hatchling 1.32.0**, which emits `Metadata-Version: 2.5`. Locked **Twine 6.2.0** rejects both the wheel and source archive. Updating the development toolchain, including **Twine 7.0.0 / Packaging 26.3**, makes both checks pass. A passing historic workflow does not prove a fresh build will pass today because build isolation resolves new Hatchling releases independently of `uv.lock`. + +Main tested updates: + +| Package | Locked | Tested current | +|---|---|---| +| Pillow | 12.2.0 | 12.3.0 | +| Cryptography, dev only | 48.0.0 | 50.0.1 | +| Twine | 6.2.0 | 7.0.0 | +| Packaging | 26.2 | 26.3 | +| Build | 1.5.0 | 1.6.1 | +| Pytest | 9.0.3 | 9.1.1 | +| Ruff | 0.15.15 | 0.16.7 | + +**Update:** refresh the lockfile, raise the runtime Pillow minimum to a patched version, and establish compatible minimum publishing-tool versions. In `/tmp/codex-imagegen-upgrade-check`, a full lock refresh passed all 23 tests on Python 3.10, built valid artifacts and produced [zero known dependency findings](evidence/upgraded-scan-summary.json). New Ruff defaults produce 38 import/type-modernization findings; clean them up or explicitly retain the existing `E4,E7,E9,F` rule set, which was tested and passes. Do not treat a blind lock refresh as fully green CI. + +Seven [open dependency PRs](https://github.com/jdmnk/codex-imagegen-cli/pulls) were found: #9 checkout v7, #10 pytest, #13 Pillow, #14 Hatchling requirement, #15 Ruff, #16 Build, #17 setup-python v7. Some target versions are already behind today's releases. Review/update them together with a fresh CI run. + +## Priority 1 — Make size/background behavior accurate + +Locations: [`_responses_payload`](../codex_imagegen_cli/cli.py#L518), [`_write_response_image`](../codex_imagegen_cli/cli.py#L565), [README backend/options](../README.md#backend-path). + +The CLI sends explicit sizes but silently saves differently sized images and reports success. Two independent CLI generations reproduced this. The native endpoint also returned different dimensions, so **switching endpoints alone does not fix exact-size behavior**. + +Transparent background is accepted by the parser and advertised without a model caveat, but the tested backend rejects it with a user-error response. + +**Update:** verify decoded image dimensions, report the actual size, and provide a clear mismatch policy. If exact-size postprocessing is offered, make resizing/cropping explicit. Make option validation/documentation reflect the selected backend and image model. Preserve useful backend error details. Do not claim all quality flags are ignored: the native response reported the requested `low` quality; image quality itself was not benchmarked. + +## Priority 2 — Align the backend with current Codex + +The project forces an `image_generation` tool through `/backend-api/codex/responses`, selected by a reasoning-model name. That path still works here. + +Current Codex 0.154.0's native image extension instead uses: + +```text +POST /backend-api/codex/images/generations +POST /backend-api/codex/images/edits +model: gpt-image-2 +``` + +Its responses are JSON containing `data[].b64_json`, image dimensions and quality. The native generation request was successfully tested in this audit. Native edit behavior was inspected in source, not live-tested. Sources: [current backend](https://github.com/openai/codex/blob/rust-v0.154.0/codex-rs/ext/image-generation/src/backend.rs), [endpoint/schema implementation](https://github.com/openai/codex/blob/rust-v0.154.0/codex-rs/codex-api/src/endpoint/images.rs), [tool model and five-image limit](https://github.com/openai/codex/blob/rust-v0.154.0/codex-rs/ext/image-generation/src/tool.rs#L58). + +**Update:** evaluate making this native path primary, preserving the existing path deliberately if needed, and separate image-model selection from reasoning-model selection. It removes an unnecessary reasoning-model dependency and the need to extract image output from SSE. Keep the private-backend compatibility caveat. + +Official documentation still describes Codex's built-in model as `gpt-image-2`; the separate public Images API now documents GPT Image 2.5. API availability does **not** establish subscription-backend availability. Do not blindly substitute a public API model. [Codex image-generation docs](https://learn.chatgpt.com/docs/image-generation), [public API image guide](https://developers.openai.com/api/docs/guides/image-generation). + +The existing five-reference-image limit still matches current Codex. The `gpt-5.5` fallback worked in this audit, although [issue #18](https://github.com/jdmnk/codex-imagegen-cli/issues/18) reports an account-specific `model_not_found`. It is not proven globally broken. + +## Priority 2 — Correct configuration, batch and image handling + +| Confirmed behavior | Location | Update | +|---|---|---| +| Regex model lookup reads a model from an inactive TOML profile | `_default_model`, line 502 | Parse TOML and respect top-level/active-profile precedence; use a Python 3.10-compatible parser strategy | +| `"images": null` is accepted in batch validation, then crashes with `TypeError` | `_load_jobs_jsonl`, line 154; `_cmd_batch`, line 985 | Store normalized validated jobs | +| `"prompt": null` becomes the literal prompt `"None"` | Same | Require actual non-empty strings | +| Invalid mode aborts all later batch jobs even without `--fail-fast` | `_cmd_batch`, line 985 | Validate before starting the batch, or put per-job validation inside the existing failure-handling block | +| EXIF-rotated input loses its intended orientation during WebP compaction | `_encoded_input_image`, line 474 | Apply EXIF transpose before resizing and encoding | +| Invalid base64 `!!!!` creates an empty PNG file and reports success | `_write_response_image`, line 565 | Strict base64 decoding plus image validation before replacing output | + +Codex can now store credentials in file, keyring, automatic or ephemeral modes. This CLI only reads files. That is an explicit limitation to document and diagnose clearly; do not imply that any successful `codex login` guarantees a readable `auth.json`. [Official authentication documentation](https://learn.chatgpt.com/docs/auth#credential-storage). + +## Priority 2/3 — Harden transport and user-facing errors + +If the Responses backend is retained, the audit reproduces: + +- **CRLF SSE separators fail:** `_post_sse` only splits on `\n\n` and merges multiple CRLF-delimited events into invalid JSON. LF-delimited events pass the positive control. +- **Terminal-response fallback is missing:** a result in `response.completed.response.output` is ignored when no per-item result was emitted. Current live streams worked; this is a robustness gap, not a live outage observed today. +- **Incomplete HTTP reads leak a traceback:** `http.client.IncompleteRead` is not wrapped as `TransportError`. +- **HTTP 429 is not retried:** only streamed `response.failed` rate-limit events are handled. +- **401 followed by a streamed rate limit bypasses retries:** the post-refresh request executes inside the exception handler, outside the common retry loop. +- **Bad timeout values and filesystem input errors leak through:** negative timeouts pass validation, and passing a directory as `--prompt-file` raises `IsADirectoryError` instead of a concise CLI error. + +Update the stream parser, unify bounded retry handling, retain `Retry-After` information, validate positive finite timeouts and normalize expected filesystem/HTTP errors. Avoid automatic retries after ambiguous successful generation unless duplicate image usage is accounted for. + +## Reproduction and suggested update order + +```bash +cd /home/jure/codex-imagegen-cli +uv sync --frozen --dev +uv run pytest # original 23 tests +uv run pytest audit/test_controls.py # six passing controls +uv run pytest audit/test_regressions.py # 14 expected failures on audited commit +uv run ruff check . +uv run python -m build +uv run python -m twine check dist/* # currently fails on metadata 2.5 +``` + +On this VPS, `uv` was absent and system Python lacked `ensurepip`. Audit tooling was installed separately; use `/tmp/codex-imagegen-audit-tools/uv` here. The system-Python build works with `PATH=/tmp/codex-imagegen-audit-tools:$PATH` and `uv run python -m build --installer uv`. Complete Python 3.10/3.14 runtimes also build with the documented command. Missing `ensurepip` is an environment issue, separate from the reproducible Twine incompatibility. + +Recommended sequence: + +1. Fix auth serialization; add the real Codex compatibility regression. +2. Update Pillow and publishing dependencies; resolve new Ruff findings/configuration and rerun CI. +3. Correct size/background promises and validate actual output. +4. Evaluate the current native image endpoints and clarify model selection. +5. Fix batch normalization, TOML handling, EXIF orientation, output validation and remaining error paths. +6. Promote the audit probes into the normal suite as fixes land; add an opt-in live compatibility check and update the changelog/version for a release. + +No commits, pushes, dependency merges, deployments, or implementation fixes were performed. diff --git a/audit/evidence/live-images.json b/audit/evidence/live-images.json new file mode 100644 index 0000000..97c9d28 --- /dev/null +++ b/audit/evidence/live-images.json @@ -0,0 +1,42 @@ +[ + { + "file": "generate-fallback.png", + "format": "PNG", + "size": [ + 1347, + 1167 + ], + "mode": "RGB", + "bytes": 773878 + }, + { + "file": "native-generate-0.png", + "format": "PNG", + "size": [ + 1254, + 1254 + ], + "mode": "RGB", + "bytes": 624831 + }, + { + "file": "generate-default.png", + "format": "PNG", + "size": [ + 1254, + 1254 + ], + "mode": "RGB", + "bytes": 654206 + }, + { + "file": "edit.webp", + "format": "WEBP", + "size": [ + 1254, + 1254 + ], + "mode": "RGB", + "bytes": 13858 + } +] diff --git a/audit/evidence/maintenance-dependency-scan.json b/audit/evidence/maintenance-dependency-scan.json new file mode 100644 index 0000000..5d9c833 --- /dev/null +++ b/audit/evidence/maintenance-dependency-scan.json @@ -0,0 +1,4 @@ +{ + "packages": 34, + "vulnerability_records": 0 +} diff --git a/audit/evidence/maintenance-live-images.json b/audit/evidence/maintenance-live-images.json new file mode 100644 index 0000000..dbbe8bc --- /dev/null +++ b/audit/evidence/maintenance-live-images.json @@ -0,0 +1,29 @@ +[ + { + "file": "native-edit.webp", + "size": [ + 1254, + 1254 + ], + "format": "WEBP", + "bytes": 15740 + }, + { + "file": "native.png", + "size": [ + 1254, + 1254 + ], + "format": "PNG", + "bytes": 762675 + }, + { + "file": "responses.png", + "size": [ + 1254, + 1254 + ], + "format": "PNG", + "bytes": 778788 + } +] diff --git a/audit/evidence/maintenance-live-tests.txt b/audit/evidence/maintenance-live-tests.txt new file mode 100644 index 0000000..123074a --- /dev/null +++ b/audit/evidence/maintenance-live-tests.txt @@ -0,0 +1,12 @@ +============================= test session starts ============================== +platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0 -- /home/jure/codex-imagegen-cli/.venv/bin/python +cachedir: .pytest_cache +rootdir: /home/jure/codex-imagegen-cli +configfile: pyproject.toml +testpaths: tests +collecting ... collected 98 items / 96 deselected / 2 selected + +tests/test_live.py::test_live_native_generate_and_edit PASSED [ 50%] +tests/test_live.py::test_live_responses_compatibility PASSED [100%] + +====================== 2 passed, 96 deselected in 51.95s ======================= diff --git a/audit/evidence/maintenance-offline-tests.txt b/audit/evidence/maintenance-offline-tests.txt new file mode 100644 index 0000000..d0c399b --- /dev/null +++ b/audit/evidence/maintenance-offline-tests.txt @@ -0,0 +1,3 @@ +.............................ss......................................... [ 73%] +.......................... [100%] +96 passed, 2 skipped in 0.68s diff --git a/audit/evidence/native-request-summary.json b/audit/evidence/native-request-summary.json new file mode 100644 index 0000000..5cce475 --- /dev/null +++ b/audit/evidence/native-request-summary.json @@ -0,0 +1,40 @@ +{ + "url": "https://chatgpt.com/backend-api/codex/images/generations", + "request": { + "model": "gpt-image-2", + "prompt": "A single orange square centered on a white square canvas. No text.", + "quality": "low", + "size": "1024x1024", + "background": "opaque" + }, + "response": { + "created": 1789476769, + "background": "opaque", + "output_format": "png", + "quality": "low", + "size": "1254x1254", + "usage": { + "input_tokens": 20, + "input_tokens_details": { + "image_tokens": 0, + "text_tokens": 20 + }, + "output_tokens": 515, + "output_tokens_details": { + "image_tokens": 515, + "text_tokens": 0 + }, + "total_tokens": 535 + } + }, + "image_count": 1, + "actual": { + "size": [ + 1254, + 1254 + ], + "mode": "RGB", + "format": "PNG" + }, + "seconds": 17.1 +} \ No newline at end of file diff --git a/audit/evidence/outdated-packages.json b/audit/evidence/outdated-packages.json new file mode 100644 index 0000000..3046f40 --- /dev/null +++ b/audit/evidence/outdated-packages.json @@ -0,0 +1 @@ +[{"name":"build","version":"1.5.0","latest_version":"1.6.1","latest_filetype":"wheel"},{"name":"certifi","version":"2026.5.20","latest_version":"2026.7.22","latest_filetype":"wheel"},{"name":"cffi","version":"2.0.0","latest_version":"2.1.1","latest_filetype":"wheel"},{"name":"charset-normalizer","version":"3.4.7","latest_version":"3.5.1","latest_filetype":"wheel"},{"name":"cryptography","version":"48.0.0","latest_version":"50.0.1","latest_filetype":"wheel"},{"name":"idna","version":"3.17","latest_version":"3.19","latest_filetype":"wheel"},{"name":"jaraco-functools","version":"4.5.0","latest_version":"4.6.0","latest_filetype":"wheel"},{"name":"nh3","version":"0.3.5","latest_version":"0.3.7","latest_filetype":"wheel"},{"name":"packaging","version":"26.2","latest_version":"26.3","latest_filetype":"wheel"},{"name":"pillow","version":"12.2.0","latest_version":"12.3.0","latest_filetype":"wheel"},{"name":"pygments","version":"2.20.0","latest_version":"2.21.0","latest_filetype":"wheel"},{"name":"pytest","version":"9.0.3","latest_version":"9.1.1","latest_filetype":"wheel"},{"name":"readme-renderer","version":"44.0","latest_version":"46.0","latest_filetype":"wheel"},{"name":"ruff","version":"0.15.15","latest_version":"0.16.7","latest_filetype":"wheel"},{"name":"twine","version":"6.2.0","latest_version":"7.0.0","latest_filetype":"wheel"}] diff --git a/audit/evidence/pytest.txt b/audit/evidence/pytest.txt new file mode 100644 index 0000000..3342f77 --- /dev/null +++ b/audit/evidence/pytest.txt @@ -0,0 +1,132 @@ +.............................FFFFFFFFFFFFFF [100%] +=================================== FAILURES =================================== +________________ test_refresh_preserves_id_token_as_jwt_string _________________ +audit/test_regressions.py:49: in test_refresh_preserves_id_token_as_jwt_string + assert json.loads(path.read_text())["tokens"]["id_token"] == new_id +E AssertionError: assert {'chatgpt_account_id': None, 'chatgpt_account_is_fedramp': False, 'chatgpt_plan_type': None, 'chatgpt_user_id': None, ...} == 'e30.eyJlbWFpbCI6ICJ0ZXN0QGV4YW1wbGUuaW52YWxpZCJ9.signature' +__________________ test_stream_accepts_crlf_event_separators ___________________ +audit/test_regressions.py:60: in test_stream_accepts_crlf_event_separators + assert cli._stream_image_result("https://example.invalid", headers={}, payload={}, timeout=1) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +codex_imagegen_cli/cli.py:812: in _stream_image_result + raise CliError("Responses stream ended without an image generation result.") +E codex_imagegen_cli.cli.CliError: Responses stream ended without an image generation result. +_______________ test_stream_accepts_result_in_completed_response _______________ +audit/test_regressions.py:69: in test_stream_accepts_result_in_completed_response + assert cli._stream_image_result("https://example.invalid", headers={}, payload={}, timeout=1) == encoded + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +codex_imagegen_cli/cli.py:812: in _stream_image_result + raise CliError("Responses stream ended without an image generation result.") +E codex_imagegen_cli.cli.CliError: Responses stream ended without an image generation result. +_____________ test_truncated_http_response_has_cli_transport_error _____________ +audit/test_regressions.py:79: in test_truncated_http_response_has_cli_transport_error + list(cli._post_sse("https://example.invalid", headers={}, payload={}, timeout=1)) +codex_imagegen_cli/cli.py:391: in _post_sse + with request.urlopen(req, timeout=timeout) as response: +/usr/lib/python3.12/contextlib.py:137: in __enter__ + return next(self.gen) + ^^^^^^^^^^^^^^ +audit/test_regressions.py:75: in broken + raise IncompleteRead(b"partial", 100) +E http.client.IncompleteRead: IncompleteRead(7 bytes read, 100 more expected) +__________ test_rate_limit_retry_also_works_after_http_errors[False] ___________ +audit/test_regressions.py:101: in test_rate_limit_retry_also_works_after_http_errors + assert cli._call_responses_backend(args=args, mode="generate", prompt="test", output_path=tmp_path / "out.png") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +codex_imagegen_cli/cli.py:701: in _call_responses_backend + encoded = _stream_image_result( +audit/test_regressions.py:93: in stream + raise result +E codex_imagegen_cli.cli.HttpError: HTTP 429: rate limited +___________ test_rate_limit_retry_also_works_after_http_errors[True] ___________ +audit/test_regressions.py:93: in stream + raise result +E codex_imagegen_cli.cli.HttpError: HTTP 401: expired + +During handling of the above exception, another exception occurred: +audit/test_regressions.py:101: in test_rate_limit_retry_also_works_after_http_errors + assert cli._call_responses_backend(args=args, mode="generate", prompt="test", output_path=tmp_path / "out.png") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +codex_imagegen_cli/cli.py:713: in _call_responses_backend + encoded = _stream_image_result( +audit/test_regressions.py:93: in stream + raise result +E codex_imagegen_cli.cli.ResponsesImageGenerationError: Responses image generation failed (rate_limit_exceeded): Please try again in 1ms. +_________________ test_batch_null_images_behaves_as_empty_list _________________ +audit/test_regressions.py:109: in test_batch_null_images_behaves_as_empty_list + assert cli.main(["batch", "--input", str(jobs), "--out-dir", str(tmp_path)]) == 0 + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +codex_imagegen_cli/cli.py:1064: in main + return int(args.func(args)) + ^^^^^^^^^^^^^^^ +codex_imagegen_cli/cli.py:997: in _cmd_batch + images = [_resolve_path(raw, cd) for raw in job.get("images", [])] + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E TypeError: 'NoneType' object is not iterable +______________________ test_batch_rejects_nontext_prompt _______________________ +audit/test_regressions.py:115: in test_batch_rejects_nontext_prompt + with pytest.raises(cli.CliError): +E Failed: DID NOT RAISE +_____________ test_batch_invalid_job_does_not_abort_remaining_jobs _____________ +audit/test_regressions.py:128: in test_batch_invalid_job_does_not_abort_remaining_jobs + assert visited == ["good"] +E AssertionError: assert [] == ['good'] +E +E Right contains one more item: 'good' +E Use -v to get more diff +----------------------------- Captured stderr call ----------------------------- +Error: Job 1 has invalid mode: invalid +_____________ test_config_does_not_use_model_from_inactive_profile _____________ +audit/test_regressions.py:137: in test_config_does_not_use_model_from_inactive_profile + assert cli._default_model(args) == cli.DEFAULT_CODEX_MODEL +E AssertionError: assert 'unavailable-model' == 'gpt-5.5' +E +E - gpt-5.5 +E + unavailable-model +___________________ test_compaction_applies_exif_orientation ___________________ +audit/test_regressions.py:148: in test_compaction_applies_exif_orientation + assert output.size == (20, 40) +E assert (40, 20) == (20, 40) +E +E At index 0 diff: 40 != 20 +E Use -v to get more diff +____________ test_corrupt_base64_does_not_create_successful_output _____________ +audit/test_regressions.py:153: in test_corrupt_base64_does_not_create_successful_output + with pytest.raises(cli.CliError): +E Failed: DID NOT RAISE +________________________ test_timeout_must_be_positive _________________________ +audit/test_regressions.py:161: in test_timeout_must_be_positive + with pytest.raises(cli.CliError): +E Failed: DID NOT RAISE +_________________ test_prompt_directory_is_a_user_facing_error _________________ +audit/test_regressions.py:167: in test_prompt_directory_is_a_user_facing_error + cli._read_prompt(None, str(tmp_path), tmp_path) +codex_imagegen_cli/cli.py:89: in _read_prompt + text = _read_optional_prompt(prompt, prompt_file, cd) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +codex_imagegen_cli/cli.py:102: in _read_optional_prompt + text = path.read_text(encoding="utf-8").strip() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +/usr/lib/python3.12/pathlib.py:1029: in read_text + with self.open(mode='r', encoding=encoding, errors=errors) as f: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +/usr/lib/python3.12/pathlib.py:1015: in open + return io.open(self, mode, buffering, encoding, errors, newline) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E IsADirectoryError: [Errno 21] Is a directory: '/tmp/pytest-of-jure/pytest-6/test_prompt_directory_is_a_use0' +=========================== short test summary info ============================ +FAILED audit/test_regressions.py::test_refresh_preserves_id_token_as_jwt_string +FAILED audit/test_regressions.py::test_stream_accepts_crlf_event_separators +FAILED audit/test_regressions.py::test_stream_accepts_result_in_completed_response +FAILED audit/test_regressions.py::test_truncated_http_response_has_cli_transport_error +FAILED audit/test_regressions.py::test_rate_limit_retry_also_works_after_http_errors[False] +FAILED audit/test_regressions.py::test_rate_limit_retry_also_works_after_http_errors[True] +FAILED audit/test_regressions.py::test_batch_null_images_behaves_as_empty_list +FAILED audit/test_regressions.py::test_batch_rejects_nontext_prompt - Failed:... +FAILED audit/test_regressions.py::test_batch_invalid_job_does_not_abort_remaining_jobs +FAILED audit/test_regressions.py::test_config_does_not_use_model_from_inactive_profile +FAILED audit/test_regressions.py::test_compaction_applies_exif_orientation - ... +FAILED audit/test_regressions.py::test_corrupt_base64_does_not_create_successful_output +FAILED audit/test_regressions.py::test_timeout_must_be_positive - Failed: DID... +FAILED audit/test_regressions.py::test_prompt_directory_is_a_user_facing_error +14 failed, 29 passed in 0.82s diff --git a/audit/evidence/pytest.xml b/audit/evidence/pytest.xml new file mode 100644 index 0000000..5da4dc4 --- /dev/null +++ b/audit/evidence/pytest.xml @@ -0,0 +1,85 @@ +audit/test_regressions.py:49: in test_refresh_preserves_id_token_as_jwt_string + assert json.loads(path.read_text())["tokens"]["id_token"] == new_id +E AssertionError: assert {'chatgpt_account_id': None, 'chatgpt_account_is_fedramp': False, 'chatgpt_plan_type': None, 'chatgpt_user_id': None, ...} == 'e30.eyJlbWFpbCI6ICJ0ZXN0QGV4YW1wbGUuaW52YWxpZCJ9.signature'audit/test_regressions.py:60: in test_stream_accepts_crlf_event_separators + assert cli._stream_image_result("https://example.invalid", headers={}, payload={}, timeout=1) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +codex_imagegen_cli/cli.py:812: in _stream_image_result + raise CliError("Responses stream ended without an image generation result.") +E codex_imagegen_cli.cli.CliError: Responses stream ended without an image generation result.audit/test_regressions.py:69: in test_stream_accepts_result_in_completed_response + assert cli._stream_image_result("https://example.invalid", headers={}, payload={}, timeout=1) == encoded + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +codex_imagegen_cli/cli.py:812: in _stream_image_result + raise CliError("Responses stream ended without an image generation result.") +E codex_imagegen_cli.cli.CliError: Responses stream ended without an image generation result.audit/test_regressions.py:79: in test_truncated_http_response_has_cli_transport_error + list(cli._post_sse("https://example.invalid", headers={}, payload={}, timeout=1)) +codex_imagegen_cli/cli.py:391: in _post_sse + with request.urlopen(req, timeout=timeout) as response: +/usr/lib/python3.12/contextlib.py:137: in __enter__ + return next(self.gen) + ^^^^^^^^^^^^^^ +audit/test_regressions.py:75: in broken + raise IncompleteRead(b"partial", 100) +E http.client.IncompleteRead: IncompleteRead(7 bytes read, 100 more expected)audit/test_regressions.py:101: in test_rate_limit_retry_also_works_after_http_errors + assert cli._call_responses_backend(args=args, mode="generate", prompt="test", output_path=tmp_path / "out.png") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +codex_imagegen_cli/cli.py:701: in _call_responses_backend + encoded = _stream_image_result( +audit/test_regressions.py:93: in stream + raise result +E codex_imagegen_cli.cli.HttpError: HTTP 429: rate limitedaudit/test_regressions.py:93: in stream + raise result +E codex_imagegen_cli.cli.HttpError: HTTP 401: expired + +During handling of the above exception, another exception occurred: +audit/test_regressions.py:101: in test_rate_limit_retry_also_works_after_http_errors + assert cli._call_responses_backend(args=args, mode="generate", prompt="test", output_path=tmp_path / "out.png") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +codex_imagegen_cli/cli.py:713: in _call_responses_backend + encoded = _stream_image_result( +audit/test_regressions.py:93: in stream + raise result +E codex_imagegen_cli.cli.ResponsesImageGenerationError: Responses image generation failed (rate_limit_exceeded): Please try again in 1ms.audit/test_regressions.py:109: in test_batch_null_images_behaves_as_empty_list + assert cli.main(["batch", "--input", str(jobs), "--out-dir", str(tmp_path)]) == 0 + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +codex_imagegen_cli/cli.py:1064: in main + return int(args.func(args)) + ^^^^^^^^^^^^^^^ +codex_imagegen_cli/cli.py:997: in _cmd_batch + images = [_resolve_path(raw, cd) for raw in job.get("images", [])] + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E TypeError: 'NoneType' object is not iterableaudit/test_regressions.py:115: in test_batch_rejects_nontext_prompt + with pytest.raises(cli.CliError): +E Failed: DID NOT RAISE <class 'codex_imagegen_cli.cli.CliError'>audit/test_regressions.py:128: in test_batch_invalid_job_does_not_abort_remaining_jobs + assert visited == ["good"] +E AssertionError: assert [] == ['good'] +E +E Right contains one more item: 'good' +E Use -v to get more diffaudit/test_regressions.py:137: in test_config_does_not_use_model_from_inactive_profile + assert cli._default_model(args) == cli.DEFAULT_CODEX_MODEL +E AssertionError: assert 'unavailable-model' == 'gpt-5.5' +E +E - gpt-5.5 +E + unavailable-modelaudit/test_regressions.py:148: in test_compaction_applies_exif_orientation + assert output.size == (20, 40) +E assert (40, 20) == (20, 40) +E +E At index 0 diff: 40 != 20 +E Use -v to get more diffaudit/test_regressions.py:153: in test_corrupt_base64_does_not_create_successful_output + with pytest.raises(cli.CliError): +E Failed: DID NOT RAISE <class 'codex_imagegen_cli.cli.CliError'>audit/test_regressions.py:161: in test_timeout_must_be_positive + with pytest.raises(cli.CliError): +E Failed: DID NOT RAISE <class 'codex_imagegen_cli.cli.CliError'>audit/test_regressions.py:167: in test_prompt_directory_is_a_user_facing_error + cli._read_prompt(None, str(tmp_path), tmp_path) +codex_imagegen_cli/cli.py:89: in _read_prompt + text = _read_optional_prompt(prompt, prompt_file, cd) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +codex_imagegen_cli/cli.py:102: in _read_optional_prompt + text = path.read_text(encoding="utf-8").strip() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +/usr/lib/python3.12/pathlib.py:1029: in read_text + with self.open(mode='r', encoding=encoding, errors=errors) as f: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +/usr/lib/python3.12/pathlib.py:1015: in open + return io.open(self, mode, buffering, encoding, errors, newline) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E IsADirectoryError: [Errno 21] Is a directory: '/tmp/pytest-of-jure/pytest-6/test_prompt_directory_is_a_use0' \ No newline at end of file diff --git a/audit/evidence/request-event-summary.json b/audit/evidence/request-event-summary.json new file mode 100644 index 0000000..c5504d4 --- /dev/null +++ b/audit/evidence/request-event-summary.json @@ -0,0 +1,14 @@ +[ + { + "requested_model": "gpt-6-astra", + "requested_tools": [ + { + "type": "image_generation", + "output_format": "png", + "size": "1536x1024", + "quality": "low", + "background": "transparent" + } + ] + } +] \ No newline at end of file diff --git a/audit/evidence/upgraded-scan-summary.json b/audit/evidence/upgraded-scan-summary.json new file mode 100644 index 0000000..4367de7 --- /dev/null +++ b/audit/evidence/upgraded-scan-summary.json @@ -0,0 +1,4 @@ +{ + "scanned_packages": 34, + "known_vulnerability_records": 0 +} diff --git a/audit/evidence/vulnerability-summary.json b/audit/evidence/vulnerability-summary.json new file mode 100644 index 0000000..e9dbcca --- /dev/null +++ b/audit/evidence/vulnerability-summary.json @@ -0,0 +1,208 @@ +[ + { + "package": "cryptography", + "version": "48.0.0", + "raw_records": 7, + "unique_advisories": [ + { + "id": "GHSA-m2h6-j472-rp4c", + "aliases": [ + "CVE-2026-69248", + "GHSA-m2h6-j472-rp4c", + "PYSEC-2026-3554" + ], + "fix_versions": [ + "49.0.0" + ] + }, + { + "id": "GHSA-g6cj-pr64-35w5", + "aliases": [ + "CVE-2026-69247", + "GHSA-g6cj-pr64-35w5", + "PYSEC-2026-3552" + ], + "fix_versions": [ + "50.0.0" + ] + }, + { + "id": "GHSA-jwv3-5hgf-82ww", + "aliases": [ + "CVE-2026-69249", + "GHSA-jwv3-5hgf-82ww", + "PYSEC-2026-3553" + ], + "fix_versions": [ + "49.0.0" + ] + }, + { + "id": "GHSA-537c-gmf6-5ccf", + "aliases": [ + "GHSA-537c-gmf6-5ccf" + ], + "fix_versions": [ + "48.0.1" + ] + } + ] + }, + { + "package": "pillow", + "version": "12.2.0", + "raw_records": 25, + "unique_advisories": [ + { + "id": "GHSA-8v84-f9pq-wr9x", + "aliases": [ + "BIT-pillow-2026-54059", + "CVE-2026-54059", + "GHSA-8v84-f9pq-wr9x", + "PYSEC-2026-2253" + ], + "fix_versions": [ + "12.3.0" + ] + }, + { + "id": "GHSA-45hq-cxwh-f6vc", + "aliases": [ + "BIT-pillow-2026-55379", + "CVE-2026-55379", + "GHSA-45hq-cxwh-f6vc", + "PYSEC-2026-2255" + ], + "fix_versions": [ + "12.3.0" + ] + }, + { + "id": "GHSA-4x4j-2g7c-83w6", + "aliases": [ + "CVE-2026-55798", + "GHSA-4x4j-2g7c-83w6", + "PYSEC-2026-2257" + ], + "fix_versions": [ + "12.3.0" + ] + }, + { + "id": "GHSA-phj9-mv4w-65pm", + "aliases": [ + "BIT-pillow-2026-55380", + "CVE-2026-55380", + "GHSA-phj9-mv4w-65pm", + "PYSEC-2026-2256" + ], + "fix_versions": [ + "12.3.0" + ] + }, + { + "id": "GHSA-5x94-69rx-g8h2", + "aliases": [ + "BIT-pillow-2026-54060", + "CVE-2026-54060", + "GHSA-5x94-69rx-g8h2", + "PYSEC-2026-2254" + ], + "fix_versions": [ + "12.3.0" + ] + }, + { + "id": "GHSA-9hw9-ch79-4vh6", + "aliases": [ + "BIT-pillow-2026-59205", + "CVE-2026-59205", + "GHSA-9hw9-ch79-4vh6", + "PYSEC-2026-3453" + ], + "fix_versions": [ + "12.3.0" + ] + }, + { + "id": "GHSA-6r8x-57c9-28j4", + "aliases": [ + "BIT-pillow-2026-59199", + "CVE-2026-59199", + "GHSA-6r8x-57c9-28j4", + "PYSEC-2026-3451" + ], + "fix_versions": [ + "12.3.0" + ] + }, + { + "id": "GHSA-pg7v-jwj7-p798", + "aliases": [ + "BIT-pillow-2026-59203", + "CVE-2026-59203", + "GHSA-pg7v-jwj7-p798", + "PYSEC-2026-3452" + ], + "fix_versions": [ + "12.3.0" + ] + }, + { + "id": "GHSA-62p4-gmf7-7g93", + "aliases": [ + "CVE-2026-54058", + "GHSA-62p4-gmf7-7g93", + "PYSEC-2026-3493" + ], + "fix_versions": [ + "12.3.0" + ] + }, + { + "id": "GHSA-xj96-63gp-2gmr", + "aliases": [ + "CVE-2026-59197", + "GHSA-xj96-63gp-2gmr", + "PYSEC-2026-3454" + ], + "fix_versions": [ + "12.3.0" + ] + }, + { + "id": "GHSA-fj7v-r99m-22gq", + "aliases": [ + "CVE-2026-59198", + "GHSA-fj7v-r99m-22gq", + "PYSEC-2026-3494" + ], + "fix_versions": [ + "12.3.0" + ] + }, + { + "id": "GHSA-jjj6-mw9f-p565", + "aliases": [ + "CVE-2026-59200", + "GHSA-jjj6-mw9f-p565", + "PYSEC-2026-3495" + ], + "fix_versions": [ + "12.3.0" + ] + }, + { + "id": "GHSA-vjc4-5qp5-m44j", + "aliases": [ + "CVE-2026-59204", + "GHSA-vjc4-5qp5-m44j", + "PYSEC-2026-3496" + ], + "fix_versions": [ + "12.3.0" + ] + } + ] + } +] diff --git a/codex_imagegen_cli/__init__.py b/codex_imagegen_cli/__init__.py index 761e803..4af43b0 100644 --- a/codex_imagegen_cli/__init__.py +++ b/codex_imagegen_cli/__init__.py @@ -1,3 +1,3 @@ """Scriptable image generation with Codex ChatGPT auth.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/codex_imagegen_cli/cli.py b/codex_imagegen_cli/cli.py index 0887547..71cfa0a 100644 --- a/codex_imagegen_cli/cli.py +++ b/codex_imagegen_cli/cli.py @@ -2,34 +2,47 @@ import argparse import base64 -from io import BytesIO -from datetime import datetime, timezone +import codecs +import copy import json -import mimetypes +import math import os import platform -from pathlib import Path import re import shutil import subprocess import sys +import tempfile import time +from collections.abc import Sequence +from contextlib import contextmanager from dataclasses import dataclass -from typing import Any, List, Optional, Sequence, Tuple +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime +from http.client import HTTPException +from io import BytesIO +from pathlib import Path +from typing import Any from urllib import error, request -from PIL import Image +from PIL import Image, ImageOps -from codex_imagegen_cli import __version__ +try: + import tomllib +except ImportError: # Python 3.10 + import tomli as tomllib +from codex_imagegen_cli import __version__ DEFAULT_BASE_URL = "https://chatgpt.com/backend-api/codex" DEFAULT_REFRESH_URL = "https://auth.openai.com/oauth/token" CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" DEFAULT_CODEX_MODEL = "gpt-5.5" +# Match Codex request format; the backend does not report the actual image model. +NATIVE_REQUEST_MODEL = "gpt-image-2" +MAX_IMAGE_BYTES = 32 * 1024 * 1024 MAX_EDIT_IMAGES = 5 DEFAULT_ORIGINATOR = "codex_cli_rs" -IMAGE_SIZE_CHOICES = ("auto", "1024x1024", "1536x1024", "1024x1536") OUTPUT_FORMAT_CHOICES = ("auto", "png", "webp") MAX_RESPONSES_IMAGE_RETRIES = 4 INPUT_IMAGE_RATE_LIMIT_DELAYS = (65.0, 130.0, 260.0, 300.0) @@ -42,7 +55,8 @@ class CliError(Exception): class HttpError(CliError): - def __init__(self, status: int, body: str) -> None: + def __init__(self, status: int, body: str, retry_after: str | None = None) -> None: + self.retry_after = retry_after super().__init__(f"HTTP {status}: {body[:800]}") self.status = status self.body = body @@ -85,22 +99,19 @@ def _log(message: str) -> None: print(message, file=sys.stderr) -def _read_prompt(prompt: Optional[str], prompt_file: Optional[str], cd: Path) -> str: +def _read_prompt(prompt: str | None, prompt_file: str | None, cd: Path) -> str: text = _read_optional_prompt(prompt, prompt_file, cd) if text is None: raise CliError("Missing prompt. Use --prompt or --prompt-file.") return text -def _read_optional_prompt(prompt: Optional[str], prompt_file: Optional[str], cd: Path) -> Optional[str]: - if prompt and prompt_file: +def _read_optional_prompt(prompt: str | None, prompt_file: str | None, cd: Path) -> str | None: + if prompt is not None and prompt_file is not None: raise CliError("Use --prompt or --prompt-file, not both.") - if prompt_file: - path = _resolve_path(prompt_file, cd) - if not path.exists(): - raise CliError(f"Prompt file not found: {path}") - text = path.read_text(encoding="utf-8").strip() - elif prompt: + if prompt_file is not None: + text = _read_text(_resolve_path(prompt_file, cd), "Prompt file").strip() + elif prompt is not None: text = prompt.strip() else: return None @@ -109,7 +120,14 @@ def _read_optional_prompt(prompt: Optional[str], prompt_file: Optional[str], cd: return text -def _style_transfer_prompt(extra_prompt: Optional[str] = None) -> str: +def _read_text(path: Path, label: str) -> str: + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise CliError(f"{label} could not be read: {path} ({exc})") from exc + + +def _style_transfer_prompt(extra_prompt: str | None = None) -> str: prompt = ( "Create a new version of the first input image. Preserve its main subject, identity, " "composition, proportions, and important details. Use the final input image only as a style " @@ -121,7 +139,7 @@ def _style_transfer_prompt(extra_prompt: Optional[str] = None) -> str: return prompt -def _resolve_cd(raw_cd: Optional[str]) -> Path: +def _resolve_cd(raw_cd: str | None) -> Path: cd = Path(raw_cd or os.getcwd()).expanduser().resolve() if not cd.exists(): raise CliError(f"--cd directory does not exist: {cd}") @@ -151,12 +169,12 @@ def _slugify(value: str) -> str: return value[:60] or "image" -def _load_jobs_jsonl(path: str, cd: Path) -> List[dict[str, Any]]: +def _load_jobs_jsonl(path: str, cd: Path) -> list[dict[str, Any]]: input_path = _resolve_path(path, cd) if not input_path.exists(): raise CliError(f"Batch input not found: {input_path}") - jobs: List[dict[str, Any]] = [] - for line_no, raw in enumerate(input_path.read_text(encoding="utf-8").splitlines(), start=1): + jobs: list[dict[str, Any]] = [] + for line_no, raw in enumerate(_read_text(input_path, "Batch input").splitlines(), start=1): line = raw.strip() if not line or line.startswith("#"): continue @@ -168,15 +186,17 @@ def _load_jobs_jsonl(path: str, cd: Path) -> List[dict[str, Any]]: item = {"prompt": item} if not isinstance(item, dict): raise CliError(f"Line {line_no} must be a JSON string or object.") - prompt = str(item.get("prompt", "")).strip() - if not prompt: + prompt = item.get("prompt") + if not isinstance(prompt, str) or not prompt.strip(): raise CliError(f"Line {line_no} is missing a non-empty prompt.") images = item.get("images", []) if images is None: images = [] if not isinstance(images, list) or not all(isinstance(v, str) for v in images): raise CliError(f"Line {line_no} images must be a list of strings.") - jobs.append(item) + if "out" in item and (not isinstance(item["out"], str) or not item["out"].strip()): + raise CliError(f"Line {line_no} out must be a non-empty string.") + jobs.append({**item, "prompt": prompt.strip(), "images": images}) if not jobs: raise CliError("Batch input did not contain any jobs.") return jobs @@ -222,10 +242,12 @@ def _auth_file(args: argparse.Namespace) -> Path: def _load_auth(auth_file: Path) -> dict[str, Any]: if not auth_file.exists(): raise CliError( - f"Codex auth file not found: {auth_file}. Run `codex login` and choose ChatGPT." + f"Codex auth file not found: {auth_file}. This CLI requires file-based ChatGPT auth; " + 'keyring and ephemeral credentials are not supported. Set cli_auth_credentials_store = "file" ' + "in Codex config before logging in, or use --auth-file with an existing auth.json." ) try: - data = json.loads(auth_file.read_text(encoding="utf-8")) + data = json.loads(_read_text(auth_file, "Codex auth file")) except json.JSONDecodeError as exc: raise CliError(f"Invalid Codex auth file JSON: {auth_file}") from exc if not isinstance(data, dict): @@ -252,11 +274,13 @@ def _access_token(auth: dict[str, Any]) -> str: raise CliError("Codex ChatGPT auth not found. Run `codex login` and choose ChatGPT.") access_token = tokens.get("access_token") if not isinstance(access_token, str) or not access_token: - raise CliError("Codex ChatGPT access token not found. Run `codex login` and choose ChatGPT.") + raise CliError( + "Codex ChatGPT access token not found. Run `codex login` and choose ChatGPT." + ) return access_token -def _account_id(auth: dict[str, Any]) -> Optional[str]: +def _account_id(auth: dict[str, Any]) -> str | None: tokens = auth.get("tokens") if isinstance(tokens, dict): account_id = tokens.get("account_id") @@ -341,7 +365,7 @@ def _id_token_from_jwt(raw_jwt: str) -> dict[str, Any]: def _post_json( url: str, *, - headers: Optional[dict[str, str]] = None, + headers: dict[str, str] | None = None, payload: dict[str, Any], timeout: float, ) -> dict[str, Any]: @@ -355,66 +379,78 @@ def _post_json( raw = response.read().decode("utf-8") except error.HTTPError as exc: raw = exc.read().decode("utf-8", errors="replace") - raise HttpError(exc.code, raw) from exc + raise HttpError( + exc.code, raw, exc.headers.get("Retry-After") if exc.headers else None + ) from exc except error.URLError as exc: raise TransportError(f"Request failed: {exc.reason}") from exc - except OSError as exc: + except (OSError, HTTPException, UnicodeError) as exc: raise TransportError(f"Request failed: {exc}") from exc if not raw: return {} try: parsed = json.loads(raw) except json.JSONDecodeError as exc: - raise CliError(f"Response was not valid JSON: {raw[:800]}") from exc + raise CliError("Response was not valid JSON.") from exc if not isinstance(parsed, dict): raise CliError("Response JSON was not an object.") return parsed -def _post_sse( - url: str, - *, - headers: dict[str, str], - payload: dict[str, Any], - timeout: float, -): - request_headers = dict(headers) - request_headers["Accept"] = "text/event-stream" - request_headers["Content-Type"] = "application/json" +def _post_sse(url: str, *, headers: dict[str, str], payload: dict[str, Any], timeout: float): + request_headers = {**headers, "Accept": "text/event-stream", "Content-Type": "application/json"} req = request.Request( - url, - data=json.dumps(payload).encode("utf-8"), - headers=request_headers, - method="POST", + url, data=json.dumps(payload).encode("utf-8"), headers=request_headers, method="POST" ) try: with request.urlopen(req, timeout=timeout) as response: + decoder = codecs.getincrementaldecoder("utf-8")() pending = "" + block = [] + # read1 avoids waiting for 4096 bytes before yielding a small event. + read = getattr(response, "read1", response.read) while True: - chunk = response.read(4096) + chunk = read(4096) + pending += decoder.decode(chunk, final=not chunk) + while True: + match = re.search(r"[\r\n]", pending) + if match is None: + break + idx = match.start() + if pending[idx:] == "\r" and chunk: + break # CRLF may straddle chunks. + width = 2 if pending[idx : idx + 2] == "\r\n" else 1 + line, pending = pending[:idx], pending[idx + width :] + if line: + block.append(line) + else: + parsed = _parse_sse_block("\n".join(block)) + block = [] + if parsed is not None: + yield parsed if not chunk: - break - pending += chunk.decode("utf-8", errors="replace") - while "\n\n" in pending: - block, pending = pending.split("\n\n", 1) - parsed = _parse_sse_block(block) + if pending: + block.append(pending) + parsed = _parse_sse_block("\n".join(block)) if parsed is not None: yield parsed - parsed = _parse_sse_block(pending) - if parsed is not None: - yield parsed + break except error.HTTPError as exc: raw = exc.read().decode("utf-8", errors="replace") - raise HttpError(exc.code, raw) from exc + raise HttpError( + exc.code, raw, exc.headers.get("Retry-After") if exc.headers else None + ) from exc except error.URLError as exc: - raise TransportError(f"Request failed while reading streamed response: {exc.reason}") from exc - except OSError as exc: + raise TransportError( + f"Request failed while reading streamed response: {exc.reason}" + ) from exc + except (OSError, HTTPException, UnicodeError) as exc: raise TransportError(f"Request failed while reading streamed response: {exc}") from exc -def _parse_sse_block(block: str) -> Optional[Tuple[Optional[str], str]]: +def _parse_sse_block(block: str) -> tuple[str | None, str] | None: event = None - data_lines: List[str] = [] + data_lines: list[str] = [] for raw in block.splitlines(): if raw.startswith("event:"): event = raw.split(":", 1)[1].strip() @@ -426,34 +462,124 @@ def _parse_sse_block(block: str) -> Optional[Tuple[Optional[str], str]]: def _refresh_auth(auth: dict[str, Any], auth_file: Path, *, timeout: float) -> dict[str, Any]: - tokens = auth.get("tokens") - if not isinstance(tokens, dict): - raise CliError("Codex ChatGPT auth not found. Run `codex login` and choose ChatGPT.") - refresh_token = tokens.get("refresh_token") - if not isinstance(refresh_token, str) or not refresh_token: - raise CliError("Codex refresh token not found. Run `codex login` again.") + # A separate lock survives atomic auth.json replacement. Codex itself does not + # share this lock, so also re-read before and after the network request. + with _auth_refresh_lock(auth_file, timeout): + existed = auth_file.exists() + current = _load_auth(auth_file) if existed else auth + if _account_id(current) != _account_id(auth): + raise CliError("Codex account changed during this request. Run the command again.") + if current.get("tokens") != auth.get("tokens") and not _token_is_expiring( + _access_token(current) + ): + return current + updated = copy.deepcopy(current) + tokens = updated.get("tokens") + if not isinstance(tokens, dict): + raise CliError("Codex ChatGPT auth not found. Run `codex login` and choose ChatGPT.") + refresh_token = tokens.get("refresh_token") + if not isinstance(refresh_token, str) or not refresh_token: + raise CliError("Codex refresh token not found. Run `codex login` again.") + payload = { + "client_id": CODEX_CLIENT_ID, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + } + refresh_url = os.environ.get("CODEX_IMAGEGEN_REFRESH_URL", DEFAULT_REFRESH_URL) + try: + refreshed = _post_json(refresh_url, payload=payload, timeout=timeout) + except HttpError as exc: + # OAuth error bodies can contain credentials; never echo them. + raise CliError( + f"Codex token refresh failed (HTTP {exc.status}). Run `codex login` again." + ) from exc + access = refreshed.get("access_token") + if not isinstance(access, str) or not access: + raise CliError( + "Codex token refresh returned no access token; auth file was not changed." + ) + for key in ("access_token", "refresh_token", "id_token"): + value = refreshed.get(key) + if isinstance(value, str) and value: + tokens[key] = value + # Repair a legacy dictionary only when its original JWT is available. + if isinstance(tokens.get("id_token"), dict): + tokens["id_token"] = tokens["id_token"].get("raw_jwt") + if not isinstance(tokens.get("id_token"), str) or not tokens["id_token"]: + raise CliError("Codex ID token is invalid. Run `codex login` again.") + updated["last_refresh"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + if (existed and not auth_file.exists()) or ( + auth_file.exists() and _load_auth(auth_file) != current + ): + raise CliError( + "Codex auth changed while refreshing; refusing to overwrite it. Run the command again." + ) + _atomic_write(auth_file, (json.dumps(updated, indent=2) + "\n").encode(), replace=True) + return updated - refresh_url = os.environ.get("CODEX_IMAGEGEN_REFRESH_URL", DEFAULT_REFRESH_URL) - payload = { - "client_id": CODEX_CLIENT_ID, - "grant_type": "refresh_token", - "refresh_token": refresh_token, - } - refreshed = _post_json(refresh_url, payload=payload, timeout=timeout) - for key in ("access_token", "refresh_token"): - value = refreshed.get(key) - if isinstance(value, str) and value: - tokens[key] = value - id_token = refreshed.get("id_token") - if isinstance(id_token, str) and id_token: - tokens["id_token"] = _id_token_from_jwt(id_token) - auth["last_refresh"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + +@contextmanager +def _auth_refresh_lock(auth_file: Path, timeout: float): auth_file.parent.mkdir(parents=True, exist_ok=True) - auth_file.write_text(json.dumps(auth, indent=2) + "\n", encoding="utf-8") - return auth + lock_path = auth_file.with_name(auth_file.name + ".imagegen.lock") + flags = os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(lock_path, flags, 0o600) + with os.fdopen(fd, "r+b") as lock: + if os.name == "nt": + import msvcrt + + if lock_path.stat().st_size == 0: + lock.write(b"0") + lock.flush() + else: + import fcntl + deadline = time.monotonic() + min(timeout, 30.0) + while True: + try: + if os.name == "nt": + lock.seek(0) + msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1) + else: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except (BlockingIOError, OSError) as exc: + if time.monotonic() >= deadline: + raise CliError("Timed out waiting for another imagegen auth refresh.") from exc + time.sleep(0.05) + try: + yield + finally: + if os.name == "nt": + lock.seek(0) + msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + + +def _atomic_write(path: Path, data: bytes, *, replace: bool) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) + try: + with os.fdopen(fd, "wb") as output: + output.write(data) + output.flush() + os.fsync(output.fileno()) + if replace: + os.replace(temporary, path) + else: + # Atomic no-clobber publication: an output created during generation + # must not be overwritten, even after the earlier preflight check. + try: + os.link(temporary, path) + except FileExistsError as exc: + raise CliError( + f"Output already exists: {path} (use --force to allow replacement)" + ) from exc + finally: + Path(temporary).unlink(missing_ok=True) -def _load_ready_auth(args: argparse.Namespace) -> Tuple[dict[str, Any], Path]: +def _load_ready_auth(args: argparse.Namespace) -> tuple[dict[str, Any], Path]: auth_file = _auth_file(args) auth = _load_auth(auth_file) if _token_is_expiring(_access_token(auth)): @@ -471,17 +597,20 @@ def _data_url_for_image(path: Path, args: argparse.Namespace) -> str: return f"data:{mime_type};base64,{encoded}" -def _encoded_input_image(path: Path, args: argparse.Namespace) -> Tuple[bytes, str]: +def _encoded_input_image(path: Path, args: argparse.Namespace) -> tuple[bytes, str]: try: - with Image.open(path) as image: - image.load() - image = _resize_input_image(image, args.input_max_edge) + with Image.open(path) as source: + source.load() + image = _resize_input_image(ImageOps.exif_transpose(source), args.input_max_edge) + if image.mode not in {"RGB", "RGBA"}: + image = image.convert( + "RGBA" if "A" in image.getbands() or "transparency" in image.info else "RGB" + ) output = BytesIO() image.save(output, "WEBP", quality=args.input_webp_quality) return output.getvalue(), "image/webp" - except Exception as exc: - _warn(f"could not compact input image {path}; sending original bytes ({exc})") - return path.read_bytes(), mimetypes.guess_type(str(path))[0] or "image/png" + except (OSError, ValueError, Image.DecompressionBombError) as exc: + raise CliError(f"Could not read/compact input image {path}: {exc}") from exc def _resize_input_image(image: Image.Image, max_edge: int) -> Image.Image: @@ -506,14 +635,46 @@ def _default_model(args: argparse.Namespace) -> str: if env_model: return env_model config_file = _codex_config_file(args) + config = {} if config_file.exists(): - match = re.search( - r"""(?m)^model\s*=\s*["']([^"']+)["']""", - config_file.read_text(encoding="utf-8"), - ) - if match: - return match.group(1) - return DEFAULT_CODEX_MODEL + try: + config = tomllib.loads(_read_text(config_file, "Codex config")) + except tomllib.TOMLDecodeError as exc: + raise CliError(f"Invalid Codex config TOML: {config_file} ({exc})") from exc + profile = getattr(args, "profile", None) or config.get("profile") + model = config.get("model", DEFAULT_CODEX_MODEL) + if profile: + profiles = config.get("profiles", {}) + if ( + not isinstance(profile, str) + or not isinstance(profiles, dict) + or profile not in profiles + ): + raise CliError(f"Unknown Codex profile: {profile}") + settings = profiles[profile] + if not isinstance(settings, dict): + raise CliError(f"Invalid Codex profile: {profile}") + model = settings.get("model", model) + if not isinstance(model, str) or not model.strip(): + raise CliError("Codex model must be a non-empty string.") + return model + + +def _native_payload( + *, prompt: str, args: argparse.Namespace, mode: str, images: Sequence[Path] | None = None +) -> dict[str, Any]: + payload = { + "model": NATIVE_REQUEST_MODEL, + "prompt": prompt, + "size": args.size, + "quality": args.quality, + "background": args.background, + } + if mode == "edit": + if not images or len(images) > MAX_EDIT_IMAGES: + raise CliError(f"Edit supports one to {MAX_EDIT_IMAGES} images.") + payload["images"] = [{"image_url": _data_url_for_image(path, args)} for path in images] + return payload def _responses_payload( @@ -521,9 +682,9 @@ def _responses_payload( prompt: str, args: argparse.Namespace, mode: str, - images: Optional[Sequence[Path]] = None, + images: Sequence[Path] | None = None, ) -> dict[str, Any]: - content: List[dict[str, Any]] = [{"type": "input_text", "text": prompt}] + content: list[dict[str, Any]] = [{"type": "input_text", "text": prompt}] if images: if len(images) > MAX_EDIT_IMAGES: raise CliError(f"Edit supports at most {MAX_EDIT_IMAGES} images.") @@ -569,19 +730,42 @@ def _write_response_image( force: bool, output_format: str, webp_quality: int, + requested_size: str = "auto", + size_policy: str = "warn", ) -> Path: _check_output(output_path, force) - output_path.parent.mkdir(parents=True, exist_ok=True) + if len(encoded) > ((MAX_IMAGE_BYTES + 2) // 3) * 4: + raise CliError("Image generation result exceeds the 32 MiB limit.") try: - image_bytes = base64.b64decode(encoded) + image_bytes = base64.b64decode(encoded.strip(), validate=True) except ValueError as exc: raise CliError("Image generation result was not valid base64.") from exc + try: + with Image.open(BytesIO(image_bytes)) as image: + if image.format != "PNG": + raise CliError(f"Expected PNG image data, received {image.format}.") + image.verify() + with Image.open(BytesIO(image_bytes)) as image: + image.load() + actual_size = f"{image.width}x{image.height}" + except (OSError, ValueError, Image.DecompressionBombError) as exc: + raise CliError(f"Image generation returned an invalid image: {exc}") from exc + if requested_size != "auto" and requested_size != actual_size: + message = f"Requested {requested_size}, backend returned {actual_size} for {output_path}." + if size_policy == "error": + raise CliError(message + " Output was not written (--size-policy error).") + _warn( + message + + " Saving the original dimensions; use --size-policy error to reject mismatches." + ) _write_image_bytes( image_bytes, output_path, output_format=output_format, webp_quality=webp_quality, + force=force, ) + _log(f" saved {actual_size} {_resolve_output_format(output_path, output_format).upper()}") return output_path @@ -591,17 +775,17 @@ def _write_image_bytes( *, output_format: str, webp_quality: int, + force: bool = False, ) -> None: - resolved_format = _resolve_output_format(output_path, output_format) - if resolved_format == "png": - output_path.write_bytes(image_bytes) - return - - try: - with Image.open(BytesIO(image_bytes)) as image: - image.save(output_path, "WEBP", quality=webp_quality) - except Exception as exc: - raise CliError(f"Failed to convert image to WebP: {exc}") from exc + if _resolve_output_format(output_path, output_format) == "webp": + try: + with Image.open(BytesIO(image_bytes)) as image: + output = BytesIO() + image.save(output, "WEBP", quality=webp_quality) + image_bytes = output.getvalue() + except (OSError, ValueError) as exc: + raise CliError(f"Failed to convert image to WebP: {exc}") from exc + _atomic_write(output_path, image_bytes, replace=force) def _resolve_output_format(output_path: Path, output_format: str) -> str: @@ -610,7 +794,7 @@ def _resolve_output_format(output_path: Path, output_format: str) -> str: return "webp" if output_path.suffix.lower() == ".webp" else "png" -def _output_paths(output_path: Path, count: int) -> List[Path]: +def _output_paths(output_path: Path, count: int) -> list[Path]: if count <= 1: return [output_path] suffix = output_path.suffix or ".png" @@ -628,10 +812,10 @@ def _redacted_headers(headers: dict[str, str]) -> dict[str, str]: def _dry_run_payload( *, url: str, - headers: Optional[dict[str, str]], + headers: dict[str, str] | None, payload: dict[str, Any], output_path: Path, - output_count: Optional[int] = None, + output_count: int | None = None, ) -> dict[str, Any]: count = output_count if output_count is not None else int(payload.get("n", 1)) return { @@ -667,10 +851,50 @@ def _call_responses_backend( mode: str, prompt: str, output_path: Path, - image_paths: Optional[Sequence[Path]] = None, -) -> List[Path]: - url = _endpoint(args.base_url, "responses") - payload = _responses_payload(prompt=prompt, args=args, mode=mode, images=image_paths) + image_paths: Sequence[Path] | None = None, +) -> list[Path]: + return _call_backend( + args=args, + output_path=output_path, + url=_endpoint(args.base_url, "responses"), + payload=_responses_payload(prompt=prompt, args=args, mode=mode, images=image_paths), + native=False, + ) + + +def _call_native_backend( + *, + args: argparse.Namespace, + mode: str, + prompt: str, + output_path: Path, + image_paths: Sequence[Path] | None = None, +) -> list[Path]: + return _call_backend( + args=args, + output_path=output_path, + url=_endpoint(args.base_url, "images/edits" if mode == "edit" else "images/generations"), + payload=_native_payload(prompt=prompt, args=args, mode=mode, images=image_paths), + native=True, + ) + + +def _native_image_result( + url: str, *, headers: dict[str, str], payload: dict[str, Any], timeout: float +) -> str: + response = _post_json(url, headers=headers, payload=payload, timeout=timeout) + data = response.get("data") + if not isinstance(data, list) or len(data) != 1 or not isinstance(data[0], dict): + raise CliError("Native image response must contain exactly one image in data.") + encoded = data[0].get("b64_json") + if not isinstance(encoded, str) or not encoded: + raise CliError("Native image response contains no base64 image result.") + return encoded + + +def _call_backend( + *, args: argparse.Namespace, output_path: Path, url: str, payload: dict[str, Any], native: bool +) -> list[Path]: output_paths = _output_paths(output_path, args.n) if args.dry_run: print( @@ -686,47 +910,47 @@ def _call_responses_backend( ) ) return output_paths - auth, auth_file = _load_ready_auth(args) headers = _auth_headers(auth) - saved: List[Path] = [] + saved = [] + call = _native_image_result if native else _stream_image_result for idx, path in enumerate(output_paths, start=1): - current_payload = dict(payload) + if _token_is_expiring(_access_token(auth)): + auth = _refresh_auth(auth, auth_file, timeout=args.timeout) + headers = _auth_headers(auth) if args.n > 1: - current_payload["prompt_cache_key"] = f"codex-imagegen-cli-{idx}" _log(f" image {idx}/{args.n} ...") attempt = 0 + refreshed = False while True: try: - encoded = _stream_image_result( - url, - headers=headers, - payload=current_payload, - timeout=args.timeout, - ) + encoded = call(url, headers=headers, payload=payload, timeout=args.timeout) break except HttpError as exc: - if exc.status != 401: + if exc.status == 401 and not refreshed: + auth = _refresh_auth(auth, auth_file, timeout=args.timeout) + headers = _auth_headers(auth) + refreshed = True + continue + retry = _http_retry_decision(exc, attempt) + if retry is None or attempt >= MAX_RESPONSES_IMAGE_RETRIES: + if exc.status == 404 and not native and "model_not_found" in exc.body: + raise CliError( + str(exc) + + " Select an accessible --model with --backend responses, or use --backend native." + ) from exc raise - auth = _refresh_auth(auth, auth_file, timeout=args.timeout) - headers = _auth_headers(auth) - encoded = _stream_image_result( - url, - headers=headers, - payload=current_payload, - timeout=args.timeout, - ) - break except ResponsesImageGenerationError as exc: retry = _responses_image_retry_decision(exc.event, attempt) if retry is None or attempt >= MAX_RESPONSES_IMAGE_RETRIES: raise - attempt += 1 - _warn( - f"{retry.reason}; " - f"retrying in {retry.delay:.1f}s ({attempt}/{MAX_RESPONSES_IMAGE_RETRIES})" - ) - time.sleep(retry.delay) + # Transport failures and ambiguous server errors are deliberately not + # replayed: an image may already have consumed account usage. + attempt += 1 + _warn( + f"{retry.reason}; retrying in {retry.delay:.1f}s ({attempt}/{MAX_RESPONSES_IMAGE_RETRIES})" + ) + time.sleep(retry.delay) saved.append( _write_response_image( encoded, @@ -734,17 +958,65 @@ def _call_responses_backend( force=args.force, output_format=args.output_format, webp_quality=args.webp_quality, + requested_size=args.size, + size_policy=args.size_policy, ) ) + # Publish each successful path immediately, even if a later image fails. + print(str(path), flush=True) return saved -def _responses_image_retry_delay(event: dict[str, Any], attempt: int) -> Optional[float]: +def _http_retry_decision(exc: HttpError, attempt: int) -> RetryDecision | None: + if exc.status != 429: + return None + try: + body = json.loads(exc.body) + except ValueError: + body = {} + error_obj = body.get("error", {}) if isinstance(body, dict) else {} + if isinstance(error_obj, dict) and ( + error_obj.get("code") not in {None, "rate_limit_exceeded"} + or error_obj.get("type") in {"image_generation_user_error", "insufficient_quota"} + ): + return None # Quota/billing/user errors need intervention, not a retry. + decision = _responses_image_retry_decision( + { + "response": { + "error": { + **(error_obj if isinstance(error_obj, dict) else {}), + "code": "rate_limit_exceeded", + } + } + }, + attempt, + ) + if decision is None: + return None + delay = decision.delay + if exc.retry_after: + try: + wait = float(exc.retry_after) + except ValueError: + try: + wait = parsedate_to_datetime(exc.retry_after).timestamp() - time.time() + except (ValueError, TypeError, OverflowError): + wait = 0 + if not math.isfinite(wait): + return None + delay = max(delay, wait) + # Keep server-supplied delays bounded too. + if delay > 300: + return None + return RetryDecision(delay, decision.reason) + + +def _responses_image_retry_delay(event: dict[str, Any], attempt: int) -> float | None: decision = _responses_image_retry_decision(event, attempt) return decision.delay if decision is not None else None -def _responses_image_retry_decision(event: dict[str, Any], attempt: int) -> Optional[RetryDecision]: +def _responses_image_retry_decision(event: dict[str, Any], attempt: int) -> RetryDecision | None: error_obj = _responses_image_error(event) if not isinstance(error_obj, dict) or error_obj.get("code") != "rate_limit_exceeded": return None @@ -752,7 +1024,9 @@ def _responses_image_retry_decision(event: dict[str, Any], attempt: int) -> Opti parsed_delay = None if isinstance(message, str): if "input-images per min" in message and _rate_limit_bucket_exhausted(message): - delay = INPUT_IMAGE_RATE_LIMIT_DELAYS[min(attempt, len(INPUT_IMAGE_RATE_LIMIT_DELAYS) - 1)] + delay = INPUT_IMAGE_RATE_LIMIT_DELAYS[ + min(attempt, len(INPUT_IMAGE_RATE_LIMIT_DELAYS) - 1) + ] return RetryDecision(delay, "input-image quota full") match = re.search(r"try again in\s+(\d+(?:\.\d+)?)\s*(ms|s)", message, re.IGNORECASE) if match: @@ -762,12 +1036,14 @@ def _responses_image_retry_decision(event: dict[str, Any], attempt: int) -> Opti backoff_delay = min(2.0**attempt, 16.0) if parsed_delay is None: return RetryDecision(backoff_delay, "image generation rate-limited") + if not math.isfinite(parsed_delay) or parsed_delay > 300: + return None return RetryDecision(max(parsed_delay, backoff_delay), "image generation rate-limited") -def _responses_image_error(event: dict[str, Any]) -> Optional[dict[str, Any]]: +def _responses_image_error(event: dict[str, Any]) -> dict[str, Any] | None: response = event.get("response") - error_obj = response.get("error") if isinstance(response, dict) else None + error_obj = response.get("error") if isinstance(response, dict) else event.get("error", event) return error_obj if isinstance(error_obj, dict) else None @@ -780,35 +1056,41 @@ def _rate_limit_bucket_exhausted(message: str) -> bool: def _stream_image_result( - url: str, - *, - headers: dict[str, str], - payload: dict[str, Any], - timeout: float, + url: str, *, headers: dict[str, str], payload: dict[str, Any], timeout: float ) -> str: last_status = None - last_error = None - for _event, data in _post_sse(url, headers=headers, payload=payload, timeout=timeout): - if not data: - continue - try: - event = json.loads(data) - except json.JSONDecodeError: - continue - if not isinstance(event, dict): - continue - if event.get("type") in {"response.failed", "response.incomplete"}: - last_error = event - item = event.get("item") - if isinstance(item, dict) and item.get("type") == "image_generation_call": - last_status = item.get("status") - result = item.get("result") - if isinstance(result, str) and result: - return result - if last_error is not None: - raise ResponsesImageGenerationError(last_error) + stream = _post_sse(url, headers=headers, payload=payload, timeout=timeout) + try: + for _event, data in stream: + if not data or data == "[DONE]": + continue + try: + event = json.loads(data) + except json.JSONDecodeError as exc: + raise CliError("Responses stream contained invalid JSON.") from exc + if not isinstance(event, dict): + continue + if event.get("type") in {"response.failed", "response.incomplete", "error"}: + raise ResponsesImageGenerationError(event) + items = [event.get("item")] + if event.get("type") == "response.completed": + response = event.get("response") + if isinstance(response, dict) and isinstance(response.get("output"), list): + items.extend(response["output"]) + for item in items: + if isinstance(item, dict) and item.get("type") == "image_generation_call": + last_status = item.get("status") + result = item.get("result") + if isinstance(result, str) and result and last_status == "completed": + return result + finally: + close = getattr(stream, "close", None) + if close: + close() if last_status: - raise CliError(f"Responses stream ended without an image result; last status was {last_status}.") + raise CliError( + f"Responses stream ended without an image result; last status was {last_status}." + ) raise CliError("Responses stream ended without an image generation result.") @@ -818,9 +1100,10 @@ def _call_image_backend( mode: str, prompt: str, output_path: Path, - image_paths: Optional[Sequence[Path]] = None, -) -> List[Path]: - return _call_responses_backend( + image_paths: Sequence[Path] | None = None, +) -> list[Path]: + backend = _call_native_backend if args.backend == "native" else _call_responses_backend + return backend( args=args, mode=mode, prompt=prompt, @@ -829,18 +1112,13 @@ def _call_image_backend( ) -def _print_saved(paths: Sequence[Path]) -> None: - for path in paths: - print(str(path)) - - def _run_one( *, args: argparse.Namespace, mode: str, prompt: str, output_path: Path, - image_paths: Optional[Sequence[Path]] = None, + image_paths: Sequence[Path] | None = None, log_prefix: str = "", ) -> bool: if not args.dry_run: @@ -853,7 +1131,7 @@ def _run_one( _log(f"{prefix}{mode} {count}{output_path}") t0 = time.monotonic() - paths = _call_image_backend( + _call_image_backend( args=args, mode=mode, prompt=prompt, @@ -864,18 +1142,32 @@ def _run_one( if not args.dry_run: elapsed = time.monotonic() - t0 _log(f" done ({elapsed:.1f}s)") - _print_saved(paths) return True def _add_auth_args(parser: argparse.ArgumentParser) -> None: parser.add_argument("--cd", help="Base directory for resolving relative paths.") - parser.add_argument("--auth-file", help="Path to Codex auth.json. Defaults to $CODEX_HOME/auth.json.") - parser.add_argument("--codex-home", help="Codex home directory. Defaults to $CODEX_HOME or ~/.codex.") + parser.add_argument( + "--auth-file", help="Path to Codex auth.json. Defaults to $CODEX_HOME/auth.json." + ) + parser.add_argument( + "--codex-home", help="Codex home directory. Defaults to $CODEX_HOME or ~/.codex." + ) + parser.add_argument( + "--backend", + choices=["native", "responses"], + default="native", + help="Image backend (default: native). The image model is controlled by the backend.", + ) + parser.add_argument( + "--profile", help="Codex profile for reasoning-model selection (responses backend only)." + ) parser.add_argument( "--model", + "--reasoning-model", help=( - "Codex reasoning model for the direct hosted image tool. Defaults to CODEX_IMAGEGEN_MODEL, " + "Reasoning model for --backend responses only; does not select the image model. " + "Defaults to CODEX_IMAGEGEN_MODEL, " "then Codex config, then the built-in fallback." ), ) @@ -885,7 +1177,9 @@ def _add_auth_args(parser: argparse.ArgumentParser) -> None: help="Codex backend base URL.", ) parser.add_argument("--timeout", type=float, default=300.0, help="HTTP timeout in seconds.") - parser.add_argument("--dry-run", action="store_true", help="Print the request without contacting Codex.") + parser.add_argument( + "--dry-run", action="store_true", help="Print the request without contacting Codex." + ) def _add_image_args(parser: argparse.ArgumentParser) -> None: @@ -903,9 +1197,15 @@ def _add_image_args(parser: argparse.ArgumentParser) -> None: ) parser.add_argument( "--size", - choices=IMAGE_SIZE_CHOICES, + type=_parse_size, default="auto", - help="Direct size parameter. Choices: auto, 1024x1024, 1536x1024, 1024x1536.", + help="Requested image dimensions: auto or WIDTHxHEIGHT. Backend may return a different size.", + ) + parser.add_argument( + "--size-policy", + choices=["warn", "error"], + default="warn", + help="On a dimension mismatch, warn and save (default) or fail without writing.", ) parser.add_argument( "--output-format", @@ -941,7 +1241,38 @@ def _add_prompt_args(parser: argparse.ArgumentParser) -> None: parser.add_argument("--force", action="store_true") +def _parse_size(value: str) -> str: + if value == "auto": + return value + match = re.fullmatch(r"([1-9][0-9]{0,3})x([1-9][0-9]{0,3})", value) + if match: + width, height = map(int, match.groups()) + if ( + width % 16 == height % 16 == 0 + and max(width, height) <= 3840 + and max(width, height) <= 3 * min(width, height) + and 655360 <= width * height <= 8294400 + ): + return value + raise argparse.ArgumentTypeError( + "Size must be auto or WIDTHxHEIGHT: multiples of 16, " + "at most 3840 per edge, 1:3 to 3:1 aspect, 655360–8294400 pixels." + ) + + def _validate_common(args: argparse.Namespace) -> Path: + if not math.isfinite(args.timeout) or args.timeout <= 0: + raise CliError("--timeout must be a positive finite number.") + if args.backend == "native" and (args.model or args.profile): + raise CliError( + "--model/--reasoning-model and --profile require --backend responses. " + "The image model is controlled by the backend." + ) + if args.background == "transparent": + raise CliError( + "Transparent background is not supported by the current Codex image backend. " + "Use --background auto or opaque." + ) if args.n < 1: raise CliError("--n must be at least 1.") if not 1 <= args.webp_quality <= 100: @@ -988,19 +1319,24 @@ def _cmd_batch(args: argparse.Namespace) -> int: out_dir = _resolve_path(args.out_dir, cd) failures = 0 for idx, job in enumerate(jobs, start=1): - prompt = str(job["prompt"]).strip() - raw_out = job.get("out") - if raw_out: - output_path = _resolve_path(str(out_dir / str(raw_out)), cd) - else: - output_path = out_dir / f"{idx:03d}-{_slugify(prompt)}.png" - images = [_resolve_path(raw, cd) for raw in job.get("images", [])] - mode = str(job.get("mode", "edit" if images else "generate")) - if mode not in {"generate", "edit"}: - raise CliError(f"Job {idx} has invalid mode: {mode}") - if mode == "edit" and not images: - raise CliError(f"Job {idx} mode is edit but images is empty.") try: + prompt = job["prompt"] + raw_out = job.get("out") + output_path = ( + _resolve_path(str(out_dir / raw_out), cd) + if raw_out + else out_dir / f"{idx:03d}-{_slugify(prompt)}.png" + ) + if not output_path.is_relative_to(out_dir): + raise CliError(f"Job {idx} output must stay under --out-dir.") + images = [_resolve_path(raw, cd) for raw in job["images"]] + mode = job.get("mode", "edit" if images else "generate") + if mode not in ("generate", "edit"): + raise CliError(f"Job {idx} has invalid mode: {mode}") + if mode == "edit" and not images: + raise CliError(f"Job {idx} mode is edit but images is empty.") + if mode == "generate" and images: + raise CliError(f"Job {idx} has images but mode is generate; use edit.") _run_one( args=args, mode=mode, @@ -1009,7 +1345,7 @@ def _cmd_batch(args: argparse.Namespace) -> int: image_paths=images, log_prefix=f"[{idx}/{len(jobs)}]", ) - except CliError as exc: + except (CliError, OSError, UnicodeError) as exc: failures += 1 _warn(f"[{idx}/{len(jobs)}] failed: {exc}") if args.fail_fast: @@ -1057,13 +1393,15 @@ def build_parser() -> argparse.ArgumentParser: return parser -def main(argv: Optional[Sequence[str]] = None) -> int: +def main(argv: Sequence[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) try: return int(args.func(args)) except CliError as exc: _die(str(exc)) + except (OSError, UnicodeError) as exc: + _die(str(exc)) except KeyboardInterrupt: print("Interrupted.", file=sys.stderr) return 130 diff --git a/pyproject.toml b/pyproject.toml index 6722c20..53433b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["hatchling>=1.27.0"] +requires = ["hatchling>=1.32.0,<1.33"] build-backend = "hatchling.build" [project] name = "codex-imagegen-cli" -version = "0.1.0" +version = "0.2.0" description = "Scriptable image generation CLI using Codex ChatGPT auth." readme = "README.md" requires-python = ">=3.10" @@ -30,7 +30,8 @@ classifiers = [ "Topic :: Utilities", ] dependencies = [ - "pillow>=11.0", + "pillow>=12.3.0", + "tomli>=2.4.1; python_version < '3.11'", ] [project.urls] @@ -45,10 +46,10 @@ codex-imagegen-cli = "codex_imagegen_cli.cli:main" [dependency-groups] dev = [ - "build>=1.2.2.post1", - "pytest>=8.3", - "ruff>=0.8", - "twine>=6.0", + "build>=1.6.1", + "pytest>=9.1.1", + "ruff>=0.16.7", + "twine>=7.0.0", ] [tool.hatch.build.targets.wheel] @@ -69,7 +70,11 @@ include = [ [tool.pytest.ini_options] testpaths = ["tests"] +markers = ["live: opt-in real Codex image generation (consumes account usage)"] [tool.ruff] line-length = 100 target-version = "py310" + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "UP"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..db3bf5e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +"""Offline tests must not depend on a developer's Codex configuration or auth.""" + +import pytest + + +@pytest.fixture(autouse=True) +def isolated_codex_settings(tmp_path, monkeypatch, request): + if request.node.get_closest_marker("live"): + return + home = tmp_path / "isolated-codex" + monkeypatch.setenv("CODEX_HOME", str(home)) + monkeypatch.setenv("CODEX_IMAGEGEN_CODEX_VERSION", "0.154.0") + for name in [ + "CODEX_IMAGEGEN_MODEL", + "CODEX_IMAGEGEN_BASE_URL", + "CODEX_IMAGEGEN_REFRESH_URL", + "CODEX_INTERNAL_ORIGINATOR_OVERRIDE", + ]: + monkeypatch.delenv(name, raising=False) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3ea3df7..f6032d9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,13 +7,19 @@ import pytest from codex_imagegen_cli import cli -from codex_imagegen_cli.cli import main +from codex_imagegen_cli.cli import main as cli_main + + +def main(argv): + if argv and argv[0] in {"generate", "edit", "batch"}: + argv = [argv[0], "--backend", "responses", *argv[1:]] + return cli_main(argv) -PNG_BYTES = b"\x89PNG\r\n\x1a\n" VALID_PNG_BYTES = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC" ) +PNG_BYTES = VALID_PNG_BYTES def _jwt(payload: dict[str, object]) -> str: @@ -34,7 +40,7 @@ def _auth_file(tmp_path, access_token: str = "token"): "access_token": access_token, "refresh_token": "refresh", "account_id": "acct_123", - "id_token": {"chatgpt_account_is_fedramp": False}, + "id_token": _jwt({"email": "test@example.invalid"}), }, } ), @@ -183,7 +189,10 @@ def test_edit_dry_run_redacts_direct_input_images(tmp_path, capsys): assert code == 0 assert payload["url"] == "https://chatgpt.com/backend-api/codex/responses" - assert payload["payload"]["input"][0]["content"][1]["image_url"] == "data:image/png;base64," + assert ( + payload["payload"]["input"][0]["content"][1]["image_url"] + == "data:image/webp;base64," + ) assert payload["payload"]["tools"][0]["background"] == "auto" @@ -209,7 +218,10 @@ def test_edit_dry_run_compacts_valid_input_image_to_webp(tmp_path, capsys): payload = json.loads(captured.out) assert code == 0 - assert payload["payload"]["input"][0]["content"][1]["image_url"] == "data:image/webp;base64," + assert ( + payload["payload"]["input"][0]["content"][1]["image_url"] + == "data:image/webp;base64," + ) def test_model_default_prefers_env_before_codex_config(tmp_path, monkeypatch, capsys): @@ -417,7 +429,9 @@ def fake_urlopen(req, timeout): with pytest.raises(cli.TransportError) as exc: list(cli._post_sse("https://example.test/responses", headers={}, payload={}, timeout=1)) - assert str(exc.value) == "Request failed while reading streamed response: [Errno 32] Broken pipe" + assert ( + str(exc.value) == "Request failed while reading streamed response: [Errno 32] Broken pipe" + ) def test_stream_transport_error_is_user_facing(tmp_path, monkeypatch, capsys): @@ -425,7 +439,9 @@ def test_stream_transport_error_is_user_facing(tmp_path, monkeypatch, capsys): output = tmp_path / "mug.png" def fake_post_sse(url, *, headers, payload, timeout): - raise cli.TransportError("Request failed while reading streamed response: [Errno 32] Broken pipe") + raise cli.TransportError( + "Request failed while reading streamed response: [Errno 32] Broken pipe" + ) yield monkeypatch.setattr(cli, "_post_sse", fake_post_sse) @@ -445,7 +461,10 @@ def fake_post_sse(url, *, headers, payload, timeout): captured = capsys.readouterr() assert exc.value.code == 1 - assert "Error: Request failed while reading streamed response: [Errno 32] Broken pipe" in captured.err + assert ( + "Error: Request failed while reading streamed response: [Errno 32] Broken pipe" + in captured.err + ) def test_edit_encodes_input_image(tmp_path, monkeypatch): @@ -480,7 +499,9 @@ def fake_post_sse(url, *, headers, payload, timeout): assert code == 0 assert seen["url"].endswith("/responses") - assert seen["payload"]["input"][0]["content"][1]["image_url"].startswith("data:image/png;base64,") + assert seen["payload"]["input"][0]["content"][1]["image_url"].startswith( + "data:image/webp;base64," + ) assert seen["payload"]["tools"][0]["type"] == "image_generation" assert output.read_bytes() == PNG_BYTES @@ -662,7 +683,10 @@ def test_batch_accepts_jsonl_string_jobs(tmp_path, capsys): payload = json.loads(captured.out) assert code == 0 - assert payload["payload"]["input"][0]["content"][0]["text"] == "A compact shuttle parked in a hangar" + assert ( + payload["payload"]["input"][0]["content"][0]["text"] + == "A compact shuttle parked in a hangar" + ) assert payload["outputs"][0].endswith("001-a-compact-shuttle-parked-in-a-hangar.png") @@ -676,7 +700,7 @@ def test_size_rejects_unknown_value(tmp_path): "--out", str(tmp_path / "mug.png"), "--size", - "2048x2048", + "1023x1024", "--dry-run", ] ) diff --git a/tests/test_controls.py b/tests/test_controls.py new file mode 100644 index 0000000..7181110 --- /dev/null +++ b/tests/test_controls.py @@ -0,0 +1,112 @@ +"""Positive controls for the focused audit; all transport/auth data is synthetic.""" + +import json +from io import BytesIO + +import pytest +from PIL import Image +from test_regressions import args_for, fake_auth, image_result + +from codex_imagegen_cli import cli + + +def test_real_sse_reader_handles_lf_delimited_events(monkeypatch): + encoded = image_result() + event = { + "type": "response.output_item.done", + "item": { + "type": "image_generation_call", + "status": "completed", + "result": encoded, + }, + } + data = ( + "data: " + + json.dumps({"type": "response.created"}) + + "\n\n" + + "data: " + + json.dumps(event) + + "\n\n" + ).encode() + monkeypatch.setattr(cli.request, "urlopen", lambda *a, **kw: BytesIO(data)) + assert ( + cli._stream_image_result("https://example.invalid", headers={}, payload={}, timeout=1) + == encoded + ) + + +def test_multiple_outputs_are_valid_numbered_images(tmp_path, monkeypatch): + calls = [] + + def stream(*a, **kw): + calls.append(kw["payload"]) + return image_result() + + monkeypatch.setattr(cli, "_stream_image_result", stream) + monkeypatch.setattr(cli, "_load_ready_auth", lambda a: (fake_auth(), tmp_path / "auth.json")) + monkeypatch.setattr(cli, "_auth_headers", lambda a: {}) + args = args_for(tmp_path) + args.n = 2 + out = cli._call_responses_backend( + args=args, mode="generate", prompt="test", output_path=tmp_path / "out.png" + ) + assert [p.name for p in out] == ["out-1.png", "out-2.png"] + assert len(calls) == 2 + for p in out: + with Image.open(p) as image: + image.verify() + + +def test_existing_output_is_protected_before_backend_call(tmp_path, monkeypatch): + out = tmp_path / "out.png" + out.write_bytes(b"existing file") + + def forbidden(**kw): + pytest.fail("backend must not run") + + monkeypatch.setattr(cli, "_call_image_backend", forbidden) + with pytest.raises(cli.CliError, match="already exists"): + cli._run_one(args=args_for(tmp_path), mode="generate", prompt="test", output_path=out) + assert out.read_bytes() == b"existing file" + + +@pytest.mark.parametrize("fail_fast", [False, True]) +def test_batch_handles_backend_job_failures(tmp_path, monkeypatch, fail_fast): + jobs = tmp_path / "jobs.jsonl" + jobs.write_text('"bad"\n"good"\n') + calls = [] + + def run(**kw): + calls.append(kw["prompt"]) + if kw["prompt"] == "bad": + raise cli.CliError("simulated backend failure") + return True + + monkeypatch.setattr(cli, "_run_one", run) + argv = ["batch", "--input", str(jobs), "--out-dir", str(tmp_path)] + if fail_fast: + argv.append("--fail-fast") + assert cli.main(argv) == 1 + assert calls == (["bad"] if fail_fast else ["bad", "good"]) + + +def test_dry_run_never_reads_auth(tmp_path, monkeypatch): + def forbidden(*a, **kw): + pytest.fail("auth must not be read") + + monkeypatch.setattr(cli, "_load_ready_auth", forbidden) + assert ( + cli.main( + [ + "generate", + "--prompt", + "test", + "--out", + str(tmp_path / "out.png"), + "--dry-run", + "--auth-file", + "/nonexistent/auth.json", + ] + ) + == 0 + ) diff --git a/tests/test_live.py b/tests/test_live.py new file mode 100644 index 0000000..6c5cbc1 --- /dev/null +++ b/tests/test_live.py @@ -0,0 +1,107 @@ +"""Opt-in compatibility checks: three image requests using existing ChatGPT auth. + +CODEX_IMAGEGEN_LIVE_TEST=1 uv run pytest -m live -v +Never refreshes or writes the user's credentials. No servers or subprocess agents. +""" + +import os + +import pytest +from PIL import Image + +from codex_imagegen_cli import cli + +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif( + os.environ.get("CODEX_IMAGEGEN_LIVE_TEST") != "1", + reason="live account usage is opt-in", + ), +] + + +@pytest.fixture +def live_options(monkeypatch): + def refuse_refresh(*args, **kwargs): + raise cli.CliError( + "Live compatibility check will not refresh shared credentials; log in with Codex and retry." + ) + + monkeypatch.setattr(cli, "_refresh_auth", refuse_refresh) + monkeypatch.setattr(cli, "MAX_RESPONSES_IMAGE_RETRIES", 0) + monkeypatch.delenv("CODEX_IMAGEGEN_BASE_URL", raising=False) + args = cli.build_parser().parse_args(["generate", "--prompt", "test", "--out", "unused.png"]) + auth = cli._load_auth(cli._auth_file(args)) + assert not cli._token_is_expiring(cli._access_token(auth)), ( + "Use Codex to refresh your login first" + ) + return ["--quality", "low", "--timeout", "120", "--size", "1024x1024"] + + +def test_live_native_generate_and_edit(tmp_path, monkeypatch, capsys, live_options): + source = tmp_path / "native.png" + assert ( + cli.main( + [ + "generate", + "--prompt", + "A solid blue circle on a plain white background. No text.", + "--out", + str(source), + *live_options, + ] + ) + == 0 + ) + with Image.open(source) as image: + image.load() + assert image.format == "PNG" + assert min(image.size) >= 256 + actual_size = image.size + output = capsys.readouterr() + assert output.out.splitlines() == [str(source)] + if actual_size != (1024, 1024): + assert "backend returned" in output.err + edited = tmp_path / "native-edit.webp" + assert ( + cli.main( + [ + "edit", + "--image", + str(source), + "--prompt", + "Change the blue circle to red. Preserve the composition and white background.", + "--out", + str(edited), + *live_options, + ] + ) + == 0 + ) + with Image.open(edited) as image: + image.load() + assert image.format == "WEBP" + assert min(image.size) >= 256 + + +def test_live_responses_compatibility(tmp_path, live_options): + out = tmp_path / "responses.png" + assert ( + cli.main( + [ + "generate", + "--backend", + "responses", + "--prompt", + "A solid green triangle on a plain white background. No text.", + "--out", + str(out), + *live_options, + ] + ) + == 0 + ) + with Image.open(out) as image: + image.load() + assert image.format == "PNG" + assert min(image.size) >= 256 diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py new file mode 100644 index 0000000..22f51fd --- /dev/null +++ b/tests/test_maintenance.py @@ -0,0 +1,503 @@ +"""Backend, credential persistence, output and transport integration regressions.""" + +import base64 +import copy +import json +import os +import shutil +import subprocess +import threading +from concurrent.futures import ThreadPoolExecutor +from email.utils import formatdate +from http.client import IncompleteRead +from io import BytesIO + +import pytest +from PIL import Image +from test_regressions import args_for, fake_auth, image_result, jwt + +from codex_imagegen_cli import cli + + +def install_fake_auth(tmp_path, monkeypatch): + auth = fake_auth() + path = tmp_path / "auth.json" + path.write_text(json.dumps(auth)) + monkeypatch.setattr(cli, "_load_ready_auth", lambda a: (auth, path)) + monkeypatch.setattr(cli, "_auth_headers", lambda a: {"Authorization": "Bearer fake-access"}) + return auth, path + + +def test_native_is_default_and_does_not_read_reasoning_config(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("CODEX_IMAGEGEN_IMAGE_MODEL", "nonexistent-legacy-override") + (tmp_path / "config.toml").write_text("not valid toml") + assert ( + cli.main( + [ + "generate", + "--prompt", + "native", + "--out", + str(tmp_path / "a.png"), + "--codex-home", + str(tmp_path), + "--dry-run", + ] + ) + == 0 + ) + request = json.loads(capsys.readouterr().out) + assert request["url"].endswith("/images/generations") + assert request["payload"] == { + "model": "gpt-image-2", + "prompt": "native", + "quality": "auto", + "size": "auto", + "background": "auto", + } + + +def test_native_edit_and_style_reference_encode_ordered_images(tmp_path, monkeypatch, capsys): + install_fake_auth(tmp_path, monkeypatch) + source = tmp_path / "content.png" + style = tmp_path / "style.png" + Image.new("RGB", (4, 4), "red").save(source) + Image.new("RGB", (4, 4), "blue").save(style) + calls = [] + + def post(url, **kw): + calls.append((url, kw)) + return {"data": [{"b64_json": image_result()}]} + + monkeypatch.setattr(cli, "_post_json", post) + out = tmp_path / "edit.webp" + assert ( + cli.main(["edit", "--image", str(source), "--style-image", str(style), "--out", str(out)]) + == 0 + ) + assert calls[0][0].endswith("/images/edits") + payload = calls[0][1]["payload"] + assert "final input image only as a style reference" in payload["prompt"] + for ref, color in zip(payload["images"], [0, 2], strict=True): + with Image.open(BytesIO(base64.b64decode(ref["image_url"].split(",")[1]))) as image: + assert image.getpixel((0, 0))[color] > 240 + with Image.open(out) as image: + assert image.format == "WEBP" + image.verify() + assert capsys.readouterr().out.splitlines() == [str(out)] + + +@pytest.mark.parametrize( + "body", + [ + {}, + {"data": []}, + {"data": [{"b64_json": None}]}, + {"data": [{"b64_json": "a"}, {"b64_json": "b"}]}, + ], +) +def test_native_rejects_malformed_response(body, monkeypatch): + monkeypatch.setattr(cli, "_post_json", lambda *a, **kw: body) + with pytest.raises(cli.CliError): + cli._native_image_result("https://example.invalid", headers={}, payload={}, timeout=1) + + +def test_native_multiple_outputs_preserves_progress_on_failure(tmp_path, monkeypatch, capsys): + install_fake_auth(tmp_path, monkeypatch) + calls = [] + + def post(*a, **kw): + calls.append(kw["payload"]) + if len(calls) == 2: + raise cli.TransportError("interrupted") + return {"data": [{"b64_json": image_result()}]} + + monkeypatch.setattr(cli, "_post_json", post) + with pytest.raises(SystemExit): + cli.main(["generate", "--prompt", "test", "--n", "2", "--out", str(tmp_path / "out.png")]) + assert capsys.readouterr().out.splitlines() == [str(tmp_path / "out-1.png")] + assert (tmp_path / "out-1.png").exists() + assert not (tmp_path / "out-2.png").exists() + assert len(calls) == 2 + + +@pytest.mark.parametrize("policy", ["warn", "error"]) +def test_size_mismatch_policy_preserves_existing_output(tmp_path, capsys, policy): + out = tmp_path / "out.png" + out.write_bytes(b"old") + kw = dict( + force=True, + output_format="png", + webp_quality=85, + requested_size="1024x1024", + size_policy=policy, + ) + if policy == "error": + with pytest.raises(cli.CliError, match="backend returned 2x2"): + cli._write_response_image(image_result(), out, **kw) + assert out.read_bytes() == b"old" + else: + cli._write_response_image(image_result(), out, **kw) + assert "backend returned 2x2" in capsys.readouterr().err + with Image.open(out) as image: + assert image.size == (2, 2) + + +def test_matching_size_and_auto_do_not_warn(tmp_path, capsys): + for size in ["auto", "2x2"]: + cli._write_response_image( + image_result(), + tmp_path / f"{size}.png", + force=False, + output_format="png", + webp_quality=85, + requested_size=size, + size_policy="error", + ) + assert "Warning" not in capsys.readouterr().err + + +@pytest.mark.parametrize("data", [b"not an image", b"\x89PNG\r\n\x1a\n"]) +def test_invalid_image_preserves_existing_output(tmp_path, data): + out = tmp_path / "out.png" + out.write_bytes(b"old") + with pytest.raises(cli.CliError, match="invalid image"): + cli._write_response_image( + base64.b64encode(data).decode(), out, force=True, output_format="png", webp_quality=85 + ) + assert out.read_bytes() == b"old" + + +def test_atomic_write_failure_preserves_original_and_cleans_temp(tmp_path, monkeypatch): + out = tmp_path / "auth.json" + out.write_bytes(b"original") + + def fail(*a): + raise OSError("simulated disk failure") + + monkeypatch.setattr(cli.os, "replace", fail) + with pytest.raises(OSError): + cli._atomic_write(out, b"new", replace=True) + assert out.read_bytes() == b"original" + assert sorted(p.name for p in tmp_path.iterdir()) == ["auth.json"] + + +def test_output_created_during_generation_is_not_overwritten(tmp_path, monkeypatch): + out = tmp_path / "image.png" + link = cli.os.link + + def race(source, target): + out.write_bytes(b"another process") + return link(source, target) + + monkeypatch.setattr(cli.os, "link", race) + with pytest.raises(cli.CliError, match="already exists"): + cli._write_response_image( + image_result(), out, force=False, output_format="png", webp_quality=85 + ) + assert out.read_bytes() == b"another process" + assert list(tmp_path.iterdir()) == [out] + + +def test_refresh_keeps_raw_jwt_permissions_and_unknown_fields(tmp_path, monkeypatch): + auth = fake_auth() + auth["extra"] = {"preserve": True} + original = copy.deepcopy(auth) + path = tmp_path / "auth.json" + path.write_text(json.dumps(auth)) + new_id = jwt({"email": "test@example.invalid"}) + monkeypatch.setattr( + cli, + "_post_json", + lambda *a, **kw: { + "access_token": "new-access", + "refresh_token": "new-refresh", + "id_token": new_id, + }, + ) + updated = cli._refresh_auth(auth, path, timeout=1) + assert auth == original + assert updated == json.loads(path.read_text()) + assert updated["tokens"]["id_token"] == new_id + assert updated["extra"] == auth["extra"] + if os.name != "nt": + assert path.stat().st_mode & 0o777 == 0o600 + + +def test_refreshed_auth_is_readable_by_installed_codex(tmp_path, monkeypatch): + codex = shutil.which("codex") + if not codex: + pytest.skip("Codex binary not installed; JWT schema is covered separately") + auth = fake_auth() + path = tmp_path / "auth.json" + path.write_text(json.dumps(auth)) + monkeypatch.setattr( + cli, + "_post_json", + lambda *a, **kw: { + "access_token": "new-access", + "id_token": jwt({"email": "test@example.invalid"}), + }, + ) + cli._refresh_auth(auth, path, timeout=1) + env = { + k: v + for k, v in os.environ.items() + if not any(fragment in k for fragment in ("TOKEN", "API_KEY", "AUTH", "BEDROCK", "AWS")) + } + env["CODEX_HOME"] = str(tmp_path) + result = subprocess.run( + [codex, "login", "status"], env=env, capture_output=True, text=True, timeout=15 + ) + assert result.returncode == 0, result.stderr + assert "ChatGPT" in result.stderr + result.stdout + + +def test_concurrent_refreshes_reuse_new_token(tmp_path, monkeypatch): + auth = fake_auth() + path = tmp_path / "auth.json" + path.write_text(json.dumps(auth)) + entered = threading.Event() + release = threading.Event() + calls = [] + + def post(*a, **kw): + calls.append(1) + entered.set() + assert release.wait(2) + return {"access_token": "new-access", "refresh_token": "new-refresh"} + + monkeypatch.setattr(cli, "_post_json", post) + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(cli._refresh_auth, auth, path, timeout=2) + try: + assert entered.wait(2) + second = pool.submit(cli._refresh_auth, auth, path, timeout=2) + finally: + release.set() + assert first.result(timeout=3)["tokens"]["access_token"] == "new-access" + assert second.result(timeout=3)["tokens"]["access_token"] == "new-access" + assert len(calls) == 1 + + +@pytest.mark.parametrize("change", ["replace", "delete"]) +def test_refresh_does_not_overwrite_external_auth_change(tmp_path, monkeypatch, change): + auth = fake_auth() + path = tmp_path / "auth.json" + path.write_text(json.dumps(auth)) + other = copy.deepcopy(auth) + other["tokens"]["access_token"] = "external-change" + + def post(*a, **kw): + if change == "replace": + path.write_text(json.dumps(other)) + else: + path.unlink() + return {"access_token": "imagegen-refresh"} + + monkeypatch.setattr(cli, "_post_json", post) + with pytest.raises(cli.CliError, match="auth changed"): + cli._refresh_auth(auth, path, timeout=1) + assert (json.loads(path.read_text()) == other) if change == "replace" else not path.exists() + + +def test_refresh_error_never_echoes_oauth_body(tmp_path, monkeypatch): + def fail(*a, **kw): + raise cli.HttpError(400, "fake-access fake-refresh") + + monkeypatch.setattr(cli, "_post_json", fail) + with pytest.raises(cli.CliError) as error: + cli._refresh_auth(fake_auth(), tmp_path / "auth.json", timeout=1) + assert "fake-access" not in str(error.value) + assert "fake-refresh" not in str(error.value) + + +def test_refresh_without_access_token_leaves_auth_unchanged(tmp_path, monkeypatch): + auth = fake_auth() + path = tmp_path / "auth.json" + path.write_text(json.dumps(auth)) + monkeypatch.setattr(cli, "_post_json", lambda *a, **kw: {"refresh_token": "new"}) + with pytest.raises(cli.CliError, match="no access token"): + cli._refresh_auth(auth, path, timeout=1) + assert json.loads(path.read_text()) == auth + + +def test_config_profile_precedence(tmp_path, monkeypatch): + monkeypatch.delenv("CODEX_IMAGEGEN_MODEL", raising=False) + (tmp_path / "config.toml").write_text( + 'model = "top"\nprofile = "active"\n' + '[profiles.inactive]\nmodel = "wrong"\n[profiles.active]\nmodel = "active-model"\n' + ) + args = args_for(tmp_path) + args.codex_home = str(tmp_path) + args.model = None + assert cli._default_model(args) == "active-model" + args.profile = "inactive" + assert cli._default_model(args) == "wrong" + monkeypatch.setenv("CODEX_IMAGEGEN_MODEL", "environment") + assert cli._default_model(args) == "environment" + args.model = "explicit" + assert cli._default_model(args) == "explicit" + + +@pytest.mark.parametrize("size", ["2048x2048", "1536x864", "3840x2160", "auto"]) +def test_supported_custom_sizes(size): + assert cli._parse_size(size) == size + + +@pytest.mark.parametrize( + "size", ["1x1", "0x0", "1023x1024", "4096x4096", "3840x3840", "3840x512", "nan"] +) +def test_invalid_sizes(size): + with pytest.raises(cli.argparse.ArgumentTypeError): + cli._parse_size(size) + + +@pytest.mark.parametrize("timeout", [0, -1, float("nan"), float("inf")]) +def test_invalid_timeout(timeout, tmp_path): + args = args_for(tmp_path) + args.timeout = timeout + with pytest.raises(cli.CliError, match="positive finite"): + cli._validate_common(args) + + +@pytest.mark.parametrize("backend", ["native", "responses"]) +def test_removed_image_model_flag_fails_before_auth(backend, tmp_path, monkeypatch, capsys): + monkeypatch.setattr(cli, "_load_ready_auth", lambda *a: pytest.fail("unexpected auth read")) + with pytest.raises(SystemExit) as error: + cli.main( + [ + "generate", + "--prompt", + "test", + "--out", + str(tmp_path / "out.png"), + "--backend", + backend, + "--image-model", + "gpt-image-2.5-sunburst", + ] + ) + assert error.value.code == 2 + assert "unrecognized arguments: --image-model" in capsys.readouterr().err + + +def test_native_transparency_and_wrong_model_flags_fail_before_auth(tmp_path, monkeypatch): + monkeypatch.setattr(cli, "_load_ready_auth", lambda *a: pytest.fail("unexpected auth read")) + for extra in [["--background", "transparent"], ["--model", "gpt-5.5"]]: + with pytest.raises(SystemExit): + cli.main(["generate", "--prompt", "test", "--out", str(tmp_path / "out.png"), *extra]) + + +def test_sse_crlf_and_utf8_split_across_chunks(monkeypatch): + text = 'event: response.output_text.delta\r\ndata: {"delta":"café"}\r\n\r\n' + + class Chunks(BytesIO): + def read1(self, n): + return self.read(1) + + monkeypatch.setattr(cli.request, "urlopen", lambda *a, **kw: Chunks(text.encode())) + events = list(cli._post_sse("https://example.invalid", headers={}, payload={}, timeout=1)) + assert events == [("response.output_text.delta", '{"delta":"café"}')] + + +def test_stream_closes_on_completed_image(monkeypatch): + closed = [] + + def events(*a, **kw): + try: + yield ( + None, + json.dumps( + { + "type": "response.output_item.done", + "item": { + "type": "image_generation_call", + "status": "completed", + "result": image_result(), + }, + } + ), + ) + pytest.fail("should return after complete image") + finally: + closed.append(True) + + monkeypatch.setattr(cli, "_post_sse", events) + cli._stream_image_result("https://example.invalid", headers={}, payload={}, timeout=1) + assert closed == [True] + + +def test_json_incomplete_read_is_transport_error(monkeypatch): + class Broken(BytesIO): + def read(self, *a): + raise IncompleteRead(b"partial", 10) + + monkeypatch.setattr(cli.request, "urlopen", lambda *a, **kw: Broken()) + with pytest.raises(cli.TransportError): + cli._post_json("https://example.invalid", payload={}, timeout=1) + + +@pytest.mark.parametrize( + "header,delay", [("3", 3), ("0", 1), ("invalid", 1), ("301", None), ("inf", None)] +) +def test_retry_after_bounded(header, delay): + result = cli._http_retry_decision(cli.HttpError(429, "rate limited", header), 0) + assert (result.delay if result else None) == delay + + +def test_retry_after_http_date(monkeypatch): + monkeypatch.setattr(cli.time, "time", lambda: 1000000) + result = cli._http_retry_decision( + cli.HttpError(429, "rate limited", formatdate(1000020, usegmt=True)), 0 + ) + assert result.delay == 20 + + +def test_server_message_delay_is_bounded(): + body = json.dumps( + {"error": {"code": "rate_limit_exceeded", "message": "Please try again in 9999s."}} + ) + assert cli._http_retry_decision(cli.HttpError(429, body), 0) is None + + +@pytest.mark.parametrize( + "code", ["insufficient_quota", "billing_hard_limit_reached", "moderation_blocked"] +) +def test_quota_errors_are_not_retried(code): + assert ( + cli._http_retry_decision(cli.HttpError(429, json.dumps({"error": {"code": code}})), 0) + is None + ) + + +def test_native_401_then_429_uses_common_retry_loop(tmp_path, monkeypatch): + install_fake_auth(tmp_path, monkeypatch) + responses = [ + cli.HttpError(401, "expired"), + cli.HttpError(429, "rate limited", "2"), + {"data": [{"b64_json": image_result()}]}, + ] + + def post(*a, **kw): + value = responses.pop(0) + if isinstance(value, Exception): + raise value + return value + + monkeypatch.setattr(cli, "_post_json", post) + monkeypatch.setattr(cli, "_refresh_auth", lambda *a, **kw: fake_auth()) + sleeps = [] + monkeypatch.setattr(cli.time, "sleep", sleeps.append) + assert cli.main(["generate", "--prompt", "test", "--out", str(tmp_path / "out.png")]) == 0 + assert not responses + assert sleeps == [2] + + +def test_batch_rejects_output_escape_and_continues(tmp_path, monkeypatch): + jobs = tmp_path / "jobs.jsonl" + jobs.write_text('{"prompt":"bad","out":"../outside.png"}\n{"prompt":"good"}\n') + calls = [] + monkeypatch.setattr(cli, "_run_one", lambda **kw: calls.append(kw["prompt"])) + assert cli.main(["batch", "--input", str(jobs), "--out-dir", str(tmp_path / "out")]) == 1 + assert calls == ["good"] diff --git a/tests/test_regressions.py b/tests/test_regressions.py new file mode 100644 index 0000000..7c33ebf --- /dev/null +++ b/tests/test_regressions.py @@ -0,0 +1,218 @@ +"""Regression coverage from the September 2026 audit; no live credentials/network.""" + +import base64 +import json +from contextlib import contextmanager +from http.client import IncompleteRead +from io import BytesIO + +import pytest +from PIL import Image + +from codex_imagegen_cli import cli + + +def jwt(payload): + encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") + return f"e30.{encoded}.signature" + + +def fake_auth(): + return { + "auth_mode": "chatgpt", + "tokens": { + "id_token": jwt({"email": "test@example.invalid"}), + "access_token": "fake-access", + "refresh_token": "fake-refresh", + "account_id": "fake-account", + }, + } + + +def image_result(): + out = BytesIO() + Image.new("RGB", (2, 2), "blue").save(out, "PNG") + return base64.b64encode(out.getvalue()).decode() + + +def args_for(tmp_path): + return cli.build_parser().parse_args( + [ + "generate", + "--backend", + "responses", + "--prompt", + "test", + "--out", + str(tmp_path / "out.png"), + "--model", + "test-model", + ] + ) + + +def test_refresh_preserves_id_token_as_jwt_string(tmp_path, monkeypatch): + new_id = jwt({"email": "test@example.invalid"}) + monkeypatch.setattr( + cli, + "_post_json", + lambda *a, **kw: { + "id_token": new_id, + "access_token": "new-access", + "refresh_token": "new-refresh", + }, + ) + path = tmp_path / "auth.json" + cli._refresh_auth(fake_auth(), path, timeout=1) + assert json.loads(path.read_text())["tokens"]["id_token"] == new_id + + +def test_stream_accepts_crlf_event_separators(monkeypatch): + events = [ + {"type": "response.created"}, + { + "type": "response.output_item.done", + "item": { + "type": "image_generation_call", + "status": "completed", + "result": image_result(), + }, + }, + ] + body = "".join("data: " + json.dumps(e) + "\r\n\r\n" for e in events).encode() + monkeypatch.setattr(cli.request, "urlopen", lambda *a, **kw: BytesIO(body)) + assert cli._stream_image_result("https://example.invalid", headers={}, payload={}, timeout=1) + + +def test_stream_accepts_result_in_completed_response(monkeypatch): + encoded = image_result() + event = { + "type": "response.completed", + "response": { + "status": "completed", + "output": [ + { + "type": "image_generation_call", + "status": "completed", + "result": encoded, + } + ], + }, + } + monkeypatch.setattr(cli, "_post_sse", lambda *a, **kw: iter([(None, json.dumps(event))])) + assert ( + cli._stream_image_result("https://example.invalid", headers={}, payload={}, timeout=1) + == encoded + ) + + +def test_truncated_http_response_has_cli_transport_error(monkeypatch): + @contextmanager + def broken(*a, **kw): + raise IncompleteRead(b"partial", 100) + yield + + monkeypatch.setattr(cli.request, "urlopen", broken) + with pytest.raises(cli.TransportError): + list(cli._post_sse("https://example.invalid", headers={}, payload={}, timeout=1)) + + +@pytest.mark.parametrize("first_unauthorized", [False, True]) +def test_rate_limit_retry_also_works_after_http_errors(tmp_path, monkeypatch, first_unauthorized): + sequence = ([cli.HttpError(401, "expired")] if first_unauthorized else []) + [ + cli.ResponsesImageGenerationError( + { + "response": { + "error": { + "code": "rate_limit_exceeded", + "message": "Please try again in 1ms.", + } + } + } + ) + if first_unauthorized + else cli.HttpError(429, "rate limited"), + image_result(), + ] + + def stream(*a, **kw): + result = sequence.pop(0) + if isinstance(result, Exception): + raise result + return result + + monkeypatch.setattr(cli, "_stream_image_result", stream) + monkeypatch.setattr(cli, "_load_ready_auth", lambda a: (fake_auth(), tmp_path / "auth.json")) + monkeypatch.setattr(cli, "_refresh_auth", lambda *a, **kw: fake_auth()) + monkeypatch.setattr(cli, "_auth_headers", lambda a: {}) + monkeypatch.setattr(cli.time, "sleep", lambda s: None) + args = args_for(tmp_path) + assert cli._call_responses_backend( + args=args, mode="generate", prompt="test", output_path=tmp_path / "out.png" + ) + assert not sequence + + +def test_batch_null_images_behaves_as_empty_list(tmp_path, monkeypatch): + jobs = tmp_path / "jobs.jsonl" + jobs.write_text('{"prompt":"test", "images":null}\n') + monkeypatch.setattr(cli, "_run_one", lambda **kw: True) + assert cli.main(["batch", "--input", str(jobs), "--out-dir", str(tmp_path)]) == 0 + + +def test_batch_rejects_nontext_prompt(tmp_path): + jobs = tmp_path / "jobs.jsonl" + jobs.write_text('{"prompt":null}\n') + with pytest.raises(cli.CliError): + cli._load_jobs_jsonl(str(jobs), tmp_path) + + +def test_batch_invalid_job_does_not_abort_remaining_jobs(tmp_path, monkeypatch): + jobs = tmp_path / "jobs.jsonl" + jobs.write_text('{"prompt":"bad", "mode":"invalid"}\n{"prompt":"good"}\n') + visited = [] + monkeypatch.setattr(cli, "_run_one", lambda **kw: visited.append(kw["prompt"])) + try: + cli.main(["batch", "--input", str(jobs), "--out-dir", str(tmp_path)]) + except SystemExit: + pass + assert visited == ["good"] + + +def test_config_does_not_use_model_from_inactive_profile(tmp_path, monkeypatch): + (tmp_path / "config.toml").write_text('[profiles.inactive]\nmodel = "unavailable-model"\n') + args = args_for(tmp_path) + args.model = None + args.codex_home = str(tmp_path) + monkeypatch.delenv("CODEX_IMAGEGEN_MODEL", raising=False) + assert cli._default_model(args) == cli.DEFAULT_CODEX_MODEL + + +def test_compaction_applies_exif_orientation(tmp_path): + source = tmp_path / "portrait.jpg" + image = Image.new("RGB", (40, 20), "blue") + exif = Image.Exif() + exif[274] = 6 + image.save(source, exif=exif) + encoded, _ = cli._encoded_input_image(source, args_for(tmp_path)) + with Image.open(BytesIO(encoded)) as output: + assert output.size == (20, 40) + + +def test_corrupt_base64_does_not_create_successful_output(tmp_path): + out = tmp_path / "out.png" + with pytest.raises(cli.CliError): + cli._write_response_image("!!!!", out, force=False, output_format="png", webp_quality=85) + assert not out.exists() + + +def test_timeout_must_be_positive(tmp_path): + args = args_for(tmp_path) + args.timeout = -1 + with pytest.raises(cli.CliError): + cli._validate_common(args) + + +def test_prompt_directory_is_a_user_facing_error(tmp_path): + with pytest.raises(cli.CliError): + cli._read_prompt(None, str(tmp_path), tmp_path) diff --git a/uv.lock b/uv.lock index 4d6e8c7..0b6ed79 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "build" -version = "1.5.0" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "os_name == 'nt'" }, @@ -22,184 +22,245 @@ dependencies = [ { name = "pyproject-hooks" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/67/4898a44ea4f3f8e213b0954ec0aa0a16971d62a6212d6ea3931e97115b99/build-1.6.1.tar.gz", hash = "sha256:51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53", size = 113427, upload-time = "2026-09-10T07:56:00.417Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl", hash = "sha256:ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7", size = 31179, upload-time = "2026-09-10T07:55:59.148Z" }, ] [[package]] name = "certifi" -version = "2026.5.20" +version = "2026.7.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] name = "cffi" -version = "2.0.0" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, - { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, - { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, - { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, - { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, - { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, - { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, - { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, - { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, - { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" }, + { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" }, + { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, ] [[package]] name = "codex-imagegen-cli" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "pillow" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] [package.dev-dependencies] @@ -211,14 +272,17 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "pillow", specifier = ">=11.0" }] +requires-dist = [ + { name = "pillow", specifier = ">=12.3.0" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.4.1" }, +] [package.metadata.requires-dev] dev = [ - { name = "build", specifier = ">=1.2.2.post1" }, - { name = "pytest", specifier = ">=8.3" }, - { name = "ruff", specifier = ">=0.8" }, - { name = "twine", specifier = ">=6.0" }, + { name = "build", specifier = ">=1.6.1" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.16.7" }, + { name = "twine", specifier = ">=7.0.0" }, ] [[package]] @@ -232,51 +296,45 @@ wheels = [ [[package]] name = "cryptography" -version = "48.0.0" +version = "50.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, - { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, - { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, - { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, - { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, - { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, - { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, - { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, - { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, - { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, - { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, - { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, - { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, - { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, - { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, - { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, - { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, - { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, - { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, - { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, ] [[package]] @@ -293,7 +351,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -314,23 +372,23 @@ wheels = [ [[package]] name = "idna" -version = "3.17" +version = "3.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] name = "importlib-metadata" -version = "9.0.0" +version = "9.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/7e/1e7e8dc30634b93ebb3d58a3dea569ad146e656218d3960ab04f62047b29/importlib_metadata-9.0.1.tar.gz", hash = "sha256:ab830580bc0ef3db61ce8fae716389e5462b67e033018bab6d8f80ef17172f99", size = 59124, upload-time = "2026-08-28T15:30:34.646Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, + { url = "https://files.pythonhosted.org/packages/b3/55/ecca97ae19075f1fac62def77731e7f535e6c1fb8f92ff08160c5e6dade8/importlib_metadata-9.0.1-py3-none-any.whl", hash = "sha256:bba5600596a7e21f3eef53281cf28d6a5195634d2f2b78ff9501a3272c6eaab0", size = 27920, upload-time = "2026-08-28T15:30:33.433Z" }, ] [[package]] @@ -368,14 +426,14 @@ wheels = [ [[package]] name = "jaraco-functools" -version = "4.5.0" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/cf/ea4ef2920830dea3f5ab2ea4da6fb67724e6dca80ee2553788c3607243d0/jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03", size = 20272, upload-time = "2026-05-15T21:34:10.025Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4", size = 10594, upload-time = "2026-05-15T21:34:08.595Z" }, + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, ] [[package]] @@ -437,143 +495,139 @@ wheels = [ [[package]] name = "nh3" -version = "0.3.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/5f/1d19bdc7d27238e37f3672cdc02cb77c56a4a86d140cd4f4f23c90df6e16/nh3-0.3.5.tar.gz", hash = "sha256:45855e14ff056064fec77133bfcf7cd691838168e5e17bbef075394954dc9dc8", size = 20743, upload-time = "2026-04-25T10:44:16.066Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/63/b0/8587ac42a9627ab88e7e221601f1dfccbf4db80b2a29222ea63266dc9abc/nh3-0.3.5-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:23a312224875f72cd16bde417f49071451877e29ef646a60e50fcb69407cc18a", size = 1420126, upload-time = "2026-04-25T10:43:39.834Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1b/1dbc4d0c43f12e8c1784ede17eaee6f061d4fbe5505757c65c49b2ceab95/nh3-0.3.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:387abd011e81959d5a35151a11350a0795c6edeb53ebfa02d2e882dc01299263", size = 793943, upload-time = "2026-04-25T10:43:41.363Z" }, - { url = "https://files.pythonhosted.org/packages/47/9f/d6758d7a14ee964bf439cc35ae4fa24a763a93399c8ef6f22bd11d532d29/nh3-0.3.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:48f45e3e914be93a596431aa143dedf1582557bf41a58153c296048d6e3798c9", size = 841150, upload-time = "2026-04-25T10:43:43.007Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/d5d1ae8374612c98f390e1ea7c610fa6c9716259a03bbf4d15b269f40073/nh3-0.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0a09f51806fd51b4fedbf9ea2b61fef388f19aef0d62fe51199d41648be14588", size = 1008415, upload-time = "2026-04-25T10:43:44.324Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8f/d13a9c3fd2d9c131a2a281737380e9379eb0f8c33fea24c2b923aaafbb15/nh3-0.3.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c357f1d042c67f135a5e6babb2b0e3b9d9224ff4a3543240f597767b01384ffd", size = 1092706, upload-time = "2026-04-25T10:43:45.653Z" }, - { url = "https://files.pythonhosted.org/packages/bb/57/2f3add7f8680fcc896afa6a675cb2bab09982853ee8af40bad621f6b61c4/nh3-0.3.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:38748140bf76383ab7ce2dce0ad4cb663855d8fbc9098f7f3483673d09616a17", size = 1048346, upload-time = "2026-04-25T10:43:46.974Z" }, - { url = "https://files.pythonhosted.org/packages/c1/c3/2f9e4ffa82863074d1361bfe949bc46393d91b3411579dfbbd090b24cac5/nh3-0.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:84bdeb082544fbcb77a12c034dd77d7da0556fdc0727b787eb6214b958c15e29", size = 1029038, upload-time = "2026-04-25T10:43:48.569Z" }, - { url = "https://files.pythonhosted.org/packages/e8/10/2804deb3f3315184c9cae41702e293c87524b5a21f766b07d7fe3ffbcfbb/nh3-0.3.5-cp314-cp314t-win32.whl", hash = "sha256:c3aae321f67ae66cff2a627115f106a377d4475d10b0e13d97959a13486b9a88", size = 603263, upload-time = "2026-04-25T10:43:49.851Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a2/f6685248b49f7548fc9a8c335ab3a52f68610b72e8a61576447151e4e2e6/nh3-0.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c88605d8d468f7fc1b31e06129bc91d6c96f6c621776c9b504a0da9beac9df5f", size = 616866, upload-time = "2026-04-25T10:43:51.005Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/d8c9018635d4acfefde6b68470daa510eed715a350cbaa2f928ba0609f81/nh3-0.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:72c5bdedec27fa33de6a5326346ea8aa3fe54f6ac294d54c4b204fb66a9f1e79", size = 602566, upload-time = "2026-04-25T10:43:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/85/30/d162e99746a2fb1d98bb0ef23af3e201b156cf09f7de867c7390c8fe1c06/nh3-0.3.5-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3bb854485c9b33e5bb143ff3e49e577073bc6bc320f0ff8fc316dd89c0d3c101", size = 1442393, upload-time = "2026-04-25T10:43:53.556Z" }, - { url = "https://files.pythonhosted.org/packages/25/8c/072120d506978ab053e1732d0efa7c86cb478fee0ee098fda0ac0d31cb34/nh3-0.3.5-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50d401ab2d8e86d59e2126e3ab2a2f45840c405842b626d9a51624b3a33b6878", size = 837722, upload-time = "2026-04-25T10:43:55.073Z" }, - { url = "https://files.pythonhosted.org/packages/52/86/d4e06e28c5ad1c4b065f89737d02631bd49f1660b6ebcf17a87ffcd201da/nh3-0.3.5-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:acfd354e61accbe4c74f8017c6e397a776916dfe47c48643cf7fd84ade826f93", size = 822872, upload-time = "2026-04-25T10:43:56.581Z" }, - { url = "https://files.pythonhosted.org/packages/0a/62/50659255213f241ec5797ae7427464c969397373e83b3659372b341ae869/nh3-0.3.5-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:52d877980d7ca01dc3baf3936bf844828bc6f332962227a684ed79c18cce14c3", size = 1100031, upload-time = "2026-04-25T10:43:58.098Z" }, - { url = "https://files.pythonhosted.org/packages/00/7a/a12ae77593b2fcf3be25df7bc1c01967d0de448bdb4b6c7ec80fe4f5a74f/nh3-0.3.5-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:207c01801d3e9bb8ec08f08689346bdd30ce15b8bf60013a925d08b5388962a4", size = 1057669, upload-time = "2026-04-25T10:43:59.328Z" }, - { url = "https://files.pythonhosted.org/packages/2d/71/5647dc04c0233192a3956fc91708822b21403a06508cacf78083c68e7bf0/nh3-0.3.5-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea232933394d1d58bf7c4bb348dc4660eae6604e1ae81cd2ba6d9ed80d390f3b", size = 914795, upload-time = "2026-04-25T10:44:00.52Z" }, - { url = "https://files.pythonhosted.org/packages/1b/0e/bf298920729f216adcb002acf7ea01b90842603d2e4e2ce9b900d9ee8fab/nh3-0.3.5-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe3a787dc76b50de6bee54ef242f26c41dfe47654428e3e94f0fae5bb6dd2cc1", size = 806976, upload-time = "2026-04-25T10:44:01.743Z" }, - { url = "https://files.pythonhosted.org/packages/85/01/26761e1dc2b848e65a62c19e5d39ad446283287cd4afddc89f364ab86bc9/nh3-0.3.5-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:488928988caad25ba14b1eb5bc74e25e21f3b5e40341d956f3ce4a8bc19460dc", size = 834904, upload-time = "2026-04-25T10:44:03.454Z" }, - { url = "https://files.pythonhosted.org/packages/33/53/0766113e679540ac1edc1b82b1295aecd321eeb75d6fead70109a838b6ee/nh3-0.3.5-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c069570b06aa848457713ad7af4a9905691291548c4466a9ad78ee95808382b", size = 857159, upload-time = "2026-04-25T10:44:05.003Z" }, - { url = "https://files.pythonhosted.org/packages/58/36/734d353dfaf292fed574b8b3092f0ef79dc6404f3879f7faaa61a4701fad/nh3-0.3.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:eeedc90ed8c42c327e8e10e621ccfa314fc6cce35d5929f4297ff1cdb89667c4", size = 1018600, upload-time = "2026-04-25T10:44:06.18Z" }, - { url = "https://files.pythonhosted.org/packages/6b/aa/d9c59c1b49669fcb7bababa55df82385f029ad5c2651f583c3a1141cfdd1/nh3-0.3.5-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:de8e8621853b6470fe928c684ee0d3f39ea8086cebafe4c416486488dea7b68d", size = 1103530, upload-time = "2026-04-25T10:44:07.68Z" }, - { url = "https://files.pythonhosted.org/packages/90/b0/cdd210bfb8d9d43fb02fc3c868336b9955934d8e15e66eb1d15a147b8af0/nh3-0.3.5-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:6ea58cc44d274c643b83547ca9654a0b1a817609b160601356f76a2b744c49ad", size = 1061754, upload-time = "2026-04-25T10:44:09.362Z" }, - { url = "https://files.pythonhosted.org/packages/ce/cb/7a39e72e668c8445bdd95e494b3e21cfdddc68329be8ea3522c8befb46c4/nh3-0.3.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e49c9b564e6bcb03ecd2f057213df9a0de15a95812ac9db9600b590db23d3ae9", size = 1040938, upload-time = "2026-04-25T10:44:10.775Z" }, - { url = "https://files.pythonhosted.org/packages/af/4c/fc2f9ed208a3801a319f59b5fea03cdc20cf3bd8af14be930d3a8de01224/nh3-0.3.5-cp38-abi3-win32.whl", hash = "sha256:559e4c73b689e9a7aa97ac9760b1bc488038d7c1a575aa4ab5a0e19ee9630c0f", size = 611445, upload-time = "2026-04-25T10:44:12.317Z" }, - { url = "https://files.pythonhosted.org/packages/db/1a/e4c9b5e2ae13e6092c9ec16d8ca30646cb01fcdea245f36c5b08fd21fbd5/nh3-0.3.5-cp38-abi3-win_amd64.whl", hash = "sha256:45e6a65dc88a300a2e3502cb9c8e6d1d6b831d6fba7470643333609c6aab1f30", size = 626502, upload-time = "2026-04-25T10:44:13.682Z" }, - { url = "https://files.pythonhosted.org/packages/80/7c/19cd0671d1ba2762fb388fc149697d20d0568ccfeef833b11280a619e526/nh3-0.3.5-cp38-abi3-win_arm64.whl", hash = "sha256:8f85285700a18e9f3fc5bff41fe573fa84f81542ef13b48a89f9fecca0474d3b", size = 611069, upload-time = "2026-04-25T10:44:14.934Z" }, +version = "0.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/2f/022b27146d52d24b1b353b003359134788ecbcd6fcdf6283adbd57c0fbc8/nh3-0.3.7.tar.gz", hash = "sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848", size = 25662, upload-time = "2026-08-23T14:26:30.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/88/b594f0e86856b37e182fb663283da419eea6424972506e640e890885467f/nh3-0.3.7-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba", size = 1471147, upload-time = "2026-08-23T14:25:55.259Z" }, + { url = "https://files.pythonhosted.org/packages/1e/60/847a21339f095c4d4c655af31fa2d18b174585bcc210709facacc7ce205c/nh3-0.3.7-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b", size = 820463, upload-time = "2026-08-23T14:25:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7f/1a103e00aaf5e59f2dee4c2709aac609bb2d4bb74fddaf0dcfade11ed87b/nh3-0.3.7-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32", size = 861456, upload-time = "2026-08-23T14:25:58.087Z" }, + { url = "https://files.pythonhosted.org/packages/d8/4a/e9c436089a0c80b928011ead0efd156aa7639a19b6064ef58dcedcab8369/nh3-0.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa", size = 1023930, upload-time = "2026-08-23T14:25:59.465Z" }, + { url = "https://files.pythonhosted.org/packages/04/5c/aa1468e3e281e78d2b3b7d762ccba59f681af355e971dbd255d5903f7b86/nh3-0.3.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac", size = 1102614, upload-time = "2026-08-23T14:26:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/57d186d9d3dd38905dc12dddb3484406cdf6aa0b1ce33639a2d277d4ee1c/nh3-0.3.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102", size = 1059915, upload-time = "2026-08-23T14:26:02.388Z" }, + { url = "https://files.pythonhosted.org/packages/6b/53/097a5ad0b34b15d67a472ef849165a54209fa5fbd3e639801c6fe439ba28/nh3-0.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a", size = 1047402, upload-time = "2026-08-23T14:26:03.897Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/c57a2c70534418310889a65ccfac3525e62f0bc0a8613225903403755ce7/nh3-0.3.7-cp314-cp314t-win32.whl", hash = "sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946", size = 619895, upload-time = "2026-08-23T14:26:05.105Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b7/efda1d0a611d940bdfde6893bde1ea6b7b7d48c31273aea48e35b822fd58/nh3-0.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d", size = 633456, upload-time = "2026-08-23T14:26:06.661Z" }, + { url = "https://files.pythonhosted.org/packages/1d/18/3ab564595cb88196f50d26e163ed0fd2acc731ab26ac615df91981885887/nh3-0.3.7-cp314-cp314t-win_arm64.whl", hash = "sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877", size = 611003, upload-time = "2026-08-23T14:26:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/94/0d/c257754bf57f829f307aa226bbe136d3a1356b5a0d08324c7b6bd2a8aacd/nh3-0.3.7-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5", size = 1493959, upload-time = "2026-08-23T14:26:09.025Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/a687e7091928806e514f89fa2666f25ec9bfe0a902fc4402b25e51ce408b/nh3-0.3.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479", size = 859615, upload-time = "2026-08-23T14:26:10.606Z" }, + { url = "https://files.pythonhosted.org/packages/85/05/b0e6bef633549a23347d5462aa288fcc42381e7918482062ca3cb456242a/nh3-0.3.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506", size = 839872, upload-time = "2026-08-23T14:26:12.037Z" }, + { url = "https://files.pythonhosted.org/packages/17/40/2a0921d45b20828708bcb56887e47dcf8cae13818de5bf9a01308d348712/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086", size = 1091325, upload-time = "2026-08-23T14:26:13.34Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d1/9d70e0e418a48280ec0ddc6c1b08b4b1136ebcc31a1625e57ff5c665fa51/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563", size = 1042482, upload-time = "2026-08-23T14:26:14.667Z" }, + { url = "https://files.pythonhosted.org/packages/93/a7/02dd159d4e71f98607d8d4249cddb7561e77be1a8e4dec77d76e1b68fc99/nh3-0.3.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174", size = 946868, upload-time = "2026-08-23T14:26:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ed/c5510c615dce55b6fcc364aa1838142f938beed64f5e4927490dfcaf4405/nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42", size = 832161, upload-time = "2026-08-23T14:26:17.272Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e3/3212c1a5b5745245d7f18885207bbddb34c56075f34dd682bd539aad55cc/nh3-0.3.7-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8", size = 849791, upload-time = "2026-08-23T14:26:18.498Z" }, + { url = "https://files.pythonhosted.org/packages/20/64/9e36594efad6c290de4240d02cb2bd80c339a4ab1c4de66e599ffa6d9d81/nh3-0.3.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493", size = 875473, upload-time = "2026-08-23T14:26:19.908Z" }, + { url = "https://files.pythonhosted.org/packages/00/0c/1a8985fd43fea5530c0ac890b6f0b423770ee72f111b70b7a77f2dec243a/nh3-0.3.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd", size = 1036463, upload-time = "2026-08-23T14:26:21.536Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5d/891e533b716cf00df76ad0ba6485dcfd14d59a6430a3cc99057c4c04004e/nh3-0.3.7-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac", size = 1116029, upload-time = "2026-08-23T14:26:22.907Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/ae8c0782fce74fb6fcf7234bb3d4017f37ce181b4f9d29369eab21c50a04/nh3-0.3.7-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62", size = 1076589, upload-time = "2026-08-23T14:26:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/26/a4/c3423351e8d864ad756e85e15f0c01433361f14d34e4ed156482c0518f2a/nh3-0.3.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af", size = 1058871, upload-time = "2026-08-23T14:26:25.674Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/478f153f1d7c0baaa3d1e8bb5fdcee3a6235f90fe44ea969a9d4e2b8c47a/nh3-0.3.7-cp38-abi3-win32.whl", hash = "sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59", size = 630729, upload-time = "2026-08-23T14:26:26.932Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b9/34433ccb1f0fe6968dabbb7d4bf5721c6221878ef07832748c06655a6a80/nh3-0.3.7-cp38-abi3-win_amd64.whl", hash = "sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc", size = 644462, upload-time = "2026-08-23T14:26:28.294Z" }, + { url = "https://files.pythonhosted.org/packages/f9/70/e140dffff6e808dc6343598df76e7e2407fd0f581de3524c75fba2e0cf24/nh3-0.3.7-cp38-abi3-win_arm64.whl", hash = "sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a", size = 621867, upload-time = "2026-08-23T14:26:29.547Z" }, ] [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] name = "pillow" -version = "12.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, - { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, - { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, - { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, - { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, - { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, - { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, - { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, - { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, - { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, - { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, - { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, - { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, - { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, - { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, - { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, - { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, - { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] [[package]] @@ -596,11 +650,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.20.0" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] [[package]] @@ -614,7 +668,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -625,9 +679,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -641,16 +695,16 @@ wheels = [ [[package]] name = "readme-renderer" -version = "44.0" +version = "46.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, { name = "nh3" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056, upload-time = "2024-07-08T15:00:57.805Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/d7/9309494fad74ee831d4546f69325b5519f37c6dfb2d9ba495db8c6d4f4ca/readme_renderer-46.0.tar.gz", hash = "sha256:af3e964914f6310a33ff67b72a4bdd940bed8d7c3bdecd2d14f40edf284bfe90", size = 38382, upload-time = "2026-08-28T15:18:32.49Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310, upload-time = "2024-07-08T15:00:56.577Z" }, + { url = "https://files.pythonhosted.org/packages/4b/72/ac5ca81fe9121fcaa9d828d21017cba00a16a98e4ea5fb60c878f93dda4f/readme_renderer-46.0-py3-none-any.whl", hash = "sha256:d0dae1f74bb273b534770cb4cccb6bb78735540afdb03c2146f4e19dcd412560", size = 14239, upload-time = "2026-08-28T15:18:31.132Z" }, ] [[package]] @@ -704,27 +758,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, - { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, - { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, - { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, - { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, - { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, - { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, - { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, - { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, - { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, +version = "0.16.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, ] [[package]] @@ -796,7 +850,7 @@ wheels = [ [[package]] name = "twine" -version = "6.2.0" +version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "id" }, @@ -809,18 +863,18 @@ dependencies = [ { name = "rich" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz", hash = "sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177", size = 215032, upload-time = "2026-07-27T15:59:00.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, + { url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl", hash = "sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7", size = 43204, upload-time = "2026-07-27T15:58:59.26Z" }, ] [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] From 7337f24ed02e6484116e1d419384e7ce56eb30f2 Mon Sep 17 00:00:00 2001 From: jdmnk <5247767+jdmnk@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:04:23 +0000 Subject: [PATCH 2/3] Remove historical audit reports and test logs --- audit/MAINTENANCE.md | 72 ------ audit/REPORT.md | 172 --------------- audit/evidence/live-images.json | 42 ---- .../evidence/maintenance-dependency-scan.json | 4 - audit/evidence/maintenance-live-images.json | 29 --- audit/evidence/maintenance-live-tests.txt | 12 - audit/evidence/maintenance-offline-tests.txt | 3 - audit/evidence/native-request-summary.json | 40 ---- audit/evidence/outdated-packages.json | 1 - audit/evidence/pytest.txt | 132 ----------- audit/evidence/pytest.xml | 85 ------- audit/evidence/request-event-summary.json | 14 -- audit/evidence/upgraded-scan-summary.json | 4 - audit/evidence/vulnerability-summary.json | 208 ------------------ 14 files changed, 818 deletions(-) delete mode 100644 audit/MAINTENANCE.md delete mode 100644 audit/REPORT.md delete mode 100644 audit/evidence/live-images.json delete mode 100644 audit/evidence/maintenance-dependency-scan.json delete mode 100644 audit/evidence/maintenance-live-images.json delete mode 100644 audit/evidence/maintenance-live-tests.txt delete mode 100644 audit/evidence/maintenance-offline-tests.txt delete mode 100644 audit/evidence/native-request-summary.json delete mode 100644 audit/evidence/outdated-packages.json delete mode 100644 audit/evidence/pytest.txt delete mode 100644 audit/evidence/pytest.xml delete mode 100644 audit/evidence/request-event-summary.json delete mode 100644 audit/evidence/upgraded-scan-summary.json delete mode 100644 audit/evidence/vulnerability-summary.json diff --git a/audit/MAINTENANCE.md b/audit/MAINTENANCE.md deleted file mode 100644 index 6cd0fbf..0000000 --- a/audit/MAINTENANCE.md +++ /dev/null @@ -1,72 +0,0 @@ -# Maintenance completed — 0.2.0 - -Implemented the recommendations from the [September 15 audit](REPORT.md) in the local checkout. The historical audit describes `a739870`; its defects and failing-test results are not the status of this updated tree. - -## Changes - -- **Authentication:** retain raw ID-token JWT strings; validate refresh responses; use atomic owner-only credential writes; serialize refreshes between imagegen processes; reuse credentials refreshed by another process; refuse observed external auth changes, including logout. Refresh failures do not echo OAuth response bodies. -- **Dependencies/releases:** update all locked dependencies, require Pillow 12.3.0 or newer, update Build/Twine/Ruff/Pytest, constrain Hatchling to the tested 1.32 minor series, and update GitHub checkout/setup-python actions. Modernize imports/type annotations and make Ruff rules explicit. -- **Backend:** default to the current native `images/generations` and `images/edits` paths. Keep `gpt-image-2` as a fixed request-format compatibility field, without claiming it identifies the actual output model. Keep the legacy Responses path available explicitly, with reasoning-model selection only. The backend controls the image model. -- **Image options/output:** accept valid custom dimensions; report decoded dimensions; warn on size mismatch or reject with `--size-policy error`; reject known-unsupported transparency locally. Strictly decode/validate PNG results before atomic PNG/WebP publication, preserve existing files on failures and print successful paths immediately. -- **Input/configuration/batches:** apply EXIF orientation before resizing; reject corrupt images; parse TOML profiles correctly; normalize nullable image lists and require string prompts; handle per-job validation failures consistently; keep batch outputs under their output directory. -- **Transport/errors:** parse CRLF and split UTF-8 streams; accept terminal response results; close streams promptly; wrap incomplete reads; unify 401/429 and streamed rate-limit handling; bound Retry-After/backoff; reject non-positive/non-finite timeouts and report expected filesystem errors cleanly. -- **Tests/docs:** promote all audit probes into the normal suite, isolate offline tests from real credentials/configuration, add concurrency and native-backend tests, add opt-in live checks, update the README/migration instructions/changelog, and bump the package to 0.2.0. - -## Validation - -| Check | Result | -|---|---| -| Python 3.10.21 | 98 passed, 2 live tests skipped | -| Python 3.12.3 | 98 passed, 2 live tests skipped | -| Python 3.14.7 | 98 passed, 2 live tests skipped | -| Installed Codex 0.154.0 reads refreshed synthetic auth | Pass | -| Concurrent imagegen refresh / external auth change / failed write checks | Pass | -| Opt-in live compatibility | 2 tests passed, making 3 image requests | -| Native generation / edit to WebP | Pass | -| Legacy Responses generation | Pass | -| Ruff lint and formatting | Pass | -| Lockfile consistency | Pass | -| Clean wheel and source archive builds / Twine metadata validation | Pass | -| Isolated Python 3.10 wheel install and both command entry points | Pass | -| Locked dependency audit | No known vulnerabilities reported | - -The live tests took approximately 52 seconds. All three generated files were 1254×1254 despite requests for 1024×1024; the new mismatch warning behaved correctly. See [live test log](evidence/maintenance-live-tests.txt), [image metadata](evidence/maintenance-live-images.json), [dependency scan summary](evidence/maintenance-dependency-scan.json), and generated samples in [output/maintenance](../output/maintenance). - -The GitHub workflow configuration was updated; hosted CI has not run because these changes have not been pushed. Local checks exercise the configured Python versions and build/lint/test commands. - -## Using the update - -```bash -uv sync --frozen --dev -uv run codex-imagegen generate --prompt "A blue circle on white" --out output/circle.png -``` - -Use `--size-policy error` when a size mismatch must fail without writing output. Existing commands specifying a reasoning `--model` must add `--backend responses`. The backend controls the image model; the CLI does not expose an image-model selector. - -```bash -uv run codex-imagegen generate --backend responses --model gpt-5.5 \ - --prompt "A blue circle on white" --out output/legacy-circle.png -``` - -To refresh a local user-command installation: - -```bash -uv tool install --reinstall -e . -``` - -## Remaining platform limitations - -These are documented constraints, rather than outstanding audit fixes: - -- Backend access and exact image options remain controlled by an undocumented service and account entitlements. The tool does not silently resize or switch backends. -- Keyring/ephemeral/API-key/enterprise-token authentication is not implemented; file-based ChatGPT auth is required. -- The imagegen refresh lock cannot fully coordinate with Codex or other external writers that do not share it. Observable changes are checked before publication. -- The live check disables real credential refresh. Compatibility of the stored format is tested against actual Codex using fake tokens; real OAuth refresh was not exercised. -- Windows/macOS-specific behavior was not run on this Linux VPS. CI presently covers Linux with three Python versions. -- No hosted PRs were merged, no release was published, and no server/browser/watcher was started. - -## Follow-up: remove unverified image-model selection - -Subscription-backend probes with `gpt-image-2.5-sunburst`, `gpt-image-2.5-flare`, and a deliberately nonexistent model name all returned images without reporting model identity. These results cannot establish which model ran or whether the requested name was honored. Removed the experimental `--image-model` flag and `CODEX_IMAGEGEN_IMAGE_MODEL` override; retained the default native request format. Clarified reasoning-model help and transparency errors, and added regressions for rejected flags and an ineffective legacy environment override. No public API backend was added. - -Follow-up validation: 98 offline tests passed on Python 3.10, 3.12, and 3.14, with 2 live cases skipped on each. Live generation was not repeated for this removal because the default request payload is unchanged. diff --git a/audit/REPORT.md b/audit/REPORT.md deleted file mode 100644 index fe96b85..0000000 --- a/audit/REPORT.md +++ /dev/null @@ -1,172 +0,0 @@ -# Codex Imagegen CLI audit — 2026-09-15 - -> Historical findings at `a739870`. Maintenance in 0.2.0 addresses these findings; see [maintenance validation](MAINTENANCE.md). - -## Verdict - -**Basic generation and editing still work, but the project needs updates before it is dependable for unattended use.** The most serious confirmed defect is token refresh writing a login-file format that current Codex cannot read. Other priorities are vulnerable locked dependencies, broken release metadata validation, and image options that do not match actual backend behavior. - -Repository: [jdmnk/codex-imagegen-cli](https://github.com/jdmnk/codex-imagegen-cli). No local checkout was present; this audit cloned it to `/home/jure/codex-imagegen-cli`. - -Audited `main` at `a739870aa9d600cfd0c382b6c06f38d0b1f5108b`, last source commit June 9, 2026, package version `0.1.0`. Installed Codex is `0.154.0`, also the [latest published stable release](https://github.com/openai/codex/releases/tag/rust-v0.154.0) when checked. Implementation files and the original lockfile were left unchanged. This directory contains audit tests and evidence only. - -## Test results - -| Check | Result | -|---|---| -| `uv lock --check` and frozen installation | Pass | -| Original tests on Python 3.10.21 | 23 passed | -| Original tests on Python 3.12.3 | 23 passed | -| Original tests on Python 3.14.7 | 23 passed | -| Locked Ruff 0.15.15 | Pass on all three runtimes | -| Added positive controls | 6 passed: SSE transport, numbered outputs, overwrite protection, batch continuation/fail-fast, auth-free dry run | -| Added regression probes | 14 failed, reproducing the behaviors described below | -| Source/wheel builds | Pass on complete Python runtimes | -| Locked Twine checks on freshly built artifacts | **Fail: metadata version 2.5 is rejected** | -| Wheel installed in a separate environment | Pass; both command entry points and dry run work | -| Dependency scan, original lock | Known advisories in Pillow and development-only Cryptography | -| Refreshed dependencies in separate copy | 23 tests pass, artifact checks pass, no known dependency advisories; new Ruff reports 38 lint findings | - -The combined run contains **29 passes and 14 failures**. The failures are audit probes, not failures in the original 23-test suite. See [full output](evidence/pytest.txt), [JUnit results](evidence/pytest.xml), [regression probes](../tests/test_regressions.py), and [positive controls](../tests/test_controls.py). Audit probes are outside the repository's default `testpaths`, so a plain `uv run pytest` continues to run its original suite. - -### Live backend tests - -Tests used existing ChatGPT access credentials with token refresh disabled. Temporary credential files were deleted after use. No real login refresh was performed. - -| Request | Result | -|---|---| -| Existing CLI, configured `gpt-6-astra`, blue circle, requested 1024×1024 | Succeeded in 18.9s; PNG **1254×1254** | -| Existing CLI, explicit fallback `gpt-5.5`, green triangle, requested 1024×1024 | Succeeded in 17.2s; PNG **1347×1167** | -| Existing CLI, edit blue circle to red, WebP output | Succeeded in 20.6s; valid WebP **1254×1254**, visually correct | -| Existing CLI, explicit transparent background | **HTTP 400:** `Transparent background is not supported for this model.` | -| Current Codex native generation endpoint, `gpt-image-2`, requested 1024×1024 | Succeeded in 17.1s; JSON response and decoded PNG both say **1254×1254** | - -Generated/edit previews were visually inspected. Samples are in [output/audit](../output/audit); [image measurements](evidence/live-images.json), [transparent-request evidence](evidence/request-event-summary.json), and [native endpoint evidence](evidence/native-request-summary.json) contain no credentials or base64 image bodies. - -**Scope:** one account on Linux; these tests establish present access here, not access for every subscription. Live multi-image batch/style-transfer, every quality/size combination, native edits, macOS/Windows keyrings, and real token-refresh/concurrency were not exercised. Offline tests cover multiple outputs, style-reference request construction and batch behavior. No server, browser or watcher was started. - -## Priority 1 — Fix shared Codex login corruption on refresh - -Location: [`_refresh_auth`, cli.py:428](../codex_imagegen_cli/cli.py#L428), particularly the assignment to `tokens["id_token"]`. - -When OAuth returns a new ID token, the CLI stores a decoded dictionary in `auth.json`. Current Codex requires that field to remain the raw JWT **string**. This can break ordinary Codex authentication after imagegen refreshes its shared credentials. - -Reproduced against the actual installed Codex with synthetic credentials in an isolated temporary home: - -```text -Before imagegen refresh: codex login status -> exit 0, Logged in using ChatGPT -After imagegen refresh: codex login status -> exit 1 -Error checking login status: invalid type: map, expected a string at line 4 column 16 -``` - -**Update:** persist the original returned JWT string; decode claims only in memory. Add a compatibility test that loads the resulting file with current Codex. The existing refresh test does not return `id_token`, and its auth fixture already uses the wrong dictionary representation, so it misses this defect. Also improve credential persistence with restricted permissions and atomic writes, and consider concurrent refresh coordination; those latter failure modes were inspected but not reproduced. - -Upstream contract: [Codex 0.154.0 token serialization](https://github.com/openai/codex/blob/rust-v0.154.0/codex-rs/login/src/token_data.rs#L163). - -## Priority 1 — Refresh dependencies and repair release validation - -Locations: [pyproject.toml](../pyproject.toml), [uv.lock](../uv.lock). - -The locked runtime dependency **Pillow 12.2.0** has **13 distinct advisory groups**, fixed by 12.3.0. One relevant example affects opening crafted image files by filename, a path this CLI uses: [Pillow McIdas memory-read advisory](https://github.com/python-pillow/Pillow/security/advisories/GHSA-62p4-gmf7-7g93). Not every flagged Pillow issue is reachable through this CLI, and exploitability was not tested. - -The development dependency chain also includes **Cryptography 48.0.0**, with **4 distinct advisory groups**. It is pulled in by publishing/keyring tooling, not the CLI's runtime HTTPS implementation. [Maintainer advisory for bundled OpenSSL](https://github.com/pyca/cryptography/security/advisories/GHSA-537c-gmf6-5ccf). The scanner emitted 32 raw records; duplicates reduce this to **17 distinct advisories across two packages**. See [deduplicated evidence](evidence/vulnerability-summary.json). - -Fresh isolated builds resolve the unbounded build requirement to **Hatchling 1.32.0**, which emits `Metadata-Version: 2.5`. Locked **Twine 6.2.0** rejects both the wheel and source archive. Updating the development toolchain, including **Twine 7.0.0 / Packaging 26.3**, makes both checks pass. A passing historic workflow does not prove a fresh build will pass today because build isolation resolves new Hatchling releases independently of `uv.lock`. - -Main tested updates: - -| Package | Locked | Tested current | -|---|---|---| -| Pillow | 12.2.0 | 12.3.0 | -| Cryptography, dev only | 48.0.0 | 50.0.1 | -| Twine | 6.2.0 | 7.0.0 | -| Packaging | 26.2 | 26.3 | -| Build | 1.5.0 | 1.6.1 | -| Pytest | 9.0.3 | 9.1.1 | -| Ruff | 0.15.15 | 0.16.7 | - -**Update:** refresh the lockfile, raise the runtime Pillow minimum to a patched version, and establish compatible minimum publishing-tool versions. In `/tmp/codex-imagegen-upgrade-check`, a full lock refresh passed all 23 tests on Python 3.10, built valid artifacts and produced [zero known dependency findings](evidence/upgraded-scan-summary.json). New Ruff defaults produce 38 import/type-modernization findings; clean them up or explicitly retain the existing `E4,E7,E9,F` rule set, which was tested and passes. Do not treat a blind lock refresh as fully green CI. - -Seven [open dependency PRs](https://github.com/jdmnk/codex-imagegen-cli/pulls) were found: #9 checkout v7, #10 pytest, #13 Pillow, #14 Hatchling requirement, #15 Ruff, #16 Build, #17 setup-python v7. Some target versions are already behind today's releases. Review/update them together with a fresh CI run. - -## Priority 1 — Make size/background behavior accurate - -Locations: [`_responses_payload`](../codex_imagegen_cli/cli.py#L518), [`_write_response_image`](../codex_imagegen_cli/cli.py#L565), [README backend/options](../README.md#backend-path). - -The CLI sends explicit sizes but silently saves differently sized images and reports success. Two independent CLI generations reproduced this. The native endpoint also returned different dimensions, so **switching endpoints alone does not fix exact-size behavior**. - -Transparent background is accepted by the parser and advertised without a model caveat, but the tested backend rejects it with a user-error response. - -**Update:** verify decoded image dimensions, report the actual size, and provide a clear mismatch policy. If exact-size postprocessing is offered, make resizing/cropping explicit. Make option validation/documentation reflect the selected backend and image model. Preserve useful backend error details. Do not claim all quality flags are ignored: the native response reported the requested `low` quality; image quality itself was not benchmarked. - -## Priority 2 — Align the backend with current Codex - -The project forces an `image_generation` tool through `/backend-api/codex/responses`, selected by a reasoning-model name. That path still works here. - -Current Codex 0.154.0's native image extension instead uses: - -```text -POST /backend-api/codex/images/generations -POST /backend-api/codex/images/edits -model: gpt-image-2 -``` - -Its responses are JSON containing `data[].b64_json`, image dimensions and quality. The native generation request was successfully tested in this audit. Native edit behavior was inspected in source, not live-tested. Sources: [current backend](https://github.com/openai/codex/blob/rust-v0.154.0/codex-rs/ext/image-generation/src/backend.rs), [endpoint/schema implementation](https://github.com/openai/codex/blob/rust-v0.154.0/codex-rs/codex-api/src/endpoint/images.rs), [tool model and five-image limit](https://github.com/openai/codex/blob/rust-v0.154.0/codex-rs/ext/image-generation/src/tool.rs#L58). - -**Update:** evaluate making this native path primary, preserving the existing path deliberately if needed, and separate image-model selection from reasoning-model selection. It removes an unnecessary reasoning-model dependency and the need to extract image output from SSE. Keep the private-backend compatibility caveat. - -Official documentation still describes Codex's built-in model as `gpt-image-2`; the separate public Images API now documents GPT Image 2.5. API availability does **not** establish subscription-backend availability. Do not blindly substitute a public API model. [Codex image-generation docs](https://learn.chatgpt.com/docs/image-generation), [public API image guide](https://developers.openai.com/api/docs/guides/image-generation). - -The existing five-reference-image limit still matches current Codex. The `gpt-5.5` fallback worked in this audit, although [issue #18](https://github.com/jdmnk/codex-imagegen-cli/issues/18) reports an account-specific `model_not_found`. It is not proven globally broken. - -## Priority 2 — Correct configuration, batch and image handling - -| Confirmed behavior | Location | Update | -|---|---|---| -| Regex model lookup reads a model from an inactive TOML profile | `_default_model`, line 502 | Parse TOML and respect top-level/active-profile precedence; use a Python 3.10-compatible parser strategy | -| `"images": null` is accepted in batch validation, then crashes with `TypeError` | `_load_jobs_jsonl`, line 154; `_cmd_batch`, line 985 | Store normalized validated jobs | -| `"prompt": null` becomes the literal prompt `"None"` | Same | Require actual non-empty strings | -| Invalid mode aborts all later batch jobs even without `--fail-fast` | `_cmd_batch`, line 985 | Validate before starting the batch, or put per-job validation inside the existing failure-handling block | -| EXIF-rotated input loses its intended orientation during WebP compaction | `_encoded_input_image`, line 474 | Apply EXIF transpose before resizing and encoding | -| Invalid base64 `!!!!` creates an empty PNG file and reports success | `_write_response_image`, line 565 | Strict base64 decoding plus image validation before replacing output | - -Codex can now store credentials in file, keyring, automatic or ephemeral modes. This CLI only reads files. That is an explicit limitation to document and diagnose clearly; do not imply that any successful `codex login` guarantees a readable `auth.json`. [Official authentication documentation](https://learn.chatgpt.com/docs/auth#credential-storage). - -## Priority 2/3 — Harden transport and user-facing errors - -If the Responses backend is retained, the audit reproduces: - -- **CRLF SSE separators fail:** `_post_sse` only splits on `\n\n` and merges multiple CRLF-delimited events into invalid JSON. LF-delimited events pass the positive control. -- **Terminal-response fallback is missing:** a result in `response.completed.response.output` is ignored when no per-item result was emitted. Current live streams worked; this is a robustness gap, not a live outage observed today. -- **Incomplete HTTP reads leak a traceback:** `http.client.IncompleteRead` is not wrapped as `TransportError`. -- **HTTP 429 is not retried:** only streamed `response.failed` rate-limit events are handled. -- **401 followed by a streamed rate limit bypasses retries:** the post-refresh request executes inside the exception handler, outside the common retry loop. -- **Bad timeout values and filesystem input errors leak through:** negative timeouts pass validation, and passing a directory as `--prompt-file` raises `IsADirectoryError` instead of a concise CLI error. - -Update the stream parser, unify bounded retry handling, retain `Retry-After` information, validate positive finite timeouts and normalize expected filesystem/HTTP errors. Avoid automatic retries after ambiguous successful generation unless duplicate image usage is accounted for. - -## Reproduction and suggested update order - -```bash -cd /home/jure/codex-imagegen-cli -uv sync --frozen --dev -uv run pytest # original 23 tests -uv run pytest audit/test_controls.py # six passing controls -uv run pytest audit/test_regressions.py # 14 expected failures on audited commit -uv run ruff check . -uv run python -m build -uv run python -m twine check dist/* # currently fails on metadata 2.5 -``` - -On this VPS, `uv` was absent and system Python lacked `ensurepip`. Audit tooling was installed separately; use `/tmp/codex-imagegen-audit-tools/uv` here. The system-Python build works with `PATH=/tmp/codex-imagegen-audit-tools:$PATH` and `uv run python -m build --installer uv`. Complete Python 3.10/3.14 runtimes also build with the documented command. Missing `ensurepip` is an environment issue, separate from the reproducible Twine incompatibility. - -Recommended sequence: - -1. Fix auth serialization; add the real Codex compatibility regression. -2. Update Pillow and publishing dependencies; resolve new Ruff findings/configuration and rerun CI. -3. Correct size/background promises and validate actual output. -4. Evaluate the current native image endpoints and clarify model selection. -5. Fix batch normalization, TOML handling, EXIF orientation, output validation and remaining error paths. -6. Promote the audit probes into the normal suite as fixes land; add an opt-in live compatibility check and update the changelog/version for a release. - -No commits, pushes, dependency merges, deployments, or implementation fixes were performed. diff --git a/audit/evidence/live-images.json b/audit/evidence/live-images.json deleted file mode 100644 index 97c9d28..0000000 --- a/audit/evidence/live-images.json +++ /dev/null @@ -1,42 +0,0 @@ -[ - { - "file": "generate-fallback.png", - "format": "PNG", - "size": [ - 1347, - 1167 - ], - "mode": "RGB", - "bytes": 773878 - }, - { - "file": "native-generate-0.png", - "format": "PNG", - "size": [ - 1254, - 1254 - ], - "mode": "RGB", - "bytes": 624831 - }, - { - "file": "generate-default.png", - "format": "PNG", - "size": [ - 1254, - 1254 - ], - "mode": "RGB", - "bytes": 654206 - }, - { - "file": "edit.webp", - "format": "WEBP", - "size": [ - 1254, - 1254 - ], - "mode": "RGB", - "bytes": 13858 - } -] diff --git a/audit/evidence/maintenance-dependency-scan.json b/audit/evidence/maintenance-dependency-scan.json deleted file mode 100644 index 5d9c833..0000000 --- a/audit/evidence/maintenance-dependency-scan.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "packages": 34, - "vulnerability_records": 0 -} diff --git a/audit/evidence/maintenance-live-images.json b/audit/evidence/maintenance-live-images.json deleted file mode 100644 index dbbe8bc..0000000 --- a/audit/evidence/maintenance-live-images.json +++ /dev/null @@ -1,29 +0,0 @@ -[ - { - "file": "native-edit.webp", - "size": [ - 1254, - 1254 - ], - "format": "WEBP", - "bytes": 15740 - }, - { - "file": "native.png", - "size": [ - 1254, - 1254 - ], - "format": "PNG", - "bytes": 762675 - }, - { - "file": "responses.png", - "size": [ - 1254, - 1254 - ], - "format": "PNG", - "bytes": 778788 - } -] diff --git a/audit/evidence/maintenance-live-tests.txt b/audit/evidence/maintenance-live-tests.txt deleted file mode 100644 index 123074a..0000000 --- a/audit/evidence/maintenance-live-tests.txt +++ /dev/null @@ -1,12 +0,0 @@ -============================= test session starts ============================== -platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0 -- /home/jure/codex-imagegen-cli/.venv/bin/python -cachedir: .pytest_cache -rootdir: /home/jure/codex-imagegen-cli -configfile: pyproject.toml -testpaths: tests -collecting ... collected 98 items / 96 deselected / 2 selected - -tests/test_live.py::test_live_native_generate_and_edit PASSED [ 50%] -tests/test_live.py::test_live_responses_compatibility PASSED [100%] - -====================== 2 passed, 96 deselected in 51.95s ======================= diff --git a/audit/evidence/maintenance-offline-tests.txt b/audit/evidence/maintenance-offline-tests.txt deleted file mode 100644 index d0c399b..0000000 --- a/audit/evidence/maintenance-offline-tests.txt +++ /dev/null @@ -1,3 +0,0 @@ -.............................ss......................................... [ 73%] -.......................... [100%] -96 passed, 2 skipped in 0.68s diff --git a/audit/evidence/native-request-summary.json b/audit/evidence/native-request-summary.json deleted file mode 100644 index 5cce475..0000000 --- a/audit/evidence/native-request-summary.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "url": "https://chatgpt.com/backend-api/codex/images/generations", - "request": { - "model": "gpt-image-2", - "prompt": "A single orange square centered on a white square canvas. No text.", - "quality": "low", - "size": "1024x1024", - "background": "opaque" - }, - "response": { - "created": 1789476769, - "background": "opaque", - "output_format": "png", - "quality": "low", - "size": "1254x1254", - "usage": { - "input_tokens": 20, - "input_tokens_details": { - "image_tokens": 0, - "text_tokens": 20 - }, - "output_tokens": 515, - "output_tokens_details": { - "image_tokens": 515, - "text_tokens": 0 - }, - "total_tokens": 535 - } - }, - "image_count": 1, - "actual": { - "size": [ - 1254, - 1254 - ], - "mode": "RGB", - "format": "PNG" - }, - "seconds": 17.1 -} \ No newline at end of file diff --git a/audit/evidence/outdated-packages.json b/audit/evidence/outdated-packages.json deleted file mode 100644 index 3046f40..0000000 --- a/audit/evidence/outdated-packages.json +++ /dev/null @@ -1 +0,0 @@ -[{"name":"build","version":"1.5.0","latest_version":"1.6.1","latest_filetype":"wheel"},{"name":"certifi","version":"2026.5.20","latest_version":"2026.7.22","latest_filetype":"wheel"},{"name":"cffi","version":"2.0.0","latest_version":"2.1.1","latest_filetype":"wheel"},{"name":"charset-normalizer","version":"3.4.7","latest_version":"3.5.1","latest_filetype":"wheel"},{"name":"cryptography","version":"48.0.0","latest_version":"50.0.1","latest_filetype":"wheel"},{"name":"idna","version":"3.17","latest_version":"3.19","latest_filetype":"wheel"},{"name":"jaraco-functools","version":"4.5.0","latest_version":"4.6.0","latest_filetype":"wheel"},{"name":"nh3","version":"0.3.5","latest_version":"0.3.7","latest_filetype":"wheel"},{"name":"packaging","version":"26.2","latest_version":"26.3","latest_filetype":"wheel"},{"name":"pillow","version":"12.2.0","latest_version":"12.3.0","latest_filetype":"wheel"},{"name":"pygments","version":"2.20.0","latest_version":"2.21.0","latest_filetype":"wheel"},{"name":"pytest","version":"9.0.3","latest_version":"9.1.1","latest_filetype":"wheel"},{"name":"readme-renderer","version":"44.0","latest_version":"46.0","latest_filetype":"wheel"},{"name":"ruff","version":"0.15.15","latest_version":"0.16.7","latest_filetype":"wheel"},{"name":"twine","version":"6.2.0","latest_version":"7.0.0","latest_filetype":"wheel"}] diff --git a/audit/evidence/pytest.txt b/audit/evidence/pytest.txt deleted file mode 100644 index 3342f77..0000000 --- a/audit/evidence/pytest.txt +++ /dev/null @@ -1,132 +0,0 @@ -.............................FFFFFFFFFFFFFF [100%] -=================================== FAILURES =================================== -________________ test_refresh_preserves_id_token_as_jwt_string _________________ -audit/test_regressions.py:49: in test_refresh_preserves_id_token_as_jwt_string - assert json.loads(path.read_text())["tokens"]["id_token"] == new_id -E AssertionError: assert {'chatgpt_account_id': None, 'chatgpt_account_is_fedramp': False, 'chatgpt_plan_type': None, 'chatgpt_user_id': None, ...} == 'e30.eyJlbWFpbCI6ICJ0ZXN0QGV4YW1wbGUuaW52YWxpZCJ9.signature' -__________________ test_stream_accepts_crlf_event_separators ___________________ -audit/test_regressions.py:60: in test_stream_accepts_crlf_event_separators - assert cli._stream_image_result("https://example.invalid", headers={}, payload={}, timeout=1) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -codex_imagegen_cli/cli.py:812: in _stream_image_result - raise CliError("Responses stream ended without an image generation result.") -E codex_imagegen_cli.cli.CliError: Responses stream ended without an image generation result. -_______________ test_stream_accepts_result_in_completed_response _______________ -audit/test_regressions.py:69: in test_stream_accepts_result_in_completed_response - assert cli._stream_image_result("https://example.invalid", headers={}, payload={}, timeout=1) == encoded - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -codex_imagegen_cli/cli.py:812: in _stream_image_result - raise CliError("Responses stream ended without an image generation result.") -E codex_imagegen_cli.cli.CliError: Responses stream ended without an image generation result. -_____________ test_truncated_http_response_has_cli_transport_error _____________ -audit/test_regressions.py:79: in test_truncated_http_response_has_cli_transport_error - list(cli._post_sse("https://example.invalid", headers={}, payload={}, timeout=1)) -codex_imagegen_cli/cli.py:391: in _post_sse - with request.urlopen(req, timeout=timeout) as response: -/usr/lib/python3.12/contextlib.py:137: in __enter__ - return next(self.gen) - ^^^^^^^^^^^^^^ -audit/test_regressions.py:75: in broken - raise IncompleteRead(b"partial", 100) -E http.client.IncompleteRead: IncompleteRead(7 bytes read, 100 more expected) -__________ test_rate_limit_retry_also_works_after_http_errors[False] ___________ -audit/test_regressions.py:101: in test_rate_limit_retry_also_works_after_http_errors - assert cli._call_responses_backend(args=args, mode="generate", prompt="test", output_path=tmp_path / "out.png") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -codex_imagegen_cli/cli.py:701: in _call_responses_backend - encoded = _stream_image_result( -audit/test_regressions.py:93: in stream - raise result -E codex_imagegen_cli.cli.HttpError: HTTP 429: rate limited -___________ test_rate_limit_retry_also_works_after_http_errors[True] ___________ -audit/test_regressions.py:93: in stream - raise result -E codex_imagegen_cli.cli.HttpError: HTTP 401: expired - -During handling of the above exception, another exception occurred: -audit/test_regressions.py:101: in test_rate_limit_retry_also_works_after_http_errors - assert cli._call_responses_backend(args=args, mode="generate", prompt="test", output_path=tmp_path / "out.png") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -codex_imagegen_cli/cli.py:713: in _call_responses_backend - encoded = _stream_image_result( -audit/test_regressions.py:93: in stream - raise result -E codex_imagegen_cli.cli.ResponsesImageGenerationError: Responses image generation failed (rate_limit_exceeded): Please try again in 1ms. -_________________ test_batch_null_images_behaves_as_empty_list _________________ -audit/test_regressions.py:109: in test_batch_null_images_behaves_as_empty_list - assert cli.main(["batch", "--input", str(jobs), "--out-dir", str(tmp_path)]) == 0 - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -codex_imagegen_cli/cli.py:1064: in main - return int(args.func(args)) - ^^^^^^^^^^^^^^^ -codex_imagegen_cli/cli.py:997: in _cmd_batch - images = [_resolve_path(raw, cd) for raw in job.get("images", [])] - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -E TypeError: 'NoneType' object is not iterable -______________________ test_batch_rejects_nontext_prompt _______________________ -audit/test_regressions.py:115: in test_batch_rejects_nontext_prompt - with pytest.raises(cli.CliError): -E Failed: DID NOT RAISE -_____________ test_batch_invalid_job_does_not_abort_remaining_jobs _____________ -audit/test_regressions.py:128: in test_batch_invalid_job_does_not_abort_remaining_jobs - assert visited == ["good"] -E AssertionError: assert [] == ['good'] -E -E Right contains one more item: 'good' -E Use -v to get more diff ------------------------------ Captured stderr call ----------------------------- -Error: Job 1 has invalid mode: invalid -_____________ test_config_does_not_use_model_from_inactive_profile _____________ -audit/test_regressions.py:137: in test_config_does_not_use_model_from_inactive_profile - assert cli._default_model(args) == cli.DEFAULT_CODEX_MODEL -E AssertionError: assert 'unavailable-model' == 'gpt-5.5' -E -E - gpt-5.5 -E + unavailable-model -___________________ test_compaction_applies_exif_orientation ___________________ -audit/test_regressions.py:148: in test_compaction_applies_exif_orientation - assert output.size == (20, 40) -E assert (40, 20) == (20, 40) -E -E At index 0 diff: 40 != 20 -E Use -v to get more diff -____________ test_corrupt_base64_does_not_create_successful_output _____________ -audit/test_regressions.py:153: in test_corrupt_base64_does_not_create_successful_output - with pytest.raises(cli.CliError): -E Failed: DID NOT RAISE -________________________ test_timeout_must_be_positive _________________________ -audit/test_regressions.py:161: in test_timeout_must_be_positive - with pytest.raises(cli.CliError): -E Failed: DID NOT RAISE -_________________ test_prompt_directory_is_a_user_facing_error _________________ -audit/test_regressions.py:167: in test_prompt_directory_is_a_user_facing_error - cli._read_prompt(None, str(tmp_path), tmp_path) -codex_imagegen_cli/cli.py:89: in _read_prompt - text = _read_optional_prompt(prompt, prompt_file, cd) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -codex_imagegen_cli/cli.py:102: in _read_optional_prompt - text = path.read_text(encoding="utf-8").strip() - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -/usr/lib/python3.12/pathlib.py:1029: in read_text - with self.open(mode='r', encoding=encoding, errors=errors) as f: - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -/usr/lib/python3.12/pathlib.py:1015: in open - return io.open(self, mode, buffering, encoding, errors, newline) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -E IsADirectoryError: [Errno 21] Is a directory: '/tmp/pytest-of-jure/pytest-6/test_prompt_directory_is_a_use0' -=========================== short test summary info ============================ -FAILED audit/test_regressions.py::test_refresh_preserves_id_token_as_jwt_string -FAILED audit/test_regressions.py::test_stream_accepts_crlf_event_separators -FAILED audit/test_regressions.py::test_stream_accepts_result_in_completed_response -FAILED audit/test_regressions.py::test_truncated_http_response_has_cli_transport_error -FAILED audit/test_regressions.py::test_rate_limit_retry_also_works_after_http_errors[False] -FAILED audit/test_regressions.py::test_rate_limit_retry_also_works_after_http_errors[True] -FAILED audit/test_regressions.py::test_batch_null_images_behaves_as_empty_list -FAILED audit/test_regressions.py::test_batch_rejects_nontext_prompt - Failed:... -FAILED audit/test_regressions.py::test_batch_invalid_job_does_not_abort_remaining_jobs -FAILED audit/test_regressions.py::test_config_does_not_use_model_from_inactive_profile -FAILED audit/test_regressions.py::test_compaction_applies_exif_orientation - ... -FAILED audit/test_regressions.py::test_corrupt_base64_does_not_create_successful_output -FAILED audit/test_regressions.py::test_timeout_must_be_positive - Failed: DID... -FAILED audit/test_regressions.py::test_prompt_directory_is_a_user_facing_error -14 failed, 29 passed in 0.82s diff --git a/audit/evidence/pytest.xml b/audit/evidence/pytest.xml deleted file mode 100644 index 5da4dc4..0000000 --- a/audit/evidence/pytest.xml +++ /dev/null @@ -1,85 +0,0 @@ -audit/test_regressions.py:49: in test_refresh_preserves_id_token_as_jwt_string - assert json.loads(path.read_text())["tokens"]["id_token"] == new_id -E AssertionError: assert {'chatgpt_account_id': None, 'chatgpt_account_is_fedramp': False, 'chatgpt_plan_type': None, 'chatgpt_user_id': None, ...} == 'e30.eyJlbWFpbCI6ICJ0ZXN0QGV4YW1wbGUuaW52YWxpZCJ9.signature'audit/test_regressions.py:60: in test_stream_accepts_crlf_event_separators - assert cli._stream_image_result("https://example.invalid", headers={}, payload={}, timeout=1) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -codex_imagegen_cli/cli.py:812: in _stream_image_result - raise CliError("Responses stream ended without an image generation result.") -E codex_imagegen_cli.cli.CliError: Responses stream ended without an image generation result.audit/test_regressions.py:69: in test_stream_accepts_result_in_completed_response - assert cli._stream_image_result("https://example.invalid", headers={}, payload={}, timeout=1) == encoded - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -codex_imagegen_cli/cli.py:812: in _stream_image_result - raise CliError("Responses stream ended without an image generation result.") -E codex_imagegen_cli.cli.CliError: Responses stream ended without an image generation result.audit/test_regressions.py:79: in test_truncated_http_response_has_cli_transport_error - list(cli._post_sse("https://example.invalid", headers={}, payload={}, timeout=1)) -codex_imagegen_cli/cli.py:391: in _post_sse - with request.urlopen(req, timeout=timeout) as response: -/usr/lib/python3.12/contextlib.py:137: in __enter__ - return next(self.gen) - ^^^^^^^^^^^^^^ -audit/test_regressions.py:75: in broken - raise IncompleteRead(b"partial", 100) -E http.client.IncompleteRead: IncompleteRead(7 bytes read, 100 more expected)audit/test_regressions.py:101: in test_rate_limit_retry_also_works_after_http_errors - assert cli._call_responses_backend(args=args, mode="generate", prompt="test", output_path=tmp_path / "out.png") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -codex_imagegen_cli/cli.py:701: in _call_responses_backend - encoded = _stream_image_result( -audit/test_regressions.py:93: in stream - raise result -E codex_imagegen_cli.cli.HttpError: HTTP 429: rate limitedaudit/test_regressions.py:93: in stream - raise result -E codex_imagegen_cli.cli.HttpError: HTTP 401: expired - -During handling of the above exception, another exception occurred: -audit/test_regressions.py:101: in test_rate_limit_retry_also_works_after_http_errors - assert cli._call_responses_backend(args=args, mode="generate", prompt="test", output_path=tmp_path / "out.png") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -codex_imagegen_cli/cli.py:713: in _call_responses_backend - encoded = _stream_image_result( -audit/test_regressions.py:93: in stream - raise result -E codex_imagegen_cli.cli.ResponsesImageGenerationError: Responses image generation failed (rate_limit_exceeded): Please try again in 1ms.audit/test_regressions.py:109: in test_batch_null_images_behaves_as_empty_list - assert cli.main(["batch", "--input", str(jobs), "--out-dir", str(tmp_path)]) == 0 - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -codex_imagegen_cli/cli.py:1064: in main - return int(args.func(args)) - ^^^^^^^^^^^^^^^ -codex_imagegen_cli/cli.py:997: in _cmd_batch - images = [_resolve_path(raw, cd) for raw in job.get("images", [])] - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -E TypeError: 'NoneType' object is not iterableaudit/test_regressions.py:115: in test_batch_rejects_nontext_prompt - with pytest.raises(cli.CliError): -E Failed: DID NOT RAISE <class 'codex_imagegen_cli.cli.CliError'>audit/test_regressions.py:128: in test_batch_invalid_job_does_not_abort_remaining_jobs - assert visited == ["good"] -E AssertionError: assert [] == ['good'] -E -E Right contains one more item: 'good' -E Use -v to get more diffaudit/test_regressions.py:137: in test_config_does_not_use_model_from_inactive_profile - assert cli._default_model(args) == cli.DEFAULT_CODEX_MODEL -E AssertionError: assert 'unavailable-model' == 'gpt-5.5' -E -E - gpt-5.5 -E + unavailable-modelaudit/test_regressions.py:148: in test_compaction_applies_exif_orientation - assert output.size == (20, 40) -E assert (40, 20) == (20, 40) -E -E At index 0 diff: 40 != 20 -E Use -v to get more diffaudit/test_regressions.py:153: in test_corrupt_base64_does_not_create_successful_output - with pytest.raises(cli.CliError): -E Failed: DID NOT RAISE <class 'codex_imagegen_cli.cli.CliError'>audit/test_regressions.py:161: in test_timeout_must_be_positive - with pytest.raises(cli.CliError): -E Failed: DID NOT RAISE <class 'codex_imagegen_cli.cli.CliError'>audit/test_regressions.py:167: in test_prompt_directory_is_a_user_facing_error - cli._read_prompt(None, str(tmp_path), tmp_path) -codex_imagegen_cli/cli.py:89: in _read_prompt - text = _read_optional_prompt(prompt, prompt_file, cd) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -codex_imagegen_cli/cli.py:102: in _read_optional_prompt - text = path.read_text(encoding="utf-8").strip() - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -/usr/lib/python3.12/pathlib.py:1029: in read_text - with self.open(mode='r', encoding=encoding, errors=errors) as f: - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -/usr/lib/python3.12/pathlib.py:1015: in open - return io.open(self, mode, buffering, encoding, errors, newline) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -E IsADirectoryError: [Errno 21] Is a directory: '/tmp/pytest-of-jure/pytest-6/test_prompt_directory_is_a_use0' \ No newline at end of file diff --git a/audit/evidence/request-event-summary.json b/audit/evidence/request-event-summary.json deleted file mode 100644 index c5504d4..0000000 --- a/audit/evidence/request-event-summary.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - "requested_model": "gpt-6-astra", - "requested_tools": [ - { - "type": "image_generation", - "output_format": "png", - "size": "1536x1024", - "quality": "low", - "background": "transparent" - } - ] - } -] \ No newline at end of file diff --git a/audit/evidence/upgraded-scan-summary.json b/audit/evidence/upgraded-scan-summary.json deleted file mode 100644 index 4367de7..0000000 --- a/audit/evidence/upgraded-scan-summary.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "scanned_packages": 34, - "known_vulnerability_records": 0 -} diff --git a/audit/evidence/vulnerability-summary.json b/audit/evidence/vulnerability-summary.json deleted file mode 100644 index e9dbcca..0000000 --- a/audit/evidence/vulnerability-summary.json +++ /dev/null @@ -1,208 +0,0 @@ -[ - { - "package": "cryptography", - "version": "48.0.0", - "raw_records": 7, - "unique_advisories": [ - { - "id": "GHSA-m2h6-j472-rp4c", - "aliases": [ - "CVE-2026-69248", - "GHSA-m2h6-j472-rp4c", - "PYSEC-2026-3554" - ], - "fix_versions": [ - "49.0.0" - ] - }, - { - "id": "GHSA-g6cj-pr64-35w5", - "aliases": [ - "CVE-2026-69247", - "GHSA-g6cj-pr64-35w5", - "PYSEC-2026-3552" - ], - "fix_versions": [ - "50.0.0" - ] - }, - { - "id": "GHSA-jwv3-5hgf-82ww", - "aliases": [ - "CVE-2026-69249", - "GHSA-jwv3-5hgf-82ww", - "PYSEC-2026-3553" - ], - "fix_versions": [ - "49.0.0" - ] - }, - { - "id": "GHSA-537c-gmf6-5ccf", - "aliases": [ - "GHSA-537c-gmf6-5ccf" - ], - "fix_versions": [ - "48.0.1" - ] - } - ] - }, - { - "package": "pillow", - "version": "12.2.0", - "raw_records": 25, - "unique_advisories": [ - { - "id": "GHSA-8v84-f9pq-wr9x", - "aliases": [ - "BIT-pillow-2026-54059", - "CVE-2026-54059", - "GHSA-8v84-f9pq-wr9x", - "PYSEC-2026-2253" - ], - "fix_versions": [ - "12.3.0" - ] - }, - { - "id": "GHSA-45hq-cxwh-f6vc", - "aliases": [ - "BIT-pillow-2026-55379", - "CVE-2026-55379", - "GHSA-45hq-cxwh-f6vc", - "PYSEC-2026-2255" - ], - "fix_versions": [ - "12.3.0" - ] - }, - { - "id": "GHSA-4x4j-2g7c-83w6", - "aliases": [ - "CVE-2026-55798", - "GHSA-4x4j-2g7c-83w6", - "PYSEC-2026-2257" - ], - "fix_versions": [ - "12.3.0" - ] - }, - { - "id": "GHSA-phj9-mv4w-65pm", - "aliases": [ - "BIT-pillow-2026-55380", - "CVE-2026-55380", - "GHSA-phj9-mv4w-65pm", - "PYSEC-2026-2256" - ], - "fix_versions": [ - "12.3.0" - ] - }, - { - "id": "GHSA-5x94-69rx-g8h2", - "aliases": [ - "BIT-pillow-2026-54060", - "CVE-2026-54060", - "GHSA-5x94-69rx-g8h2", - "PYSEC-2026-2254" - ], - "fix_versions": [ - "12.3.0" - ] - }, - { - "id": "GHSA-9hw9-ch79-4vh6", - "aliases": [ - "BIT-pillow-2026-59205", - "CVE-2026-59205", - "GHSA-9hw9-ch79-4vh6", - "PYSEC-2026-3453" - ], - "fix_versions": [ - "12.3.0" - ] - }, - { - "id": "GHSA-6r8x-57c9-28j4", - "aliases": [ - "BIT-pillow-2026-59199", - "CVE-2026-59199", - "GHSA-6r8x-57c9-28j4", - "PYSEC-2026-3451" - ], - "fix_versions": [ - "12.3.0" - ] - }, - { - "id": "GHSA-pg7v-jwj7-p798", - "aliases": [ - "BIT-pillow-2026-59203", - "CVE-2026-59203", - "GHSA-pg7v-jwj7-p798", - "PYSEC-2026-3452" - ], - "fix_versions": [ - "12.3.0" - ] - }, - { - "id": "GHSA-62p4-gmf7-7g93", - "aliases": [ - "CVE-2026-54058", - "GHSA-62p4-gmf7-7g93", - "PYSEC-2026-3493" - ], - "fix_versions": [ - "12.3.0" - ] - }, - { - "id": "GHSA-xj96-63gp-2gmr", - "aliases": [ - "CVE-2026-59197", - "GHSA-xj96-63gp-2gmr", - "PYSEC-2026-3454" - ], - "fix_versions": [ - "12.3.0" - ] - }, - { - "id": "GHSA-fj7v-r99m-22gq", - "aliases": [ - "CVE-2026-59198", - "GHSA-fj7v-r99m-22gq", - "PYSEC-2026-3494" - ], - "fix_versions": [ - "12.3.0" - ] - }, - { - "id": "GHSA-jjj6-mw9f-p565", - "aliases": [ - "CVE-2026-59200", - "GHSA-jjj6-mw9f-p565", - "PYSEC-2026-3495" - ], - "fix_versions": [ - "12.3.0" - ] - }, - { - "id": "GHSA-vjc4-5qp5-m44j", - "aliases": [ - "CVE-2026-59204", - "GHSA-vjc4-5qp5-m44j", - "PYSEC-2026-3496" - ], - "fix_versions": [ - "12.3.0" - ] - } - ] - } -] From 5f0a33e2c8988ef6fd064f23183b2cc01fb85941 Mon Sep 17 00:00:00 2001 From: jdmnk <5247767+jdmnk@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:12:50 +0000 Subject: [PATCH 3/3] Restore README presentation and limit edits to factual updates --- README.md | 355 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 231 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index efd82fa..8bc4f18 100644 --- a/README.md +++ b/README.md @@ -1,243 +1,350 @@ -# 🎨 codex-imagegen-cli +
-Generate and edit images from your terminal using your existing **Codex ChatGPT login**. No OpenAI API key is required. +# 🎨 codex-imagegen-cli -The CLI supports `generate`, `edit`, style references, sequential JSONL batches, and PNG/WebP output. It calls Codex's authenticated backend directly. This is an **unofficial, private backend integration**, whose availability and behavior can change. +**Generate and edit images from your terminal using [Codex](https://github.com/openai/codex)'s built-in image tools — no API key needed.** -## Install +Codex (OpenAI's CLI coding agent) has image generation capabilities built in. This CLI exposes them as a standalone scriptable tool, reusing your existing Codex ChatGPT login. Supports `generate`, `edit`, and `batch` modes out of the box. -Requirements: Python 3.10+, [uv](https://docs.astral.sh/uv/), and file-based Codex ChatGPT authentication. +[![Python](https://img.shields.io/badge/python-3.10%2B-blue?logo=python&logoColor=white)](https://www.python.org/) +[![License](https://img.shields.io/badge/license-Apache%202.0-green)](LICENSE) +[![Status](https://img.shields.io/badge/status-alpha-orange)](CHANGELOG.md) +[![uv](https://img.shields.io/badge/uv-ready-purple?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyem0tMSAxNHYtNEg3bDUtOHY0aDRsLTUgOHoiLz48L3N2Zz4=)](https://github.com/astral-sh/uv) -```bash -git clone https://github.com/jdmnk/codex-imagegen-cli.git -cd codex-imagegen-cli -uv sync --frozen --dev -uv run codex-imagegen --help -``` +
-To install a user command from this checkout: +--- -```bash -uv tool install -e . +```text +$ codex-imagegen generate \ + --prompt "A 32x32 retro RPG pixel icon of an iron helmet, no text" \ + --out output/icons/iron-helmet.png ``` -Both `codex-imagegen` and `codex-imagegen-cli` are entry points for the same command. +Uses your existing **Codex ChatGPT login** — no `OPENAI_API_KEY` required. -### Authentication +## Why this exists -The CLI reads `$CODEX_HOME/auth.json` or `~/.codex/auth.json`. Override this with `--codex-home PATH` or `--auth-file PATH`. +[Codex](https://github.com/openai/codex) (OpenAI's CLI coding agent) has image generation built in — but it's prompt-driven and agentic, not a scriptable command. There is no `codex image generate` subcommand, no `--size` or `--quality` flags, and the documented automation primitive (`codex exec`) emits assistant text, not image bytes. -Codex may store credentials in an OS keyring or only in memory. This CLI supports **file-based ChatGPT credentials only**. If needed, set this in your Codex `config.toml` before logging in: +This CLI fills that gap by calling Codex's backend directly, using your existing ChatGPT subscription auth. No `OPENAI_API_KEY` needed, as it's not API billing - just the same image generation Codex uses internally, exposed as a scriptable tool for `generate`, `edit`, and `batch` workflows with clear options for **reasoning model** (Responses backend), **size**, **quality**, **background**, and **output**. -```toml -cli_auth_credentials_store = "file" -``` +## Requirements + +- Python 3.10+ +- `uv` +- Codex logged in with file-based ChatGPT auth -Then run: +Run Codex login once if needed: ```bash codex login ``` -For a headless machine, Codex also supports `codex login --device-auth`. See [Codex authentication](https://learn.chatgpt.com/docs/auth). +Choose the ChatGPT login flow. The CLI reads `$CODEX_HOME/auth.json` or `~/.codex/auth.json`, refreshes tokens when needed, and never prints token values. Keyring-only and ephemeral credentials are not supported; if needed, set `cli_auth_credentials_store = "file"` in Codex `config.toml` before logging in. -API-key-only sessions, keyrings, ephemeral credentials, and enterprise access-token authentication are not implemented. An API key in your environment does not change this CLI to API billing. Treat `auth.json` as a secret; never commit it or include it in bug reports. +## First Run -Token refresh preserves Codex's raw JWT format and writes the file atomically with owner-only permissions on POSIX. A separate lock coordinates imagegen processes. Codex itself does not share that lock: the CLI detects observed external auth changes and refuses to overwrite them, but cannot guarantee coordination with every external writer. +Use the CLI from a local checkout: -## Quick start +```bash +git clone https://github.com/jdmnk/codex-imagegen-cli.git +cd codex-imagegen-cli +uv sync --dev +``` + +Verify the command: ```bash -uv run codex-imagegen generate \ - --prompt "A retro RPG pixel icon of an iron helmet, no text" \ - --out output/iron-helmet.png +uv run codex-imagegen --help +uv run codex-imagegen --version ``` -Inspect the planned request without reading credentials or contacting the backend: +Run a dry run: ```bash uv run codex-imagegen generate \ - --prompt "A studio photo of a mug" \ - --out output/mug.png \ + --prompt "A 32x32 retro RPG pixel icon of an iron helmet, no text" \ + --out output/icons/iron-helmet.png \ --dry-run ``` +To install it as a user command from the checkout: + +```bash +uv tool install -e . +``` + ## Generate ```bash codex-imagegen generate \ - --prompt "A bronze shield icon with a red gem, no text" \ - --out output/shield.webp \ - --n 3 \ - --webp-quality 82 + --prompt "A 32x32 retro RPG pixel icon of a steel longsword with a blue grip, no text" \ + --out output/icons/steel-longsword.png ``` -This makes one request per output, sequentially, writing `shield-1.webp`, `shield-2.webp`, and `shield-3.webp`. +Request multiple images: + +```bash +codex-imagegen generate \ + --prompt "A 32x32 retro RPG pixel icon of a bronze shield with a red gem, no text" \ + --out output/icons/bronze-shield.png \ + --n 3 +``` -Successful paths are printed to stdout as soon as each file is saved. Progress, actual dimensions and warnings go to stderr. If a later output fails, earlier files remain and their paths have already been printed. Existing files are protected unless `--force` is set. +Multiple outputs are written as `bronze-shield-1.png`, `bronze-shield-2.png`, and so on. Each saved path is printed immediately; if a later request fails, earlier outputs remain available. -## Edit and style references +Write WebP directly by using a `.webp` output path: ```bash -codex-imagegen edit \ - --image input/sword.png \ - --prompt "Make this a fire-enchanted sword; preserve its silhouette" \ - --out output/fire-sword.png +codex-imagegen generate \ + --prompt "A 32x32 retro RPG pixel icon of a bronze shield with a red gem, no text" \ + --out output/icons/bronze-shield.webp \ + --webp-quality 82 ``` -Use one to five `--image` inputs. For a style reference: +## Edit ```bash codex-imagegen edit \ - --image input/profile.png \ - --style-image input/editorial-style.png \ - --prompt "Keep the background bright" \ - --out output/profile.webp + --image input/icons/iron-sword.png \ + --prompt "Turn this into a fire-enchanted sword icon while keeping the same retro RPG pixel-art style, silhouette, and no text" \ + --out output/icons/fire-sword.png ``` -With `--style-image`, the prompt is optional extra guidance. Content images are sent first, and the style reference is sent last. The generated instruction preserves the first image's subject and composition and borrows visual style from the final image. The style reference counts toward the five-image limit. +Edit accepts one to five `--image` inputs, matching the current Codex image tool limit. -Input images are decoded, corrected for EXIF orientation, resized to a maximum edge of 1536 pixels and encoded as WebP at quality 90. Original files are not changed. Use `--input-max-edge 0` to disable resizing; WebP encoding still occurs. Invalid input images fail locally instead of uploading undecodable original bytes. +### Style Reference Edits -PNG output is validated before saving. WebP output is converted locally with Pillow. Files are published atomically after validation; a failed conversion or rejected size cannot truncate an existing output. +Use `--style-image` when you want to keep the idea/content from one image but render it in the style of another image. -## Size, quality and background +```bash +codex-imagegen edit \ + --image input/profile.png \ + --style-image input/style-reference.png \ + --out output/profile-in-style.webp +``` -- `--size auto` is the default. Explicit `WIDTHxHEIGHT` values require dimensions divisible by 16, no edge above 3840, an aspect ratio between 1:3 and 3:1, and 655,360–8,294,400 pixels. Examples: `1024x1024`, `1536x1024`, `2048x2048`, `3840x2160`. -- **Requested dimensions are not guaranteed.** The authenticated backend has returned different sizes even for explicit requests. The CLI reports the decoded dimensions. -- `--size-policy warn` (default) warns and saves the backend's original dimensions. -- `--size-policy error` fails without writing that output if explicit dimensions differ. The generation has already occurred and may have consumed usage. Existing output is preserved even with `--force`. No automatic resizing or cropping occurs. -- `--quality auto|low|medium|high` is passed to the backend. -- `--background auto|opaque` works with the current default path. Explicit transparent backgrounds are unsupported on the tested Codex image backend, so the CLI rejects `--background transparent` locally. -- `--output-format auto|png|webp` controls local output. `auto` chooses WebP for `.webp` paths, otherwise PNG. An explicit format overrides the extension. +In this mode, `--image` is the content image and `--style-image` is only the style reference. The CLI writes a clear edit instruction for Codex: preserve the subject, identity, composition, proportions, and important details from `--image`; borrow the medium, rendering approach, palette, lighting, texture, finish, and mood from `--style-image`; do not copy the style reference's subject or scene. -For a script that requires exact dimensions: +`--prompt` is optional with `--style-image`. If you include it, it is treated as extra guidance: ```bash -codex-imagegen generate \ - --prompt "A clean landscape illustration" \ - --size 1536x1024 \ - --size-policy error \ - --out output/landscape.png +codex-imagegen edit \ + --image input/profile.png \ + --style-image input/editorial-lighting.png \ + --prompt "keep the background bright and professional" \ + --out output/profile-editorial.webp ``` -## Batch +The style image counts toward the same one-to-five edit image limit. -Create a JSONL file with one prompt string or job object per line: +## Input And Output WebP -```jsonl -"An iron helmet icon" -{"prompt":"A bronze shield icon","out":"shield.webp"} -{"prompt":"Make this shield icy","images":["input/shield.png"],"mode":"edit","out":"ice-shield.png"} -``` +The CLI uses WebP in two different places: + +- **Input WebP for edits.** When you run `edit`, local input images are corrected for EXIF orientation, resized if needed, and converted to WebP before they are sent to Codex. This makes large PNG/JPEG files much smaller in the request, which helps avoid broken uploads and reduces pressure on Codex's input-image quota. Your original file is not modified. +- **Output WebP for saved files.** Codex returns PNG image bytes. If `--out` ends in `.webp`, or you set `--output-format webp`, the CLI converts the returned PNG to WebP locally before saving it. + +Example: ```bash -codex-imagegen batch --input jobs.jsonl --out-dir output/batch +codex-imagegen edit \ + --image input/profile.png \ + --prompt "make it more polished" \ + --out output/profile.webp ``` -Job fields: +In that command, `input/profile.png` is compacted to WebP for the request, and `output/profile.webp` is saved as WebP. Invalid input images are rejected locally. Output images are validated and saved atomically, preserving existing files if validation or conversion fails. -- `prompt`: required non-empty string. -- `out`: optional non-empty filename/path under `--out-dir`; paths escaping that directory are rejected. -- `images`: optional list of paths; `null` is treated as an empty list. -- `mode`: `generate` or `edit`; inferred from whether images are provided when omitted. - -Relative input-image paths are resolved against `--cd` or the working directory. Blank lines and lines starting with `#` are ignored. Whole-file JSON and basic schema errors are rejected before execution. Per-job mode/path/backend failures are reported and later jobs continue, unless `--fail-fast` is set. Any failed job produces exit status 1. Batch-wide image settings apply to all jobs. +## Batch -## Backends and reasoning-model selection +Batch mode runs JSONL jobs sequentially. -### Native images — default in 0.2.0 +```bash +mkdir -p tmp/imagegen +cat > tmp/imagegen/jobs.jsonl <<'EOF' +{"prompt":"A 32x32 retro RPG pixel icon of an iron helmet, no text","out":"iron-helmet.png"} +{"prompt":"A 32x32 retro RPG pixel icon of leather boots with tiny silver buckles, no text","out":"leather-boots.png"} +{"prompt":"Make this shield look ice-enchanted while keeping the same retro RPG pixel-art style","images":["input/icons/wooden-shield.png"],"mode":"edit","out":"ice-shield.png"} +EOF + +codex-imagegen batch \ + --input tmp/imagegen/jobs.jsonl \ + --out-dir output/imagegen/batch +``` -`--backend native` follows the native image request format used by Codex 0.154.0: +Each line can be either a JSON string prompt or an object with: + +- `prompt`: required non-empty text prompt +- `out`: optional output filename, resolved under `--out-dir`; paths escaping that directory are rejected +- `images`: optional list of image paths for edit jobs; `null` is treated as an empty list +- `mode`: optional `generate` or `edit`; defaults to `edit` when images are present + +Per-job validation or backend failures are reported and later jobs continue unless `--fail-fast` is set. Any failed job produces exit status 1. + +## Useful Flags + +- `--prompt-file prompt.txt`: read the prompt from a file +- `--force`: allow replacing existing output files +- `--cd PATH`: base directory for resolving relative paths +- `--auth-file PATH`: read a specific Codex `auth.json` +- `--codex-home PATH`: read auth from another Codex home directory +- `--backend native|responses`: choose the backend; default: `native`. +- `--model MODEL` / `--reasoning-model MODEL`: override the reasoning model with `--backend responses` only; does not select the image generator. +- `--profile NAME`: select a Codex reasoning-model profile with `--backend responses` only. +- `--base-url URL`: override the Codex backend URL for development +- `--background auto|opaque`: direct `background` parameter. Explicit `transparent` is unsupported on the tested backend and rejected locally. +- `--quality auto|low|medium|high`: direct `quality` parameter. +- `--size auto|WIDTHxHEIGHT`: requested dimensions; see constraints below. +- `--size-policy warn|error`: on a dimension mismatch, warn and save (default), or fail without writing the output. +- `--style-image PATH`: edit mode only. Use this image as the style reference; `--image` stays the content image and `--prompt` becomes optional extra guidance. +- `--output-format auto|png|webp`: output file format. Default: infer from `--out`; `.webp` writes WebP, everything else writes PNG. +- `--webp-quality 1..100`: WebP encoder quality. Default: `85`. +- `--input-max-edge PIXELS`: resize edit input images before upload so the longest edge is at most this value. Default: `1536`; use `0` to disable resizing. +- `--input-webp-quality 1..100`: WebP quality for compacted edit input images. Default: `90`. +- `--n COUNT`: request multiple output images. The CLI runs one hosted image request per output. +- `--dry-run`: print the request shape without reading auth or contacting the backend + +## Backend Path + +The default `--backend native` uses Codex's native image endpoints: ```text POST https://chatgpt.com/backend-api/codex/images/generations POST https://chatgpt.com/backend-api/codex/images/edits ``` -Generation request: +Generation sends `model`, `prompt`, `size`, `quality`, and `background`; edits also send `images: [{"image_url": "data:image/webp;base64,..."}]`. Results contain `data[].b64_json`. The fixed request field `model: "gpt-image-2"` follows Codex's request format; it does not verify the actual output model. The backend controls the image generator, and GPT Image 2.5 availability cannot be confirmed. There is no `--image-model` option; `CODEX_IMAGEGEN_IMAGE_MODEL` has no effect. + +Use `--backend responses` for the original hosted image-generation path: + +`POST https://chatgpt.com/backend-api/codex/responses` + +On this backend, the request forces the hosted `image_generation` tool and sends image options as tool parameters: + +```json +{ + "model": "gpt-5.5", + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "A studio photo of a mug"}] + } + ], + "tools": [ + { + "type": "image_generation", + "output_format": "png", + "size": "1536x1024", + "quality": "high", + "background": "opaque" + } + ], + "tool_choice": {"type": "image_generation"}, + "stream": true +} +``` + +Responses edit requests add input images to the user message: ```json { - "model": "gpt-image-2", - "prompt": "A studio photo of a mug", - "size": "auto", - "quality": "auto", - "background": "auto" + "content": [ + {"type": "input_text", "text": "Make it blue"}, + {"type": "input_image", "image_url": "data:image/webp;base64,..."} + ], + "tools": [{"type": "image_generation"}] } ``` -Edits add `images: [{"image_url": "data:image/webp;base64,..."}]`. Responses contain `data[].b64_json`. The fixed `model: "gpt-image-2"` field follows the Codex request format; it does not verify which image model produced the output. The backend controls the image model, and the CLI exposes no image-model selector. Reasoning-model settings in Codex config are not used for native images. +With `--style-image`, the content image is sent first and the style image is sent last. The text instruction labels that final image as style-only, so Codex has a clearer separation between "what to keep" and "what visual style to borrow." -In September 15, 2026 subscription-backend probes, both GPT Image 2.5 names and a deliberately nonexistent model name returned images without reporting model identity. Successful requests therefore do not establish model selection or GPT Image 2.5 availability. This CLI uses Codex subscription access only. +With `--backend responses`, reasoning-model precedence is `--model`, `CODEX_IMAGEGEN_MODEL`, the selected Codex profile, the top-level Codex model, then `gpt-5.5`. `--profile` overrides the profile selected in `config.toml`. Native requests do not use reasoning-model settings. Existing commands with `--model` must add `--backend responses`; there is no automatic fallback between backends. -### Responses — explicit compatibility mode +The accepted image preference values are: -The original route remains available: +- `size`: `auto` or `WIDTHxHEIGHT`, including `1024x1024` square, `1536x1024` landscape, or `1024x1536` portrait. Explicit dimensions must be divisible by 16, with no edge above 3840, an aspect ratio between 1:3 and 3:1, and 655,360–8,294,400 total pixels. +- `quality`: `auto`, `low`, `medium`, or `high` +- `background`: `auto` or `opaque` -```bash -codex-imagegen generate \ - --backend responses \ - --model gpt-5.5 \ - --prompt "A studio photo of a mug" \ - --out output/mug.png -``` +Requested dimensions are not guaranteed. The CLI reports actual dimensions and warns on mismatches by default. `--size-policy error` rejects a mismatched output without saving it, even with `--force`; the generation has already occurred and may have consumed usage. The CLI does not resize or crop output to match the request. + +`n` is handled by the CLI by running one hosted image request per output path. +For edit jobs, repeated outputs may wait for the per-minute input-image quota window before retrying; if the bucket stays full, retries back off progressively. +Edit input images are compacted locally to WebP before upload by default. This keeps request bodies smaller and reduces pressure on the input-image quota. +Codex returns PNG bytes; when the requested output is WebP, the CLI converts the PNG locally with Pillow. -It posts a forced `image_generation` tool request to `/backend-api/codex/responses` and reads streamed results. `--model` (alias `--reasoning-model`) selects the reasoning model on this backend; it does not select the image generator. Precedence is the flag, `CODEX_IMAGEGEN_MODEL`, the selected Codex profile's model, the top-level Codex model, then `gpt-5.5`. Use `--profile NAME` to override the top-level `profile` selection in `config.toml`. +## How It Works -There is no automatic fallback between backends: retrying a request through another route could generate a duplicate image. Model access varies by account. +`codex-imagegen-cli` reuses your normal Codex login. On each run it: -### Migration from 0.1.0 +- reads Codex auth from `$CODEX_HOME/auth.json` or `~/.codex/auth.json` +- refreshes the ChatGPT access token when needed +- compacts edit input images to WebP before upload +- sends a native Codex image request by default, or a hosted `image_generation` tool request with `--backend responses` +- decodes PNG bytes from the native JSON response or streamed Responses result +- validates and atomically saves PNG output, or converts locally to WebP when requested -Existing basic generate/edit/batch commands now use native images. Commands specifying a reasoning `--model` or `--profile` must add `--backend responses`. The experimental `--image-model` flag has been removed, and `CODEX_IMAGEGEN_IMAGE_MODEL` has no effect; the backend controls the image model. Explicit transparency on the default path now fails locally. Corrupt images, invalid batch fields and escaped output paths are rejected. Size mismatches now produce warnings, with optional strict failure. +Dry runs only print the planned request and output paths; they do not read auth or contact Codex. -## Other options and errors +## Important Note -- `--prompt-file PATH`: UTF-8 prompt file, mutually exclusive with `--prompt`. -- `--cd PATH`: base directory for relative input/output paths. -- `--auth-file PATH`, `--codex-home PATH`: credential location overrides. -- `--timeout SECONDS`: positive finite HTTP socket timeout, default 300. This is not a whole-command deadline; retry waits and multiple images add time. -- `--input-max-edge PIXELS`, `--input-webp-quality 1..100`: edit input compaction settings. -- `--webp-quality 1..100`: local WebP output quality, default 85. -- `--base-url URL` / `CODEX_IMAGEGEN_BASE_URL`: backend override for development. Credentials are sent to that URL; use only a trusted backend. -- `CODEX_IMAGEGEN_REFRESH_URL`: trusted development override for the OAuth endpoint. -- `CODEX_IMAGEGEN_CODEX_VERSION`: override the version header when Codex is not available locally. +This project relies on Codex's current authenticated app behavior, not a public OpenAI Images API contract. -HTTP 429 and streamed rate limits use bounded retries (at most four), including bounded `Retry-After` delays and longer input-image quota waits. One 401 refresh is allowed per output. Known billing/quota/user errors are not retried. Transport interruptions and ambiguous server failures are surfaced without automatic replay because an image may already have been generated. Exit codes are 0 for success, 1 for runtime/job failure, 2 for argument parsing errors, and 130 for interruption. +A few things worth knowing: -Generation consumes the account's Codex usage limits. Account access and quota policies can change. This tool is not a supported public OpenAI API contract. +- **Subscription only.** This CLI requires file-based ChatGPT auth and account access to Codex image generation. It does not support API-key sessions or route requests to the public Images API. Account eligibility can change. +- **Usage limits apply.** Image generations consume your Codex account's included limits. Edit jobs also consume an input-image quota; if that per-minute bucket is full, the CLI waits and retries. +- **No stable API contract.** Codex internals and account policies can change. The CLI may stop working or behave differently after a Codex update, and OpenAI has not documented this as a supported automation surface. -## Development and compatibility checks +We will try to keep this project updated as long as this usage remains possible and allowed. + +## Development ```bash uv lock --check -uv sync --frozen --dev +uv sync --dev uv run pytest uv run ruff check . uv run ruff format --check . +``` + +Build and validate release artifacts: + +```bash uv run python -m build --installer uv uv run python -m twine check dist/* ``` -The build requires `uv` on PATH. Build dependencies are constrained to the tested Hatchling minor release, and the publishing toolchain supports its metadata format. CI covers Python 3.10, 3.12 and 3.14. Offline tests use synthetic credentials. If a Codex binary is installed, a test also checks that the refreshed synthetic login file is readable by Codex. - -Opt-in live compatibility checks make **three image requests** (native generation/edit and Responses generation), consume account usage and never refresh your shared credentials: +Offline tests use synthetic credentials. Opt-in live checks make three image requests (native generation/edit and Responses generation), consume account usage, and disable credential refresh: ```bash CODEX_IMAGEGEN_LIVE_TEST=1 uv run pytest -m live -v ``` -Outputs use pytest's temporary directory. Without the environment variable, live tests are skipped. No server or browser is started. See [CONTRIBUTING.md](CONTRIBUTING.md) and [CHANGELOG.md](CHANGELOG.md). +## Update And Uninstall -## Update, uninstall and security +Update a source install from the checkout: ```bash git pull uv tool install --reinstall -e . ``` +Uninstall the CLI: + ```bash uv tool uninstall codex-imagegen-cli ``` -Report security issues privately as described in [SECURITY.md](SECURITY.md). Version `0.2.0` remains an alpha source-install release. See [LICENSE](LICENSE). +## Security + +Report security issues privately through GitHub Security Advisories when available. See [SECURITY.md](SECURITY.md) for scope and response expectations. + +## Project Status + +`0.2.0` is an alpha source-install release. The CLI command shape, JSONL batch format, and Codex backend behavior may change before a stable release. + +See [CONTRIBUTING.md](CONTRIBUTING.md), [CHANGELOG.md](CHANGELOG.md), and [LICENSE](LICENSE).