diff --git a/README.md b/README.md index 8a6c8f7..496acee 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `batchor` grew out of a recurring problem in academic research: running large datasets through LLMs in batch — reliably, reproducibly, and without reinventing the same glue code across every project. The patterns that kept emerging (durable state, typed results, safe resume after failure) were extracted into this library so they do not have to be rebuilt each time. -`batchor` is a durable OpenAI Batch runner for Python teams that want: +`batchor` is a durable provider Batch runner for Python teams that want: - typed Pydantic results - resumable durable runs @@ -12,7 +12,7 @@ - library-first run controls - a small operator CLI for CSV and JSONL jobs -It is intentionally narrow today: OpenAI-first, SQLite-first, and library-first. +It is intentionally narrow today: OpenAI-first for the CLI, SQLite-first for local durability, and library-first for provider selection beyond the default OpenAI path. ## What problem it solves @@ -37,6 +37,7 @@ Most OpenAI Batch examples stop at "upload a JSONL file and poll until it finish Built-in implementations: - `OpenAIProviderConfig` + `OpenAIBatchProvider` +- `GeminiProviderConfig` + `GeminiBatchProvider` for text-only Gemini Batch jobs in the Python API - `SQLiteStorage` - `PostgresStorage` as an opt-in durable control-plane backend - `MemoryStateStore` @@ -52,6 +53,8 @@ Important constraints: - the CLI supports file-backed inputs only - users still own selecting and ordering input files or partitions - the built-in CLI uses SQLite durability only +- the built-in CLI is OpenAI-only today; Gemini is exposed through the Python API +- Gemini support is text-only for now and does not build multimodal requests - structured-output rehydration requires an importable module-level Pydantic model - raw output artifacts are retained by default and must be exported before raw pruning - pause/resume/cancel and incremental terminal-result APIs are library-first today @@ -88,6 +91,7 @@ graph LR subgraph providers["providers/"] OpenAI["OpenAIBatchProvider"] + Gemini["GeminiBatchProvider"] end subgraph sources["sources/"] @@ -107,6 +111,7 @@ graph LR User -->|"start() / run_and_wait()"| BatchRunner BatchRunner --> Run BatchRunner --> OpenAI + BatchRunner --> Gemini BatchRunner --> SQLite BatchRunner --> LocalFS Files -->|"BatchItem stream"| BatchRunner @@ -142,6 +147,12 @@ Operational semantics for resume, run control, and artifact retention live in pip install batchor ``` +For Gemini Batch support, install the optional extra: + +```bash +pip install "batchor[gemini]" +``` + ## Repo Agent Setup This repo now includes local AI-agent scaffolding so a contributor agent can pick up repo conventions without extra global setup: @@ -163,8 +174,8 @@ Supported Python versions: For Python API usage, auth resolution is: -1. explicit `OpenAIProviderConfig(api_key=...)` -2. ambient `OPENAI_API_KEY` +1. explicit provider config credentials such as `OpenAIProviderConfig(api_key=...)` or `GeminiProviderConfig(api_key=...)` +2. ambient provider environment variables, currently `OPENAI_API_KEY` or `GEMINI_API_KEY` The Python library does not auto-load `.env`. @@ -193,6 +204,29 @@ run = runner.run_and_wait( print(run.results()[0].output_text) ``` +### Gemini text job + +```python +from batchor import BatchItem, BatchJob, BatchRunner, GeminiProviderConfig, PromptParts + + +runner = BatchRunner(storage="memory") +run = runner.run_and_wait( + BatchJob( + items=[BatchItem(item_id="row1", payload="Summarize this text")], + build_prompt=lambda item: PromptParts(prompt=item.payload), + provider_config=GeminiProviderConfig( + model="gemini-2.5-flash", + api_key="YOUR_GEMINI_API_KEY", + ), + ) +) + +print(run.results()[0].output_text) +``` + +Gemini support currently builds text-only `GenerateContent` batch requests. It uses Gemini JSONL `key` values internally while keeping `batchor`'s durable item and attempt tracking unchanged. + ### Structured output ```python @@ -244,6 +278,8 @@ Structured-output models are validated up front against the OpenAI strict-schema If you need a field to be optional in Python, model it as nullable in the schema shape OpenAI accepts rather than relying on omitted required fields. +The same `structured_output=` API is available with `GeminiProviderConfig`; batchor sends the schema through Gemini `generation_config.response_json_schema` and validates the returned JSON text with the same Pydantic model. + ### Rehydrate a durable run ```python diff --git a/docs/design_docs/ARCHITECTURE.md b/docs/design_docs/ARCHITECTURE.md index c68fdcb..66de7ac 100644 --- a/docs/design_docs/ARCHITECTURE.md +++ b/docs/design_docs/ARCHITECTURE.md @@ -58,6 +58,7 @@ graph TB subgraph providers["providers/"] BatchProvider["BatchProvider (ABC)"] OpenAIProvider["OpenAIBatchProvider"] + GeminiProvider["GeminiBatchProvider"] ProviderRegistry end @@ -91,6 +92,7 @@ graph TB MemoryStateStore -.->|implements| StateStore BatchRunner -->|submits/polls| BatchProvider OpenAIProvider -.->|implements| BatchProvider + GeminiProvider -.->|implements| BatchProvider BatchRunner -->|stores artifacts| ArtifactStore LocalArtifactStore -.->|implements| ArtifactStore BatchRunner -->|creates via| ProviderRegistry @@ -120,6 +122,10 @@ The public runtime model centers on four types: one logical source, while callers remain responsible for selecting and ordering the child sources up front. +Provider adaptation is intentionally concentrated behind `BatchProvider`. +The runtime stores one durable internal custom identifier per item attempt, while each provider maps that identifier to its own request JSONL field. OpenAI uses `custom_id`; Gemini uses `key`. +Provider hooks also own response-text extraction so structured-output parsing can stay generic across provider payload shapes. + ## Main user-facing flow The normal public flow is: @@ -136,7 +142,7 @@ Internally that expands to: 3. Claim a bounded submission window from pending items. 4. Build or replay request JSONL rows. 5. Persist request artifacts before upload. -6. Submit one or more OpenAI batch files. +6. Submit one or more provider batch files. 7. Poll active batches. 8. Download output/error files. 9. Parse terminal item results back into the state store. @@ -190,7 +196,7 @@ sequenceDiagram BatchRunner->>Provider: upload_input_file(local_path) Provider-->>BatchRunner: remote_file_id BatchRunner->>Provider: create_batch(remote_file_id) - Provider-->>BatchRunner: BatchRemoteRecord (status=validating) + Provider-->>BatchRunner: BatchRemoteRecord (status=submitted/validating) BatchRunner->>StateStore: register_batch() BatchRunner->>StateStore: mark_items_submitted() end diff --git a/docs/design_docs/GEMINI_BATCHING.md b/docs/design_docs/GEMINI_BATCHING.md new file mode 100644 index 0000000..feefacb --- /dev/null +++ b/docs/design_docs/GEMINI_BATCHING.md @@ -0,0 +1,109 @@ +# Gemini Batching + +This document describes the Gemini-specific behavior inside `batchor`. + +Status: implemented for text-only Python API jobs. Multimodal request construction is intentionally out of scope for now. + +## Current behavior + +Gemini support is provided by: + +- `GeminiProviderConfig` +- `GeminiBatchProvider` +- the default `ProviderRegistry` + +The provider is available through the Python API after installing the optional SDK extra: + +```bash +pip install "batchor[gemini]" +``` + +The CLI remains OpenAI-only today. + +## Request construction + +The built-in provider converts each prepared item into one Gemini Batch JSONL row: + +```json +{"key":"row1:a1","request":{"contents":[{"parts":[{"text":"Summarize this text"}]}]}} +``` + +Important details: + +- `key` is the provider-facing correlation identifier. +- the runtime still stores the same durable attempt identifier internally as `custom_id` +- `PromptParts.prompt` becomes a text part under `request.contents` +- `PromptParts.system_prompt` becomes `request.system_instruction` +- `GeminiProviderConfig.generation_config` is copied into each request when provided + +Structured-output jobs add: + +```json +{ + "generation_config": { + "response_mime_type": "application/json", + "response_json_schema": {"type": "object"} + } +} +``` + +The schema is generated from the same `structured_output=` Pydantic model used by OpenAI jobs and is validated locally before submission. + +## Authentication + +Authentication resolution is: + +1. explicit `GeminiProviderConfig(api_key=...)` +2. ambient `GEMINI_API_KEY` + +The provider builds the Google GenAI client lazily so importing `batchor` or constructing the default provider registry does not require the optional `google-genai` dependency. + +## Batch lifecycle + +The provider follows the Google Gemini Batch/File API flow: + +1. upload the prepared JSONL file through the File API with `mime_type="jsonl"` +2. create a batch with `client.batches.create(model=..., src=..., config={"display_name": ...})` +3. poll with `client.batches.get(name=batch_id)` +4. treat `JOB_STATE_SUCCEEDED` and `JOB_STATE_PARTIALLY_SUCCEEDED` as completed +5. read the result file name from `dest.file_name` +6. download the result file through `client.files.download(file=...)` + +Terminal failure states map to batchor's generic terminal batch statuses: + +- `JOB_STATE_FAILED` -> `failed` +- `JOB_STATE_CANCELLED` -> `cancelled` +- `JOB_STATE_EXPIRED` -> `expired` + +Unknown or active states are normalized to `submitted`. + +## Response parsing + +Gemini output rows are split by `key`. + +Rows with a provider `error` field, or without a JSON-object `response`, are treated as item errors. +Successful rows are expected to include Gemini response payloads such as: + +```json +{"key":"row1:a1","response":{"candidates":[{"content":{"parts":[{"text":"done"}]}}]}} +``` + +Text extraction checks `response.text` first, then concatenates `response.candidates[].content.parts[].text`. +Structured-output jobs then parse the extracted text as JSON and validate it with the requested Pydantic model. + +## Durable replay + +Request artifacts preserve the provider-specific JSONL row. On retry or fresh-process resume, the runtime reloads the persisted row and asks the provider to replace only the correlation identifier. That means Gemini retries preserve the original `request` payload while updating `key` from, for example, `row1:a1` to `row1:a2`. + +## Current limits + +- Gemini support is Python API only. +- Gemini request construction is text-only. +- multimodal File API references are not generated by `batchor` yet. +- Gemini-specific enqueue-limit controls are not implemented yet; generic request-count and request-file-size chunking still apply. +- live Gemini smoke tests are manual/TBD; automated coverage uses fake Gemini clients. + +## References + +- [Gemini Batch API](https://ai.google.dev/gemini-api/docs/batch-mode) +- [Gemini structured output](https://ai.google.dev/gemini-api/docs/structured-output) diff --git a/docs/design_docs/OPENAI_BATCHING.md b/docs/design_docs/OPENAI_BATCHING.md index 67932d2..91cd0de 100644 --- a/docs/design_docs/OPENAI_BATCHING.md +++ b/docs/design_docs/OPENAI_BATCHING.md @@ -2,10 +2,12 @@ This document describes the OpenAI-specific behavior inside `batchor`. -`batchor` is not a generic batch abstraction with many first-party providers yet. The OpenAI path is the primary implemented path, so a lot of runtime behavior is defined around OpenAI Batch semantics. +OpenAI remains the default and most feature-complete provider path. Gemini has its own design note in [`GEMINI_BATCHING.md`](GEMINI_BATCHING.md), while this page focuses only on OpenAI Batch semantics. ## Current behavior +The Python API and CLI both support OpenAI. The CLI is OpenAI-only today. + ## Request construction The built-in provider converts each prepared item into one OpenAI Batch JSONL request row. @@ -174,7 +176,6 @@ Provider-side remote batch cancellation is not implemented in v1. ## Current limits -- only the built-in OpenAI Batch provider path is implemented - the docs do not yet provide a full capability matrix across all OpenAI endpoint features - artifact storage is still local-filesystem-only diff --git a/docs/design_docs/ROADMAP.md b/docs/design_docs/ROADMAP.md index 1be62c8..989e35b 100644 --- a/docs/design_docs/ROADMAP.md +++ b/docs/design_docs/ROADMAP.md @@ -8,11 +8,12 @@ This file tracks important work that should be explicit in the extracted reposit - extend resumable ingestion beyond the built-in CSV/JSONL sources - add automated retention windows on top of the explicit export/prune lifecycle - add more input adapters beyond CSV and JSONL +- expose non-OpenAI providers through CLI workflows once provider-specific auth and flags are stable - add CLI structured-output workflows if the importability story can stay durable and predictable ## Longer-Term Ideas -- additional providers beyond OpenAI +- additional provider coverage beyond OpenAI and text-only Gemini - artifact store abstraction - partial-result streaming APIs - richer CLI or operator workflow beyond the current file-backed text-job surface diff --git a/docs/design_docs/STORAGE_AND_RUNS.md b/docs/design_docs/STORAGE_AND_RUNS.md index a5eefa1..484c730 100644 --- a/docs/design_docs/STORAGE_AND_RUNS.md +++ b/docs/design_docs/STORAGE_AND_RUNS.md @@ -72,7 +72,7 @@ Postgres is also implemented for shared control-plane state when callers explici In-memory storage exists for tests and short-lived local runs. -The SQLite/OpenAI path is covered by the default smoke test. Postgres storage compatibility is validated in a dedicated storage-contract CI job and requires `BATCHOR_TEST_POSTGRES_DSN` for equivalent local coverage. +The SQLite/OpenAI path is covered by the default smoke test. Gemini provider wiring is covered with fake-client integration tests. Postgres storage compatibility is validated in a dedicated storage-contract CI job and requires `BATCHOR_TEST_POSTGRES_DSN` for equivalent local coverage. ## Storage responsibilities @@ -164,7 +164,7 @@ Successful rehydration depends on: Fresh-process resume also requeues any `queued_local` items back to `pending` before submission resumes. -Resume compatibility intentionally ignores non-persisted secret fields such as provider API keys. +Resume compatibility intentionally ignores non-persisted secret fields such as provider API keys. Rehydrated OpenAI runs need `OPENAI_API_KEY` when no explicit in-memory config is supplied; rehydrated Gemini runs need `GEMINI_API_KEY`. For deterministic-source resume, the caller must also reuse the same `run_id` and provide the same source identity/fingerprint. For composite sources, that includes the same ordered child identities; changing the child order or swapping one file changes the logical source identity. @@ -177,6 +177,7 @@ Built-in deterministic sources currently include: - `ParquetItemSource` Once an item has a durable request artifact pointer, `batchor` prunes large inline request-building fields from the control-plane store and relies on the artifact for later retries. +Provider-specific request rows are preserved in those artifacts. During replay, OpenAI updates `custom_id`; Gemini updates `key`; both still map back to the same internal durable attempt identifier. ## Artifact lifecycle diff --git a/docs/doc-map.md b/docs/doc-map.md index 7a645fa..6159496 100644 --- a/docs/doc-map.md +++ b/docs/doc-map.md @@ -19,6 +19,7 @@ This page explains what each document is for so readers do not have to guess whi | `design_docs/BOUNDARY_AND_PHILOSOPHY.md` | Ownership boundary between `batchor`, storage/artifacts, and user pipelines. | | `design_docs/ARCHITECTURE.md` | Canonical runtime diagrams, package structure, main flows, and extension seams. | | `design_docs/OPENAI_BATCHING.md` | OpenAI request construction, token budgeting, splitting, and batch polling behavior. | +| `design_docs/GEMINI_BATCHING.md` | Gemini text-only request construction, batch polling, response parsing, and current limits. | | `design_docs/STORAGE_AND_RUNS.md` | Durable `Run` lifecycle, rehydration, checkpoints, control state, artifact retention, and operator semantics. | | `design_docs/STORAGE_MIGRATIONS.md` | SQLite schema-versioning and migration guidance. | | `design_docs/ROADMAP.md` | Intentionally unimplemented areas and planned work. | @@ -38,3 +39,4 @@ This page explains what each document is for so readers do not have to guess whi - Library-first run control now includes `pause`, `resume`, and drain-style `cancel`. - Incremental terminal-result reads/exports are documented in the Python API and storage docs. - Raw output/error artifact retention is now configurable per run through `ArtifactPolicy`. +- Gemini text-only Batch support is available through the Python API and default provider registry. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 16581ef..44b35e2 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -10,6 +10,17 @@ Supported Python versions: - `3.12` - `3.13` +- `3.14` + +## Optional provider extras + +OpenAI support is installed by default. Gemini support uses Google's optional SDK dependency: + +```bash +pip install "batchor[gemini]" +``` + +The Gemini provider is currently Python API only and text-only. The CLI remains OpenAI-focused. ## What gets installed @@ -18,6 +29,7 @@ The package includes: - the Python library - the `batchor` CLI - the built-in OpenAI provider integration +- the built-in Gemini provider integration when `batchor[gemini]` is installed - SQLite and Postgres storage implementations It does not provision external infrastructure for you. If you use Postgres or a shared artifact root, you still manage those resources yourself. @@ -29,6 +41,11 @@ For Python API usage, authentication resolution is: 1. `OpenAIProviderConfig(api_key=...)` 2. `OPENAI_API_KEY` +For Gemini Python API usage, authentication resolution is: + +1. `GeminiProviderConfig(api_key=...)` +2. `GEMINI_API_KEY` + The Python library does not auto-load `.env`. The CLI loads a local `.env` as a convenience for operator usage, then resolves `OPENAI_API_KEY`. diff --git a/docs/getting-started/python-api.md b/docs/getting-started/python-api.md index e342e90..f413df4 100644 --- a/docs/getting-started/python-api.md +++ b/docs/getting-started/python-api.md @@ -39,6 +39,32 @@ print(run.results()[0].output_text) Use `storage="memory"` only for tests or short-lived local experiments. For durable runs, use the default SQLite storage or an explicit backend. +## Gemini text job + +Gemini Batch support is available through the Python API after installing `batchor[gemini]`. +It currently builds text-only `GenerateContent` requests. + +```python +from batchor import BatchItem, BatchJob, BatchRunner, GeminiProviderConfig, PromptParts + + +runner = BatchRunner(storage="memory") +run = runner.run_and_wait( + BatchJob( + items=[BatchItem(item_id="row1", payload="Summarize this text")], + build_prompt=lambda item: PromptParts(prompt=item.payload), + provider_config=GeminiProviderConfig( + model="gemini-2.5-flash", + api_key="YOUR_GEMINI_API_KEY", + ), + ) +) + +print(run.results()[0].output_text) +``` + +If `api_key` is omitted, the provider resolves `GEMINI_API_KEY` when it needs to create a live SDK client. + ## Structured output job ```python @@ -90,6 +116,7 @@ Notes: - `batchor` validates structured-output schemas before submission; the root schema must be an object, object schemas must be closed, and object properties must all be required - `output` is the parsed Pydantic object - `output_text` preserves the raw text that was parsed +- with `GeminiProviderConfig`, the same `structured_output=` argument is sent through Gemini `generation_config.response_json_schema` ## Durable run lifecycle diff --git a/docs/index.md b/docs/index.md index aa847d7..46ecbbe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@
-`batchor` is a durable OpenAI Batch runner for Python teams that want typed results, resumable runs, replayable artifacts, and a narrow operator CLI. +`batchor` is a durable provider Batch runner for Python teams that want typed results, resumable runs, replayable artifacts, and a narrow operator CLI. [Get Started](getting-started/installation.md){ .md-button .md-button--primary } [Use Cases](getting-started/use-cases.md){ .md-button } @@ -56,7 +56,7 @@ If that is the mental model you were missing from the generated docs, start with ## Current surface -- Built-in provider: OpenAI Batch +- Built-in providers: OpenAI Batch, plus text-only Gemini Batch for Python API usage - Durable storage: SQLite by default, Postgres as an opt-in control-plane backend - Ephemeral storage: in-memory state store - Artifact backend: local filesystem via `LocalArtifactStore` diff --git a/docs/reference/api.md b/docs/reference/api.md index 0fb49b2..4bd0ec5 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -20,6 +20,7 @@ Most users only need a small subset of the package: - `BatchRunner`: start, resume, and run orchestration - `Run`: refresh, wait, inspect, export, and prune - `OpenAIProviderConfig`: built-in provider config +- `GeminiProviderConfig`: built-in Gemini provider config for Python API text batch jobs - `SQLiteStorage` and `PostgresStorage`: durable control-plane backends - `CompositeItemSource`, `CsvItemSource`, `JsonlItemSource`, and `ParquetItemSource`: deterministic item streaming @@ -63,6 +64,15 @@ This is the built-in provider implementation. Most consumers only need `OpenAIPr show_root_heading: true heading_level: 2 +## Gemini provider + +This is the built-in Gemini Batch implementation. Most consumers only need `GeminiProviderConfig`; install `batchor[gemini]` before running real Gemini jobs. + +::: batchor.providers.gemini + options: + show_root_heading: true + heading_level: 2 + ## Sources These sources support durable resume through source fingerprints and checkpoints. diff --git a/docs/smoke-test.md b/docs/smoke-test.md index 56e7c00..5e3a136 100644 --- a/docs/smoke-test.md +++ b/docs/smoke-test.md @@ -8,6 +8,7 @@ This guide defines the minimum validation bar for `batchor`. - verify durable run handling and SQLite persistence still work - verify artifact-store wiring still supports replay, export, and prune - verify OpenAI-specific batching logic through fake-provider integration tests +- verify Gemini provider wiring through fake-client integration tests - verify the documentation site still builds cleanly in strict mode ## Prerequisites @@ -66,6 +67,7 @@ uv run ty check src uv run pytest tests/unit/test_batchor_tokens.py tests/unit/test_batchor_sqlite_storage_flow.py tests/unit/test_batchor_validation.py --no-cov -q uv run pytest tests/unit/test_batchor_artifacts.py tests/unit/test_batchor_storage_contracts.py --no-cov -q uv run pytest tests/integration/test_batchor_runner.py --no-cov -q +uv run pytest tests/unit/test_batchor_gemini_provider.py tests/integration/test_batchor_gemini_runner.py --no-cov -q ``` Expected: @@ -86,6 +88,7 @@ Expected: - terminal runs, including `completed_with_failures`, can prune request artifacts without losing persisted results - shared storage-contract behavior remains aligned across SQLite and opt-in Postgres - OpenAI request splitting and enqueue-limit logic still behave as expected +- Gemini text-only request construction, batch polling normalization, response parsing, and structured-output validation still behave as expected - structured-output parsing remains stable Notes: diff --git a/mkdocs.yml b/mkdocs.yml index c746272..a7dca85 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: batchor -site_description: Durable OpenAI Batch execution with typed Pydantic results and replayable artifacts. +site_description: Durable provider Batch execution with typed Pydantic results and replayable artifacts. site_url: https://AnsonDev42.github.io/batchor/ repo_url: https://github.com/AnsonDev42/batchor repo_name: AnsonDev42/batchor @@ -88,6 +88,7 @@ nav: - Architecture: design_docs/ARCHITECTURE.md - Boundary & Philosophy: design_docs/BOUNDARY_AND_PHILOSOPHY.md - OpenAI Batching: design_docs/OPENAI_BATCHING.md + - Gemini Batching: design_docs/GEMINI_BATCHING.md - Storage & Runs: design_docs/STORAGE_AND_RUNS.md - Storage Migrations: design_docs/STORAGE_MIGRATIONS.md - Roadmap: design_docs/ROADMAP.md diff --git a/pyproject.toml b/pyproject.toml index 1bd90dc..8db0d84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "batchor" version = "0.0.1" -description = "Structured-first OpenAI Batch runner with typed Pydantic results." +description = "Structured-first provider Batch runner with typed Pydantic results." readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.12,<4" @@ -19,7 +19,7 @@ dependencies = [ "typer>=0.16.0,<1", "python-dotenv>=1.0.1,<2", ] -keywords = ["openai", "batch", "sqlite", "cli", "pydantic"] +keywords = ["openai", "gemini", "batch", "sqlite", "cli", "pydantic"] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", @@ -41,6 +41,11 @@ Issues = "https://github.com/AnsonDev42/batchor/issues" [project.scripts] batchor = "batchor.cli:app" +[project.optional-dependencies] +gemini = [ + "google-genai>=1.55.0,<2", +] + [dependency-groups] dev = [ "pytest>=8.4.2", diff --git a/src/batchor/__init__.py b/src/batchor/__init__.py index d0027d0..4ae2a20 100644 --- a/src/batchor/__init__.py +++ b/src/batchor/__init__.py @@ -1,6 +1,6 @@ -"""Batchor: durable OpenAI Batch runner with typed Pydantic results. +"""Batchor: durable batch runner with typed Pydantic results. -This package provides a library-first API for running OpenAI Batch jobs durably, +This package provides a library-first API for running provider Batch jobs durably, with SQLite-backed state, resumable item sources, replayable request artifacts, and structured Pydantic outputs. @@ -43,6 +43,7 @@ BatchItem, BatchJob, ChunkPolicy, + GeminiProviderConfig, ItemFailure, OpenAIEnqueueLimitConfig, OpenAIModelName, @@ -63,6 +64,7 @@ ProviderConfig, StructuredOutputSchema, ) +from batchor.providers.gemini import GeminiBatchProvider from batchor.providers.openai import OpenAIBatchProvider from batchor.providers.registry import ( ProviderRegistry, @@ -91,6 +93,8 @@ "ChunkPolicy", "CompositeItemSource", "CsvItemSource", + "GeminiBatchProvider", + "GeminiProviderConfig", "ItemFailure", "ItemStatus", "ItemSource", diff --git a/src/batchor/core/enums.py b/src/batchor/core/enums.py index a5dd3b1..50c6f5c 100644 --- a/src/batchor/core/enums.py +++ b/src/batchor/core/enums.py @@ -68,9 +68,11 @@ class ProviderKind(StrEnum): Attributes: OPENAI: The built-in OpenAI Batch API provider. + GEMINI: The built-in Gemini Batch API provider. """ OPENAI = "openai" + GEMINI = "gemini" class StorageKind(StrEnum): diff --git a/src/batchor/core/models.py b/src/batchor/core/models.py index b1e7b86..f76f08d 100644 --- a/src/batchor/core/models.py +++ b/src/batchor/core/models.py @@ -16,8 +16,9 @@ from __future__ import annotations +import json from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Callable, Generic, TypeAlias, TypeVar +from typing import TYPE_CHECKING, Callable, Generic, TypeAlias, TypeVar, cast from pydantic import BaseModel @@ -82,6 +83,17 @@ class PromptParts: OpenAIReasoningLevel: TypeAlias = OpenAIReasoningEffort | str +def _json_object_copy(value: object, *, label: str) -> JSONObject: + """Return a JSON-normalised object copy or raise a type error.""" + try: + normalized = json.loads(json.dumps(value, ensure_ascii=False)) + except TypeError as exc: + raise TypeError(f"{label} must be JSON-serializable") from exc + if not isinstance(normalized, dict): + raise TypeError(f"{label} must be a JSON object") + return cast(JSONObject, normalized) + + @dataclass(frozen=True) class ArtifactPolicy: """Controls which provider artifacts are retained after a batch completes. @@ -224,6 +236,87 @@ def from_payload(cls, payload: JSONObject) -> OpenAIEnqueueLimitConfig: ) +@dataclass(frozen=True) +class GeminiProviderConfig(ProviderConfig): + """Configuration for the built-in Gemini Batch provider. + + Attributes: + model: Gemini model name, e.g. ``"gemini-2.5-flash"``. + api_key: Gemini API key. When empty the runner falls back to the + ``GEMINI_API_KEY`` environment variable. + poll_interval_sec: Seconds to sleep between polling cycles when + :meth:`~batchor.Run.wait` is used without a custom interval. + generation_config: Gemini generation configuration merged into every + request. Structured-output jobs add or override the structured + response format fields in this payload. + display_name_prefix: Prefix used when creating Gemini batch jobs. + """ + + model: str + api_key: str = "" + poll_interval_sec: float = 1.0 + generation_config: JSONObject = field(default_factory=dict) + display_name_prefix: str = "batchor" + + def __post_init__(self) -> None: + if not self.model.strip(): + raise ValueError("model must be a non-empty string") + if self.poll_interval_sec <= 0: + raise ValueError("poll_interval_sec must be > 0") + if not self.display_name_prefix.strip(): + raise ValueError("display_name_prefix must be a non-empty string") + normalized_generation_config = _json_object_copy( + self.generation_config, + label="generation_config", + ) + object.__setattr__(self, "generation_config", normalized_generation_config) + + @property + def provider_kind(self) -> ProviderKind: + return ProviderKind.GEMINI + + def to_payload(self) -> JSONObject: + return { + "api_key": self.api_key, + "model": self.model, + "poll_interval_sec": self.poll_interval_sec, + "generation_config": dict(self.generation_config), + "display_name_prefix": self.display_name_prefix, + } + + def to_public_payload(self) -> JSONObject: + """Serialise the config without secret material.""" + payload = self.to_payload() + payload.pop("api_key", None) + return payload + + @classmethod + def from_payload(cls, payload: JSONObject) -> GeminiProviderConfig: + """Deserialise a previously persisted Gemini provider config payload.""" + api_key = payload.get("api_key", "") + model = payload.get("model") + poll_interval_sec = payload.get("poll_interval_sec", 1.0) + generation_config = payload.get("generation_config", {}) + display_name_prefix = payload.get("display_name_prefix", "batchor") + if not isinstance(api_key, str): + raise TypeError("api_key must be a string") + if not isinstance(model, str): + raise TypeError("model must be a string") + if not isinstance(poll_interval_sec, int | float): + raise TypeError("poll_interval_sec must be numeric") + if not isinstance(generation_config, dict): + raise TypeError("generation_config must be a JSON object") + if not isinstance(display_name_prefix, str): + raise TypeError("display_name_prefix must be a string") + return cls( + api_key=api_key, + model=model, + poll_interval_sec=float(poll_interval_sec), + generation_config=cast(JSONObject, generation_config), + display_name_prefix=display_name_prefix, + ) + + @dataclass(frozen=True) class OpenAIProviderConfig(ProviderConfig): """Configuration for the built-in OpenAI Batch provider. diff --git a/src/batchor/core/types.py b/src/batchor/core/types.py index f2c9a2d..2727078 100644 --- a/src/batchor/core/types.py +++ b/src/batchor/core/types.py @@ -59,18 +59,21 @@ class BatchRemoteRecord(TypedDict, total=False): errors: JSONValue -class BatchRequestLine(TypedDict): - """One JSONL line in an OpenAI Batch input file. +class BatchRequestLine(TypedDict, total=False): + """One JSONL line in a provider batch input file. Attributes: - custom_id: Caller-assigned unique identifier for this request, used to - correlate responses back to items. - method: HTTP method (always ``"POST"`` for OpenAI Batch). - url: Endpoint path (e.g. ``"/v1/chat/completions"``). - body: Request body as a JSON object. + custom_id: OpenAI caller-assigned correlation identifier. + key: Gemini caller-assigned correlation identifier. + method: HTTP method for providers that batch HTTP-style requests. + url: Endpoint path for providers that batch HTTP-style requests. + body: Request body for providers that batch HTTP-style requests. + request: Gemini ``GenerateContentRequest`` payload. """ custom_id: str + key: str method: str url: str body: JSONObject + request: JSONObject diff --git a/src/batchor/providers/base.py b/src/batchor/providers/base.py index cf428e3..7dc7dc5 100644 --- a/src/batchor/providers/base.py +++ b/src/batchor/providers/base.py @@ -16,7 +16,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from batchor.core.enums import ProviderKind from batchor.core.types import BatchRemoteRecord, BatchRequestLine, JSONObject @@ -82,7 +82,8 @@ class BatchProvider(ABC): """Abstract adapter between the batchor runtime and a batch API provider. Each method maps to one step of the batch submission/polling lifecycle. - The :class:`~batchor.OpenAIBatchProvider` is the built-in implementation. + Built-in implementations include :class:`~batchor.OpenAIBatchProvider` and + :class:`~batchor.GeminiBatchProvider`. """ @abstractmethod @@ -106,6 +107,34 @@ def build_request_line( """ ... + def request_correlation_id(self, request_line: BatchRequestLine) -> str: + """Return the provider-facing correlation identifier for a request line. + + OpenAI uses ``custom_id``. Providers with a different JSONL shape can + override this while the runtime continues to store one durable custom + identifier per submitted item. + """ + custom_id = request_line.get("custom_id") + if not isinstance(custom_id, str) or not custom_id: + raise ValueError("request line is missing custom_id") + return custom_id + + def with_request_correlation_id( + self, + request_line: BatchRequestLine, + custom_id: str, + ) -> BatchRequestLine: + """Return *request_line* with its provider correlation identifier set.""" + updated = dict(request_line) + updated["custom_id"] = custom_id + return cast(BatchRequestLine, updated) + + def extract_response_text(self, response_record: JSONObject) -> str: + """Extract plain text from one successful provider output record.""" + from batchor.runtime.validation import extract_response_text + + return extract_response_text(response_record) + @abstractmethod def upload_input_file(self, input_path: str | Path) -> str: """Upload a prepared JSONL input file to the provider. diff --git a/src/batchor/providers/gemini.py b/src/batchor/providers/gemini.py new file mode 100644 index 0000000..8d34dcb --- /dev/null +++ b/src/batchor/providers/gemini.py @@ -0,0 +1,322 @@ +"""Gemini Batch API provider implementation. + +Adapts batchor's generic batch model to the Gemini Batch API. The built-in +Gemini provider supports text-only ``GenerateContent`` batch requests with +optional structured JSON output. +""" + +from __future__ import annotations + +import json +import os +from importlib import import_module +from pathlib import Path +from typing import Any, cast + +from batchor.core.models import GeminiProviderConfig, PromptParts +from batchor.core.types import BatchRemoteRecord, BatchRequestLine, JSONObject +from batchor.providers.base import BatchProvider, StructuredOutputSchema +from batchor.runtime.tokens import estimate_request_tokens + + +def resolve_gemini_api_key(config: GeminiProviderConfig) -> str: + """Resolve credentials from explicit config first, then the environment.""" + if config.api_key: + return config.api_key + api_key = os.getenv("GEMINI_API_KEY", "") + if api_key: + return api_key + raise ValueError("Gemini API key is required; pass GeminiProviderConfig(api_key=...) or set GEMINI_API_KEY") + + +class GeminiBatchProvider(BatchProvider): + """Built-in provider that adapts `batchor` jobs to the Gemini Batch API.""" + + def __init__(self, config: GeminiProviderConfig, client: Any | None = None) -> None: + self.config = config + self._client = client + + @property + def client(self) -> Any: + """Return the SDK client, creating it lazily for optional dependency support.""" + if self._client is None: + self._client = self._build_default_client() + return self._client + + def _build_default_client(self) -> Any: + try: + genai = import_module("google.genai") + except ImportError as exc: + raise ImportError('Gemini provider requires the "gemini" extra; install with `batchor[gemini]`.') from exc + + return genai.Client(api_key=resolve_gemini_api_key(self.config)) + + def build_request_line( + self, + *, + custom_id: str, + prompt_parts: PromptParts, + structured_output: StructuredOutputSchema | None = None, + ) -> BatchRequestLine: + """Build one Gemini batch JSONL request row for a logical item.""" + request: JSONObject = { + "contents": [ + { + "parts": [ + { + "text": prompt_parts.prompt, + } + ] + } + ] + } + if prompt_parts.system_prompt: + request["system_instruction"] = { + "parts": [ + { + "text": prompt_parts.system_prompt, + } + ] + } + generation_config = self._generation_config(structured_output) + if generation_config: + request["generation_config"] = generation_config + return { + "key": custom_id, + "request": request, + } + + def _generation_config( + self, + structured_output: StructuredOutputSchema | None, + ) -> JSONObject: + generation_config = cast(JSONObject, json.loads(json.dumps(self.config.generation_config))) + if structured_output is not None: + generation_config["response_mime_type"] = "application/json" + generation_config["response_json_schema"] = structured_output.schema + return generation_config + + def request_correlation_id(self, request_line: BatchRequestLine) -> str: + key = request_line.get("key") + if not isinstance(key, str) or not key: + raise ValueError("Gemini request line is missing key") + return key + + def with_request_correlation_id( + self, + request_line: BatchRequestLine, + custom_id: str, + ) -> BatchRequestLine: + updated = dict(request_line) + updated["key"] = custom_id + return cast(BatchRequestLine, updated) + + def upload_input_file(self, input_path: str | Path) -> str: + """Upload a prepared local JSONL file to Gemini and return the file name.""" + try: + genai = import_module("google.genai") + types = genai.types + + upload_config: object = types.UploadFileConfig( + display_name=Path(input_path).name, + mime_type="jsonl", + ) + except (AttributeError, ImportError): + upload_config = { + "display_name": Path(input_path).name, + "mime_type": "jsonl", + } + + uploaded = self.client.files.upload( + file=Path(input_path).as_posix(), + config=upload_config, + ) + return str(uploaded.name) + + def delete_input_file(self, file_id: str) -> None: + """Best-effort deletion of an uploaded input file.""" + self.client.files.delete(name=file_id) + + def create_batch( + self, + *, + input_file_id: str, + metadata: dict[str, str] | None = None, + ) -> BatchRemoteRecord: + """Create a Gemini batch from a previously uploaded input file.""" + display_name = self._display_name(metadata) + batch = self.client.batches.create( + model=self.config.model, + src=input_file_id, + config={"display_name": display_name}, + ) + return self._normalize(batch) + + def _display_name(self, metadata: dict[str, str] | None) -> str: + run_id = (metadata or {}).get("run_id") + if run_id: + return f"{self.config.display_name_prefix}-{run_id}" + return self.config.display_name_prefix + + def get_batch(self, batch_id: str) -> BatchRemoteRecord: + """Fetch the current remote state for one Gemini batch.""" + batch = self.client.batches.get(name=batch_id) + return self._normalize(batch) + + def download_file_content(self, file_id: str) -> str: + """Download a Gemini file and normalize it to text.""" + content = self.client.files.download(file=file_id) + if isinstance(content, str): + return content + if isinstance(content, (bytes, bytearray)): + return content.decode("utf-8") + if hasattr(content, "text"): + return str(content.text) + if hasattr(content, "read"): + raw = content.read() + return raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw) + return str(content) + + @staticmethod + def parse_jsonl(content: str) -> list[JSONObject]: + """Parse provider JSONL payloads into JSON objects.""" + records: list[JSONObject] = [] + for raw_line in content.splitlines(): + line = raw_line.strip() + if not line: + continue + record = json.loads(line) + if not isinstance(record, dict): + raise ValueError("batch jsonl records must be JSON objects") + records.append(cast(JSONObject, record)) + return records + + def parse_batch_output( + self, + *, + output_content: str | None, + error_content: str | None, + ) -> tuple[dict[str, JSONObject], dict[str, JSONObject], list[JSONObject]]: + """Split Gemini output/error payloads into success and error maps.""" + raw_records: list[JSONObject] = [] + success: dict[str, JSONObject] = {} + errors: dict[str, JSONObject] = {} + + for record in self.parse_jsonl(output_content or "") + self.parse_jsonl(error_content or ""): + raw_records.append(record) + key = record.get("key") + if not isinstance(key, str) or not key: + continue + if "error" in record or not isinstance(record.get("response"), dict): + errors[key] = record + else: + success[key] = record + + return success, errors, raw_records + + def extract_response_text(self, response_record: JSONObject) -> str: + """Extract concatenated text output from a Gemini response record.""" + response = response_record.get("response") + if not isinstance(response, dict): + return "" + direct_text = response.get("text") + if isinstance(direct_text, str): + return direct_text + fragments: list[str] = [] + candidates = response.get("candidates") + if isinstance(candidates, list): + for candidate in candidates: + if not isinstance(candidate, dict): + continue + content = candidate.get("content") + if not isinstance(content, dict): + continue + parts = content.get("parts") + if not isinstance(parts, list): + continue + for part in parts: + if not isinstance(part, dict): + continue + text = part.get("text") + if isinstance(text, str): + fragments.append(text) + return "\n".join(fragment for fragment in fragments if fragment) + + def estimate_request_tokens( + self, + request_line: BatchRequestLine, + *, + chars_per_token: int, + ) -> int: + """Estimate submitted tokens for one provider request line.""" + return estimate_request_tokens( + request_line, + chars_per_token=chars_per_token, + model=self.config.model, + ) + + @staticmethod + def _normalize(obj: Any) -> BatchRemoteRecord: + """Normalise a Gemini SDK response object to a plain dict.""" + payload = _object_to_dict(obj) + batch_id = _string_value(payload, "name") or _string_value(payload, "id") + state = _state_name(payload.get("state")) + output_file_id = _nested_string(payload, ("dest", "file_name")) or _nested_string( + payload, + ("output", "responses_file"), + ) + errors = payload.get("error") or payload.get("errors") + return BatchRemoteRecord( + id=batch_id or "", + status=_normalize_status(state), + output_file_id=output_file_id, + error_file_id=None, + errors=cast(JSONObject, errors) if isinstance(errors, dict) else errors, + ) + + +def _object_to_dict(obj: Any) -> dict[str, Any]: + if isinstance(obj, dict): + return dict(obj) + if hasattr(obj, "model_dump"): + return dict(obj.model_dump()) + if hasattr(obj, "__dict__"): + return dict(obj.__dict__) + raise TypeError(f"cannot normalize object {type(obj)!r}") + + +def _string_value(payload: dict[str, Any], key: str) -> str | None: + value = payload.get(key) + return value if isinstance(value, str) and value else None + + +def _nested_string(payload: dict[str, Any], path: tuple[str, str]) -> str | None: + parent = payload.get(path[0]) + if not isinstance(parent, dict) and hasattr(parent, "__dict__"): + parent = parent.__dict__ + if not isinstance(parent, dict): + return None + value = parent.get(path[1]) + return value if isinstance(value, str) and value else None + + +def _state_name(value: Any) -> str: + name = getattr(value, "name", None) + if isinstance(name, str): + return name + if isinstance(value, str): + return value + return str(value) if value is not None else "" + + +def _normalize_status(state: str) -> str: + normalized = state.upper() + if normalized in {"JOB_STATE_SUCCEEDED", "JOB_STATE_PARTIALLY_SUCCEEDED", "BATCH_STATE_SUCCEEDED", "SUCCEEDED"}: + return "completed" + if normalized in {"JOB_STATE_FAILED", "BATCH_STATE_FAILED", "FAILED"}: + return "failed" + if normalized in {"JOB_STATE_CANCELLED", "BATCH_STATE_CANCELLED", "CANCELLED"}: + return "cancelled" + if normalized in {"JOB_STATE_EXPIRED", "BATCH_STATE_EXPIRED", "EXPIRED"}: + return "expired" + return "submitted" diff --git a/src/batchor/providers/registry.py b/src/batchor/providers/registry.py index afe8601..4d9aef7 100644 --- a/src/batchor/providers/registry.py +++ b/src/batchor/providers/registry.py @@ -16,7 +16,7 @@ from batchor.providers.base import BatchProvider, ProviderConfig if TYPE_CHECKING: - from batchor.core.models import OpenAIProviderConfig + from batchor.core.models import GeminiProviderConfig, OpenAIProviderConfig type ProviderFactory = Callable[[ProviderConfig], BatchProvider] type ProviderConfigLoader = Callable[[JSONObject], ProviderConfig] @@ -26,8 +26,8 @@ class ProviderRegistry: """Dispatch table mapping provider kinds to factory and loader callables. Use :func:`build_default_provider_registry` to obtain a registry pre-loaded - with the built-in OpenAI provider, or construct one manually to register - custom providers. + with the built-in providers, or construct one manually to register custom + providers. """ def __init__(self) -> None: @@ -128,13 +128,13 @@ def load_config(self, payload: JSONObject) -> ProviderConfig: def build_default_provider_registry() -> ProviderRegistry: - """Create a :class:`ProviderRegistry` pre-loaded with the OpenAI provider. + """Create a registry pre-loaded with built-in providers. Returns: - A :class:`ProviderRegistry` with :attr:`~batchor.ProviderKind.OPENAI` - registered. + A :class:`ProviderRegistry` with OpenAI and Gemini registered. """ - from batchor.core.models import OpenAIProviderConfig + from batchor.core.models import GeminiProviderConfig, OpenAIProviderConfig + from batchor.providers.gemini import GeminiBatchProvider from batchor.providers.openai import OpenAIBatchProvider registry = ProviderRegistry() @@ -143,6 +143,11 @@ def build_default_provider_registry() -> ProviderRegistry: factory=lambda config: OpenAIBatchProvider(_require_openai_config(config)), loader=OpenAIProviderConfig.from_payload, ) + registry.register( + kind=ProviderKind.GEMINI, + factory=lambda config: GeminiBatchProvider(_require_gemini_config(config)), + loader=GeminiProviderConfig.from_payload, + ) return registry @@ -152,3 +157,11 @@ def _require_openai_config(config: ProviderConfig) -> OpenAIProviderConfig: if not isinstance(config, OpenAIProviderConfig): raise TypeError(f"expected {ProviderKind.OPENAI.value} config, got {type(config).__name__}") return config + + +def _require_gemini_config(config: ProviderConfig) -> GeminiProviderConfig: + from batchor.core.models import GeminiProviderConfig + + if not isinstance(config, GeminiProviderConfig): + raise TypeError(f"expected {ProviderKind.GEMINI.value} config, got {type(config).__name__}") + return config diff --git a/src/batchor/runtime/polling.py b/src/batchor/runtime/polling.py index 48fab36..95fa47d 100644 --- a/src/batchor/runtime/polling.py +++ b/src/batchor/runtime/polling.py @@ -315,7 +315,7 @@ def consume_completed_batch( completions.append( CompletedItemRecord( custom_id=custom_id, - output_text=parse_text_response(record), + output_text=extract_provider_response_text(context.provider, record), raw_response=record, ) ) @@ -324,6 +324,10 @@ def consume_completed_batch( output_text, parsed_json, _validated = parse_structured_response( record, context.output_model, + text_extractor=lambda response_record: extract_provider_response_text( + context.provider, + response_record, + ), ) except Exception as exc: # noqa: BLE001 failures.append( @@ -405,6 +409,14 @@ def consume_completed_batch( ) +def extract_provider_response_text(provider: object, response_record: JSONObject) -> str: + """Extract text through a provider hook, falling back to OpenAI-compatible parsing.""" + extractor = getattr(provider, "extract_response_text", None) + if callable(extractor): + return str(extractor(response_record)) + return parse_text_response(response_record) + + def item_failure( *, error_class: str, diff --git a/src/batchor/runtime/submission.py b/src/batchor/runtime/submission.py index 998d7e0..6565d52 100644 --- a/src/batchor/runtime/submission.py +++ b/src/batchor/runtime/submission.py @@ -319,14 +319,20 @@ def prepare_claimed_item( if item.request_artifact_path is not None: if item.request_artifact_line is None or item.request_sha256 is None: raise ValueError(f"incomplete request artifact pointer for item {item.item_id}") - request_line = load_request_artifact_line( - artifact_store=artifact_store, - artifact_path=item.request_artifact_path, - line_number=item.request_artifact_line, - expected_sha256=item.request_sha256, - artifact_cache=artifact_cache, + request_line = with_provider_request_correlation_id( + context.provider, + cast( + BatchRequestLine, + load_request_artifact_line( + artifact_store=artifact_store, + artifact_path=item.request_artifact_path, + line_number=item.request_artifact_line, + expected_sha256=item.request_sha256, + artifact_cache=artifact_cache, + ), + ), + custom_id, ) - request_line["custom_id"] = custom_id else: request_line = context.provider.build_request_line( custom_id=custom_id, @@ -336,7 +342,6 @@ def prepare_claimed_item( ), structured_output=context.structured_output, ) - request_line = cast(BatchRequestLine, request_line) request_bytes = len((json.dumps(request_line, ensure_ascii=False) + "\n").encode("utf-8")) submission_tokens = context.provider.estimate_request_tokens( request_line, @@ -344,7 +349,7 @@ def prepare_claimed_item( ) return PreparedRequest( item_id=item.item_id, - custom_id=str(request_line["custom_id"]), + custom_id=provider_request_correlation_id(context.provider, request_line), request_line=cast(JSONObject, request_line), request_bytes=request_bytes, submission_tokens=submission_tokens, @@ -369,6 +374,31 @@ def prepared_request_row(item: PreparedRequest) -> dict[str, Any]: } +def provider_request_correlation_id(provider: object, request_line: BatchRequestLine) -> str: + """Return provider-facing request correlation id with OpenAI-compatible fallback.""" + getter = getattr(provider, "request_correlation_id", None) + if callable(getter): + return str(getter(request_line)) + custom_id = request_line.get("custom_id") + if not isinstance(custom_id, str) or not custom_id: + raise ValueError("request line is missing custom_id") + return custom_id + + +def with_provider_request_correlation_id( + provider: object, + request_line: BatchRequestLine, + custom_id: str, +) -> BatchRequestLine: + """Return request line with the provider-facing correlation id replaced.""" + replacer = getattr(provider, "with_request_correlation_id", None) + if callable(replacer): + return cast(BatchRequestLine, replacer(request_line, custom_id)) + updated = dict(request_line) + updated["custom_id"] = custom_id + return cast(BatchRequestLine, updated) + + def make_custom_id(item_id: str, attempt: int) -> str: """Return the stable provider custom ID for an item attempt. diff --git a/src/batchor/runtime/validation.py b/src/batchor/runtime/validation.py index 50861de..89076b8 100644 --- a/src/batchor/runtime/validation.py +++ b/src/batchor/runtime/validation.py @@ -15,7 +15,7 @@ import json import re -from typing import Any, cast +from typing import Any, Callable, cast from pydantic import BaseModel, ValidationError @@ -331,6 +331,8 @@ def parse_text_response(response_record: JSONObject) -> str: def parse_structured_response( response_record: JSONObject, output_model: type[BaseModel], + *, + text_extractor: Callable[[JSONObject], str] | None = None, ) -> tuple[str, JSONValue, BaseModel]: """Parse and validate a structured JSON response from the provider. @@ -347,7 +349,7 @@ def parse_structured_response( StructuredOutputError: If the response text is empty, is not valid JSON, or fails Pydantic validation. """ - text = extract_response_text(response_record) + text = text_extractor(response_record) if text_extractor is not None else extract_response_text(response_record) if not text: raise StructuredOutputError( "empty_response_text", diff --git a/tests/integration/test_batchor_gemini_runner.py b/tests/integration/test_batchor_gemini_runner.py new file mode 100644 index 0000000..5050f04 --- /dev/null +++ b/tests/integration/test_batchor_gemini_runner.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Callable + +from pydantic import BaseModel + +from batchor import ( + BatchItem, + BatchJob, + BatchRunner, + GeminiProviderConfig, + MemoryStateStore, + PromptParts, +) +from batchor.providers.gemini import GeminiBatchProvider + + +class ClassificationResult(BaseModel): + label: str + score: float + + +class _Uploaded: + def __init__(self, name: str) -> None: + self.name = name + + +class _State: + def __init__(self, name: str) -> None: + self.name = name + + +class _Dest: + def __init__(self, file_name: str | None = None) -> None: + self.file_name = file_name + + +class _BatchJob: + def __init__(self, *, name: str, state: str, file_name: str | None = None) -> None: + self.name = name + self.state = _State(state) + self.dest = _Dest(file_name) + + +class _FakeGeminiFiles: + def __init__(self, response_builder: Callable[[dict[str, object]], str]) -> None: + self.response_builder = response_builder + self._next_file = 0 + self.input_lines_by_file: dict[str, list[dict[str, object]]] = {} + self.batch_file_by_output_file: dict[str, str] = {} + + def upload(self, *, file: str, config: object) -> _Uploaded: + del config + file_name = f"files/input_{self._next_file}" + self._next_file += 1 + self.input_lines_by_file[file_name] = [ + json.loads(raw_line) for raw_line in Path(file).read_text(encoding="utf-8").splitlines() if raw_line + ] + return _Uploaded(file_name) + + def download(self, *, file: str) -> bytes: + input_file = self.batch_file_by_output_file[file] + records = [self.response_builder(line) for line in self.input_lines_by_file[input_file]] + return ("\n".join(records) + "\n").encode() + + def delete(self, *, name: str) -> None: + del name + + +class _FakeGeminiBatches: + def __init__(self, files: _FakeGeminiFiles) -> None: + self.files = files + self._next_batch = 0 + self.batch_to_input: dict[str, str] = {} + + def create(self, *, model: str, src: str, config: dict[str, object]) -> _BatchJob: + del model, config + batch_id = f"batches/{self._next_batch}" + self._next_batch += 1 + self.batch_to_input[batch_id] = src + self.files.batch_file_by_output_file[f"files/output_{self._next_batch - 1}"] = src + return _BatchJob(name=batch_id, state="JOB_STATE_PENDING") + + def get(self, *, name: str) -> _BatchJob: + assert name in self.batch_to_input + index = name.rsplit("/", maxsplit=1)[1] + return _BatchJob( + name=name, + state="JOB_STATE_SUCCEEDED", + file_name=f"files/output_{index}", + ) + + +class _FakeGeminiClient: + def __init__(self, response_builder: Callable[[dict[str, object]], str]) -> None: + self.files = _FakeGeminiFiles(response_builder) + self.batches = _FakeGeminiBatches(self.files) + + +def _line_key(line: dict[str, object]) -> str: + key = line["key"] + assert isinstance(key, str) + return key + + +def _prompt(line: dict[str, object]) -> str: + request = line["request"] + assert isinstance(request, dict) + contents = request["contents"] + assert isinstance(contents, list) + first = contents[0] + assert isinstance(first, dict) + parts = first["parts"] + assert isinstance(parts, list) + first_part = parts[0] + assert isinstance(first_part, dict) + text = first_part["text"] + assert isinstance(text, str) + return text + + +def test_batch_runner_completes_text_job_with_gemini_provider() -> None: + def response_builder(line: dict[str, object]) -> str: + return json.dumps( + { + "key": _line_key(line), + "response": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": f"seen: {_prompt(line)}", + } + ] + } + } + ] + }, + } + ) + + client = _FakeGeminiClient(response_builder) + runner = BatchRunner( + storage=MemoryStateStore(), + provider_factory=lambda cfg: GeminiBatchProvider(cfg, client=client), + ) + + run = runner.run_and_wait( + BatchJob( + items=[BatchItem(item_id="row1", payload={"text": "hello"})], + build_prompt=lambda item: PromptParts(prompt=item.payload["text"]), + provider_config=GeminiProviderConfig(api_key="k", model="gemini-2.5-flash"), + ) + ) + + result = run.results()[0] + assert result.output_text == "seen: hello" + assert result.raw_response is not None + assert result.raw_response["key"] == "row1:a1" + + +def test_batch_runner_completes_structured_job_with_gemini_provider() -> None: + def response_builder(line: dict[str, object]) -> str: + return json.dumps( + { + "key": _line_key(line), + "response": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": '{"label":"gemini","score":0.8}', + } + ] + } + } + ] + }, + } + ) + + client = _FakeGeminiClient(response_builder) + runner = BatchRunner( + storage=MemoryStateStore(), + provider_factory=lambda cfg: GeminiBatchProvider(cfg, client=client), + ) + + run = runner.run_and_wait( + BatchJob( + items=[BatchItem(item_id="row1", payload={"text": "classify"})], + build_prompt=lambda item: PromptParts(prompt=item.payload["text"]), + provider_config=GeminiProviderConfig(api_key="k", model="gemini-2.5-flash"), + structured_output=ClassificationResult, + ) + ) + + result = run.results()[0] + assert result.output is not None + assert result.output.label == "gemini" + submitted_line = client.files.input_lines_by_file["files/input_0"][0] + request = submitted_line["request"] + assert isinstance(request, dict) + generation_config = request["generation_config"] + assert isinstance(generation_config, dict) + assert generation_config["response_mime_type"] == "application/json" + assert "response_json_schema" in generation_config diff --git a/tests/unit/test_batchor_architecture.py b/tests/unit/test_batchor_architecture.py index e3d8bea..f3dd571 100644 --- a/tests/unit/test_batchor_architecture.py +++ b/tests/unit/test_batchor_architecture.py @@ -4,6 +4,8 @@ from pathlib import Path from batchor import ( + GeminiBatchProvider, + GeminiProviderConfig, MemoryStateStore, OpenAIBatchProvider, OpenAIEnqueueLimitConfig, @@ -57,6 +59,35 @@ def test_default_provider_registry_can_dump_secretless_openai_config() -> None: assert "api_key" not in payload["config"] +def test_default_provider_registry_round_trips_gemini_config() -> None: + registry = build_default_provider_registry() + config = GeminiProviderConfig( + api_key="k", + model="gemini-2.5-flash", + generation_config={"temperature": 0.1}, + ) + + payload = registry.dump_config(config) + loaded = registry.load_config(payload) + provider = registry.create(loaded) + + assert loaded.provider_kind is ProviderKind.GEMINI + assert isinstance(loaded, GeminiProviderConfig) + assert loaded == config + assert isinstance(provider, GeminiBatchProvider) + + +def test_default_provider_registry_can_dump_secretless_gemini_config() -> None: + registry = build_default_provider_registry() + config = GeminiProviderConfig(api_key="secret", model="gemini-2.5-flash") + + payload = registry.dump_config(config, include_secrets=False) + + assert payload["provider_kind"] == ProviderKind.GEMINI.value + assert payload["config"]["model"] == "gemini-2.5-flash" + assert "api_key" not in payload["config"] + + def test_storage_registry_supports_explicit_backend_factories(tmp_path: Path) -> None: sqlite_path = tmp_path / "registry.sqlite3" provider_registry = build_default_provider_registry() diff --git a/tests/unit/test_batchor_gemini_provider.py b/tests/unit/test_batchor_gemini_provider.py new file mode 100644 index 0000000..bd8fe55 --- /dev/null +++ b/tests/unit/test_batchor_gemini_provider.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from pydantic import BaseModel + +from batchor.core.models import GeminiProviderConfig, PromptParts +from batchor.providers.base import StructuredOutputSchema +from batchor.providers.gemini import GeminiBatchProvider, resolve_gemini_api_key +from batchor.runtime.validation import model_output_schema + + +class _ClassificationResult(BaseModel): + label: str + score: float + + +class _State: + def __init__(self, name: str) -> None: + self.name = name + + +class _Dest: + def __init__(self, file_name: str | None = None) -> None: + self.file_name = file_name + + +class _Job: + def __init__( + self, + *, + name: str, + state: str, + file_name: str | None = None, + ) -> None: + self.name = name + self.state = _State(state) + self.dest = _Dest(file_name) + + +class _Uploaded: + def __init__(self, name: str) -> None: + self.name = name + + +class _FakeFiles: + def __init__(self) -> None: + self.uploaded: list[tuple[str, object]] = [] + self.deleted: list[str] = [] + + def upload(self, *, file: str, config: object) -> _Uploaded: + Path(file).read_text(encoding="utf-8") + self.uploaded.append((file, config)) + return _Uploaded("files/input_jsonl") + + def download(self, *, file: str) -> bytes: + assert file == "files/output_jsonl" + return b'{"key":"row1:a1","response":{"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}}\n' + + def delete(self, *, name: str) -> None: + self.deleted.append(name) + + +class _FakeBatches: + def __init__(self) -> None: + self.created: list[dict[str, object]] = [] + + def create(self, **kwargs: object) -> _Job: + self.created.append(dict(kwargs)) + return _Job(name="batches/123", state="JOB_STATE_PENDING") + + def get(self, *, name: str) -> _Job: + assert name == "batches/123" + return _Job( + name="batches/123", + state="JOB_STATE_SUCCEEDED", + file_name="files/output_jsonl", + ) + + +class _FakeClient: + def __init__(self) -> None: + self.files = _FakeFiles() + self.batches = _FakeBatches() + + +def test_build_request_line_for_text_job() -> None: + provider = GeminiBatchProvider( + GeminiProviderConfig( + api_key="k", + model="gemini-2.5-flash", + generation_config={"temperature": 0.1}, + ), + client=_FakeClient(), + ) + + line = provider.build_request_line( + custom_id="row1:a1", + prompt_parts=PromptParts(prompt="hello", system_prompt="classify"), + ) + + assert line["key"] == "row1:a1" + request = line["request"] + assert request["contents"][0]["parts"][0]["text"] == "hello" + assert request["system_instruction"]["parts"][0]["text"] == "classify" + assert request["generation_config"]["temperature"] == 0.1 + + +def test_build_request_line_for_structured_output() -> None: + schema_name, schema = model_output_schema(_ClassificationResult) + provider = GeminiBatchProvider( + GeminiProviderConfig(api_key="k", model="gemini-2.5-flash"), + client=_FakeClient(), + ) + + line = provider.build_request_line( + custom_id="row1:a1", + prompt_parts=PromptParts(prompt="hello"), + structured_output=StructuredOutputSchema(schema_name, schema), + ) + + generation_config = line["request"]["generation_config"] + assert generation_config["response_mime_type"] == "application/json" + assert generation_config["response_json_schema"]["properties"]["label"]["type"] == "string" + + +def test_request_correlation_id_uses_gemini_key() -> None: + provider = GeminiBatchProvider( + GeminiProviderConfig(api_key="k", model="gemini-2.5-flash"), + client=_FakeClient(), + ) + line = provider.with_request_correlation_id( + {"key": "old", "request": {"contents": []}}, + "new", + ) + + assert line["key"] == "new" + assert provider.request_correlation_id(line) == "new" + + +def test_parse_batch_output_and_extract_response_text() -> None: + provider = GeminiBatchProvider( + GeminiProviderConfig(api_key="k", model="gemini-2.5-flash"), + client=_FakeClient(), + ) + + success, errors, raw = provider.parse_batch_output( + output_content=( + '{"key":"ok","response":{"candidates":[{"content":{"parts":[{"text":"hello"},{"text":"world"}]}}]}}\n' + '{"key":"bad","error":{"message":"nope"}}\n' + ), + error_content=None, + ) + + assert set(success) == {"ok"} + assert set(errors) == {"bad"} + assert len(raw) == 2 + assert provider.extract_response_text(success["ok"]) == "hello\nworld" + + +def test_upload_create_get_download_and_delete() -> None: + fake = _FakeClient() + provider = GeminiBatchProvider( + GeminiProviderConfig(api_key="k", model="gemini-2.5-flash"), + client=fake, + ) + + uploaded = provider.upload_input_file(Path(__file__)) + created = provider.create_batch(input_file_id=uploaded, metadata={"run_id": "run1"}) + fetched = provider.get_batch("batches/123") + + assert uploaded == "files/input_jsonl" + assert created["id"] == "batches/123" + assert created["status"] == "submitted" + assert fake.batches.created[0]["src"] == "files/input_jsonl" + assert fake.batches.created[0]["config"] == {"display_name": "batchor-run1"} + assert fetched["status"] == "completed" + assert fetched["output_file_id"] == "files/output_jsonl" + assert provider.download_file_content("files/output_jsonl").startswith('{"key"') + provider.delete_input_file(uploaded) + assert fake.files.deleted == ["files/input_jsonl"] + + +def test_resolve_gemini_api_key_prefers_explicit_value(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GEMINI_API_KEY", "env-key") + resolved = resolve_gemini_api_key(GeminiProviderConfig(api_key="explicit-key", model="gemini-2.5-flash")) + assert resolved == "explicit-key" + + +def test_resolve_gemini_api_key_falls_back_to_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GEMINI_API_KEY", "env-key") + resolved = resolve_gemini_api_key(GeminiProviderConfig(model="gemini-2.5-flash")) + assert resolved == "env-key" + + +def test_resolve_gemini_api_key_requires_explicit_or_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + with pytest.raises(ValueError, match="Gemini API key is required"): + resolve_gemini_api_key(GeminiProviderConfig(model="gemini-2.5-flash")) diff --git a/tests/unit/test_batchor_runtime_submission.py b/tests/unit/test_batchor_runtime_submission.py index 1368f53..2c67d20 100644 --- a/tests/unit/test_batchor_runtime_submission.py +++ b/tests/unit/test_batchor_runtime_submission.py @@ -7,6 +7,7 @@ BatchItem, BatchJob, ChunkPolicy, + GeminiProviderConfig, ItemStatus, LocalArtifactStore, MemoryStateStore, @@ -17,8 +18,8 @@ ) from batchor.runtime.artifacts import request_sha256 from batchor.runtime.context import build_persisted_config, build_run_context -from batchor.runtime.submission import SubmissionDeps, submit_pending_items -from batchor.storage.state import MaterializedItem, RequestArtifactPointer +from batchor.runtime.submission import SubmissionDeps, prepare_claimed_item, submit_pending_items +from batchor.storage.state import ClaimedItem, MaterializedItem, RequestArtifactPointer class _FakeSubmissionProvider: @@ -87,6 +88,35 @@ def estimate_request_tokens( return max(len(prompt), 1) +class _GeminiLikeReplayProvider: + def build_request_line(self, **kwargs): # noqa: ANN003 + del kwargs + raise AssertionError("request line should be replayed from a persisted artifact") + + def with_request_correlation_id( + self, + request_line: dict[str, object], + custom_id: str, + ) -> dict[str, object]: + updated = dict(request_line) + updated["key"] = custom_id + return updated + + def request_correlation_id(self, request_line: dict[str, object]) -> str: + key = request_line["key"] + assert isinstance(key, str) + return key + + def estimate_request_tokens( + self, + request_line: dict[str, object], + *, + chars_per_token: int, + ) -> int: + del request_line, chars_per_token + return 1 + + def _materialized_text_item(item_id: str, item_index: int, text: str) -> MaterializedItem: return MaterializedItem( item_id=item_id, @@ -97,6 +127,60 @@ def _materialized_text_item(item_id: str, item_index: int, text: str) -> Materia ) +def test_prepare_claimed_item_replays_provider_specific_correlation_key(tmp_path: Path) -> None: + provider = _GeminiLikeReplayProvider() + artifact_store = LocalArtifactStore(tmp_path / "artifacts") + request_line = { + "key": "row1:a1", + "request": { + "contents": [ + { + "parts": [ + { + "text": "hello", + } + ] + } + ] + }, + } + artifact_path = "run/requests/request.jsonl" + artifact_store.write_text( + artifact_path, + json.dumps(request_line) + "\n", + encoding="utf-8", + ) + job = BatchJob( + items=[BatchItem(item_id="row1", payload={"text": "hello"})], + build_prompt=lambda item: PromptParts(prompt=item.payload["text"]), + provider_config=GeminiProviderConfig(api_key="k", model="gemini-2.5-flash"), + ) + config = build_persisted_config(job) + context = build_run_context( + config=config, + output_model=None, + create_provider=lambda _cfg: provider, + ) + + prepared = prepare_claimed_item( + ClaimedItem( + item_id="row1", + metadata={}, + prompt="", + system_prompt=None, + attempt_count=1, + request_artifact_path=artifact_path, + request_artifact_line=1, + request_sha256=request_sha256(request_line), + ), + context=context, + artifact_store=artifact_store, + ) + + assert prepared.custom_id == "row1:a2" + assert prepared.request_line["key"] == "row1:a2" + + def test_submit_pending_items_replays_shared_request_artifact_once_per_cycle(tmp_path: Path) -> None: provider = _FakeSubmissionProvider(assert_no_build=True) storage = MemoryStateStore() diff --git a/uv.lock b/uv.lock index 2ded435..599a431 100644 --- a/uv.lock +++ b/uv.lock @@ -71,6 +71,11 @@ dependencies = [ { name = "typer" }, ] +[package.optional-dependencies] +gemini = [ + { name = "google-genai" }, +] + [package.dev-dependencies] dev = [ { name = "pytest" }, @@ -87,6 +92,7 @@ docs = [ [package.metadata] requires-dist = [ + { name = "google-genai", marker = "extra == 'gemini'", specifier = ">=1.55.0,<2" }, { name = "openai", specifier = ">=2.7.1" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0,<4" }, { name = "pyarrow", specifier = ">=20.0.0,<30" }, @@ -96,6 +102,7 @@ requires-dist = [ { name = "tiktoken", specifier = ">=0.12.0" }, { name = "typer", specifier = ">=0.16.0,<1" }, ] +provides-extras = ["gemini"] [package.metadata.requires-dev] dev = [ @@ -120,6 +127,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.6" @@ -298,6 +362,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +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/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { 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/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { 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/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { 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/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -307,6 +424,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] + [[package]] name = "execnet" version = "2.1.2" @@ -328,6 +446,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "google-auth" +version = "2.53.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/ad/ff781329bbbdc0974a098d996e89c9e1f7024262f9e3eec442fbb9ad1ac6/google_auth-2.53.0.tar.gz", hash = "sha256:e7e6aa16f6bee7b2b264830fd04f08087a1d5a836df516251a5d15327b246c9c", size = 335844, upload-time = "2026-05-15T20:53:07.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/c9/db44165ba7c581268c6d46017ef63339110378305062830104fc7fa144cb/google_auth-2.53.0-py3-none-any.whl", hash = "sha256:6e7449917c599b35126a99ec268ec6880301f2fea41dce198fe8fd83ff642b68", size = 246071, upload-time = "2026-05-15T20:53:05.609Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "google-genai" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/59/3ed61240ef20b3ae6ed54e82c6f8b6d1f194947bc6679679dd6cdb037594/google_genai-1.75.0.tar.gz", hash = "sha256:56bac3991b311c93f980c0a2abcd287b672146905df1fbd71c92ed633d5a07cf", size = 539039, upload-time = "2026-05-04T22:48:54.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/b6/552d40e96da22921eb1fead7c14b00b5b5473a20e45959488660fab35ee2/google_genai-1.75.0-py3-none-any.whl", hash = "sha256:8dc4c096e7d6288c3087f6893f582fe52468932464781edb8193bd92b9fefb2c", size = 793726, upload-time = "2026-05-04T22:48:53.033Z" }, +] + [[package]] name = "greenlet" version = "3.3.2" @@ -897,6 +1054,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -1341,6 +1528,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + [[package]] name = "tiktoken" version = "0.12.0" @@ -1501,3 +1697,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +]