diff --git a/.github/assets/logo/dark.svg b/.github/assets/logo/dark.svg new file mode 100644 index 0000000..b2541ff --- /dev/null +++ b/.github/assets/logo/dark.svg @@ -0,0 +1,3 @@ + diff --git a/.github/assets/logo/light.svg b/.github/assets/logo/light.svg new file mode 100644 index 0000000..4555cf0 --- /dev/null +++ b/.github/assets/logo/light.svg @@ -0,0 +1,3 @@ + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..06e9d94 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: + - main + - feature/batch-api/rc + +permissions: + contents: read + +jobs: + check: + name: "Lint, typecheck, and test" + runs-on: ubuntu-latest + steps: + - name: "Fetch source code" + uses: actions/checkout@v4 + + - name: "Install uv" + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: "Install dependencies" + run: uv sync --all-extras + + - name: "Lint, format check, and typecheck" + run: make check + + - name: "Unit tests" + run: make test diff --git a/.gitignore b/.gitignore index a21eff9..478db78 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,16 @@ pip-delete-this-directory.txt .idea/* .vscode/ + +# uv / venv +.venv/ +.uv-cache/ + +# Test + ruff caches +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Local scratch +.DS_Store diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c50b2ac..89c9b72 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,29 +1,80 @@ -## Development +# Development -Useful commands for development and publishing. +This project uses [uv](https://docs.astral.sh/uv/) for env + dependencies, +[ruff](https://docs.astral.sh/ruff/) for lint + format, and +[ty](https://docs.astral.sh/ty/) for static type checking. Source lives +under `src/zenrows/`; tests under `tests/`. -### Install +## First-time setup -`make install` will install the dependencies from the `requirements.txt` file. +```bash +uv sync --all-extras +``` -### Build +Creates `.venv/` and installs everything in `pyproject.toml`, including +dev tools. After that, prefix commands with `uv run …` or use the +Makefile targets below. -`make build` generates the distribution packages. It will not delete previous builds. Remember to change the `__version__` before publishing or it will fail. +## Layout -### Clean +``` +src/zenrows/ +├── __init__.py # re-exports both clients +├── client.py # ZenRowsClient (legacy sync scraper) +└── batch/ + ├── __init__.py # ZenRowsBatchClient + key models + ├── client.py # hand-written typed facade + ├── _transport.py # httpx wrapper, RFC 7807 → exceptions + ├── errors.py # BatchAPIError, ProblemDetail + └── models.py # GENERATED — pydantic v2 (do not edit) +``` -`make clean` removes previous builds and cache files. +The Batch SDK is split deliberately: -### Lint +| File | Owner | Regenerate? | +|----------------|-------------------|-------------------| +| `models.py` | datamodel-codegen | `make generate` | +| `client.py` | hand-written | never auto | +| `_transport.py`| hand-written | never auto | +| `errors.py` | hand-written | never auto | -`make lint` runs the linter (`flake8`) on the source and test files. +This way the wire types stay in lockstep with the OpenAPI document +while the ergonomic surface (method names, helpers, retries, URL +override) stays in our control. -### Test +## Common tasks -`make test` runs all the tests. +| Make target | What it does | +|-----------------|--------------| +| `make sync` | `uv sync --all-extras` | +| `make test` | `uv run pytest` | +| `make check` | `ty check` + `ruff check` + `ruff format --check` (CI mode) | +| `make typecheck`| `ty check src` (static types; `models.py` excluded) | +| `make lint` | `ruff check --fix` | +| `make format` | `ruff format` | +| `make generate` | Re-emit `src/zenrows/batch/models.py` from `docs/openapi.yaml` | +| `make build` | Build wheel + sdist via hatchling | +| `make clean` | Drop caches + build outputs | -### Upload to PyPI +## Refreshing the OpenAPI spec -`python -m twine upload dist/*` uploads the latest build to PyPI. It will upload the whole `dist` folder, failing if there was a previous version. Run the `clean` command on those cases. Upload attempts of existing versions will fail with a `File already exists` error. +`docs/openapi.yaml` is the SDK-local copy of the spec. `make generate` +reads it to emit the models. To refresh after a backend spec change: -For uploading to the test repository, use `python -m twine upload --repository testpypi dist/*`. The same restrictions apply. +1. Copy the updated spec into `docs/openapi.yaml`. +2. Run `make generate`. +3. Run `make check && make test`. +4. If the wire shape changed, update `src/zenrows/batch/client.py` + so the facade method signatures still typecheck. + +## Publishing + +```bash +make clean +make build # produces dist/*.whl + dist/*.tar.gz +uv run twine upload dist/* # or test PyPI: +uv run twine upload --repository testpypi dist/* +``` + +Bump `version` in `pyproject.toml` and `src/zenrows/__version__.py` +together before each release. diff --git a/Makefile b/Makefile index 571e388..ba8e32e 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,69 @@ -.PHONY: install build clean lint test +.PHONY: install sync test lint format typecheck check generate docs clean build -install: - pip install -r requirements.txt +# Bootstrap: install + dev deps, build the local venv. +install sync: + uv sync --all-extras -build: - python setup.py sdist bdist_wheel +# Run the suite. +test: + uv run pytest -clean: - python setup.py clean - rm -rf dist build zenrows.egg-info .pytest_cache - find . -name '__pycache__' -delete -o -name '*.pyc' -delete +# Static type check (ty — Astral). Shipped surface only; tests are +# covered by the suite. Generated models.py is excluded in pyproject. +typecheck: + uv run ty check src +# Lint + format + type check (CI mode — no fixes). +check: typecheck + uv run ruff check src/ tests/ + uv run ruff format --check src/ tests/ + +# Lint + format (writes fixes). lint: - flake8 --config flake8 setup.py tests zenrows + uv run ruff check --fix src/ tests/ +format: + uv run ruff format src/ tests/ -test: - python -m pytest tests +# Regenerate the pydantic v2 models from the backend's canonical spec. +# docs/openapi.yaml is the SDK-local copy of the spec; refresh it from the +# backend when the API changes. +# The HTTP client + facade are HAND-WRITTEN in src/zenrows/batch/client.py; +# only the type definitions come from this command. +generate: + uv run datamodel-codegen \ + --input docs/openapi.yaml \ + --input-file-type openapi \ + --output src/zenrows/batch/models.py \ + --output-model-type pydantic_v2.BaseModel \ + --target-python-version 3.10 \ + --use-schema-description \ + --use-field-description \ + --use-double-quotes \ + --field-constraints \ + --use-standard-collections \ + --use-union-operator \ + --enum-field-as-literal one \ + --collapse-root-models \ + --use-annotated \ + --capitalise-enum-members \ + --reuse-model \ + --use-default + +# Regenerate the markdown API reference (docs/batch-client-reference.md) from +# the SDK's docstrings via pydoc-markdown (ephemeral — no permanent dep). The +# builder relabels internal module headers to public section titles and strips +# the `zenrows.batch._x.` qualifiers, so the private `_module` layout never +# leaks into the customer-facing reference. See scripts/build_reference.py. +docs: + @mkdir -p docs + @uv run --with pydoc-markdown python scripts/build_reference.py > docs/batch-client-reference.md + @echo "wrote docs/batch-client-reference.md ($$(wc -l < docs/batch-client-reference.md) lines)" + +# Clean build artifacts + caches. +clean: + rm -rf dist build *.egg-info src/*.egg-info .pytest_cache .ruff_cache + find . -type d -name __pycache__ -prune -exec rm -rf {} + + +# Build wheel + sdist via hatchling. +build: + uv build diff --git a/README.md b/README.md index ee56bf0..a4694ec 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,46 @@ -# ZenRows Python SDK -SDK to access [ZenRows](https://www.zenrows.com/) API directly from Python. ZenRows handles proxies rotation, headless browsers, and CAPTCHAs for you. +
+
+
`, `http_5xx:`, `timeout`,
+`transport_error:`). Absent on success.
+
+
+
+#### elapsed\_ms
+
+Wall-clock duration of the receiver POST in milliseconds.
+
+
+
+## FileInputColumnRef1 Objects
+
+```python
+class FileInputColumnRef1(RootModel[str])
+```
+
+
+
+#### root
+
+Either a column name (string) — requires `csv.header: true` —
+or a 0-based column index (integer). Other shapes are
+rejected at create-time.
+
+
+
+## FileInputColumnRef2 Objects
+
+```python
+class FileInputColumnRef2(RootModel[int])
+```
+
+
+
+#### root
+
+Either a column name (string) — requires `csv.header: true` —
+or a 0-based column index (integer). Other shapes are
+rejected at create-time.
+
+
+
+## Fields Objects
+
+```python
+class Fields(BaseModel)
+```
+
+Map from canonical task field → CSV column. Only
+`url` (required) and `external_id` (optional) are
+accepted. Each value is a column index (int) or a
+column name (string, requires `header: true`).
+
+
+
+#### url
+
+Either a column name (string) — requires `csv.header: true` —
+or a 0-based column index (integer). Other shapes are
+rejected at create-time.
+
+
+
+#### external\_id
+
+Either a column name (string) — requires `csv.header: true` —
+or a 0-based column index (integer). Other shapes are
+rejected at create-time.
+
+
+
+## Csv Objects
+
+```python
+class Csv(BaseModel)
+```
+
+
+
+#### delimiter
+
+Single-character field delimiter.
+
+
+
+#### quote
+
+Single-character quoting character.
+
+
+
+#### header
+
+When true, the first CSV row is consumed as a header
+row and `csv.fields.*` values may be column names.
+
+
+
+#### fields
+
+Map from canonical task field → CSV column. Only
+`url` (required) and `external_id` (optional) are
+accepted. Each value is a column index (int) or a
+column name (string, requires `header: true`).
+
+
+
+## CreateJobInputRequest Objects
+
+```python
+class CreateJobInputRequest(BaseModel)
+```
+
+
+
+#### type
+
+Only `csv` is supported in v1.
+
+
+
+## FileInputUploadTarget Objects
+
+```python
+class FileInputUploadTarget(BaseModel)
+```
+
+
+
+#### url
+
+Presigned PUT URL. Caller MUST send the body with the
+exact `Content-Type` shown in `headers` — the signature
+binds the content-type.
+
+
+
+#### expires\_at
+
+PUT URL TTL (~30 min).
+
+
+
+## CreateJobInputResponse Objects
+
+```python
+class CreateJobInputResponse(BaseModel)
+```
+
+
+
+#### expires\_at
+
+24 h slot lifetime — beyond this the slot and its uploaded
+body are removed and the `file_input_id` returns 404.
+
+
+
+## HMACKeyMeta Objects
+
+```python
+class HMACKeyMeta(BaseModel)
+```
+
+Public view of one HMAC key — id + creation time. Never
+includes secret material; that's only returned at /rotate.
+
+
+
+#### kid
+
+ULID identifying this key. Stable for the life of the slot; a new candidate gets a new kid.
+
+
+
+## HMACKeyList Objects
+
+```python
+class HMACKeyList(BaseModel)
+```
+
+Slots populated at the time of the call.
+
+
+
+## HMACKeyCreated Objects
+
+```python
+class HMACKeyCreated(BaseModel)
+```
+
+Response to `/rotate`. `secret` is base64-encoded raw key
+material. **This is the ONLY response that ever contains
+the secret value** — capture it now or generate a new one
+via another /rotate call.
+
+
+
+#### secret
+
+Base64-encoded 32-byte HMAC key.
+
+
+
+## HMACKeyFinalized Objects
+
+```python
+class HMACKeyFinalized(BaseModel)
+```
+
+Response to `/rotate/finalize`. No secret.
+
+
+
+## InvalidTask Objects
+
+```python
+class InvalidTask(BaseModel)
+```
+
+
+
+#### value
+
+Offending input. For URL / metadata reasons this is
+a redacted/truncated form of the bad value. For
+`unknown_param` / `invalid_param_value` this is the
+param key.
+
+
+
+## Problem Objects
+
+```python
+class Problem(BaseModel)
+```
+
+RFC 7807 Problem Details.
+
+
+
+#### invalid\_tasks
+
+Present on validation errors.
+
+
+
+## ExportStatus Objects
+
+```python
+class ExportStatus(Enum)
+```
+
+Lifecycle state of a results export.
+* `pending` — export accepted, not started yet.
+* `running` — the zip is being produced.
+* `completed` — `download_url` will be present.
+* `failed` — `error` carries the reason.
+
+
+
+## StartExportResponse Objects
+
+```python
+class StartExportResponse(BaseModel)
+```
+
+Returned by `startResultsExport`.
+
+
+
+#### export\_id
+
+ULID identifying this export. Use it for `getResultsExport`.
+
+
+
+#### expires\_at
+
+12 h after `created_at`. Past this point the export and
+its download are removed and the export id 404s.
+
+
+
+## Export Objects
+
+```python
+class Export(BaseModel)
+```
+
+Polled view of a results export. `download_url` is presigned
+fresh on every successful poll — stash the metadata, but
+re-fetch the URL right before you download.
+
+
+
+#### error
+
+Non-empty only when `status = failed`. Stable strings —
+e.g. `"results are larger then 1 gb"` when the combined
+results exceed the 1 GiB cap.
+
+
+
+#### download\_url
+
+Presigned download URL for the zipped run results.
+Present only when `status = completed`. Short-lived — the
+server mints a new one on every poll.
+
+
+
+#### expires\_at
+
+12 h after `created_at`. The download is unavailable
+after this point.
+
+
+
+## SubmitJobResponse Objects
+
+```python
+class SubmitJobResponse(BaseModel)
+```
+
+
+
+#### latest\_run
+
+Absent for `scheduled` jobs that haven't fired yet.
+
+
+
+#### webhook
+
+Echo of the webhook config persisted on the job (when
+one was supplied). Omitted when no webhook was set.
+
+
+
+## RerunJobResponse Objects
+
+```python
+class RerunJobResponse(BaseModel)
+```
+
+
+
+#### rerun\_of
+
+`run_id` of the previous run that was replayed. Empty on
+the first manual fire of a scheduled job (no prior run).
+
+
+
+#### retried\_tasks
+
+Number of rows reset to `pending` and re-enqueued. Equals
+`latest_run.stats.total` for a full rerun; equals the
+filter-matched count for a `?status=` partial retry.
+
+
+
+#### inherited\_tasks
+
+Number of rows copied verbatim from the previous run with
+`source_run_id` stamped. Zero for a full rerun; non-zero
+only when `?status=` is set.
+
+
+
+## ScheduleCalendar Objects
+
+```python
+class ScheduleCalendar(BaseModel)
+```
+
+Calendar-style fire policy. Fires at every `times_of_day`
+entry on every day matching the cadence.
+
+
+
+#### times\_of\_day
+
+Wall-clock times on a 24-hour clock, full hours only
+(`"09:00"`, `"18:00"`). Minute granularity is rejected
+with 400.
+
+
+
+## TaskResult Objects
+
+```python
+class TaskResult(BaseModel)
+```
+
+
+
+#### external\_id
+
+Caller-supplied correlation id from submit/AddTasks.
+Omitted when the caller did not supply one.
+
+
+
+#### method
+
+The task's HTTP method. Omitted for GET (the default).
+The request `body` is intentionally not part of listing
+responses.
+
+
+
+#### result\_url
+
+24-hour presigned download URL for the result body, or a
+`/v1/jobs//runs//tasks//content` URL you can
+fetch directly. Empty for non-successful tasks.
+
+
+
+#### error
+
+Present on failed tasks. The scraping engine's error
+response as Problem JSON, or a synthesised envelope with
+`code: "gateway_unreachable"` when it couldn't be reached.
+
+
+
+#### source\_run\_id
+
+Set when this row was copied from another run by
+`/rerun?status=`. The row is terminal at creation, is
+never re-executed, and its `result_url` resolves to the
+source run's stored result. On chained retries,
+`source_run_id` chases back to the run that actually
+owns the result. Empty for normally-executed rows.
+
+
+
+## TaskHistoryEvent Objects
+
+```python
+class TaskHistoryEvent(BaseModel)
+```
+
+
+
+#### attempt
+
+1-indexed attempt within the run.
+
+
+
+#### spend
+
+Indicative spend charged for this single attempt. Zero
+on attempts that didn't reach the scraping engine or
+were not charged.
+
+
+
+## JobSchedule Objects
+
+```python
+class JobSchedule(BaseModel)
+```
+
+Structured scheduling block attached to `type: scheduled`
+jobs. Exactly one of `at`, `rate`, or `calendar` must be
+set.
+
+
+
+#### at
+
+One-shot fire at a specific wall-clock timestamp.
+Mutually exclusive with `rate` and `schedule`.
+
+**Must be tz-naive** — no trailing `Z`, no offset. The
+sibling `timezone` field (mandatory) is the single
+authoritative interpreter. This keeps DST transitions
+deterministic.
+
+
+
+#### timezone
+
+IANA timezone name (e.g. `Europe/Berlin`, `UTC`).
+**Required** when `at` or `calendar` is set;
+ignored by `rate` (interval-based, no wall-clock
+meaning). Anchoring wall-clock times to a named zone
+(rather than a UTC offset baked into the string) keeps
+DST transitions deterministic.
+
+
+
+## Job Objects
+
+```python
+class Job(BaseModel)
+```
+
+
+
+#### zenrows\_params
+
+Stored canonical form — values are always strings even
+though submit accepts any JSON scalar (see ScraperParams).
+
+
+
+#### external\_id
+
+Caller-supplied correlation id passed at submit (omitted
+when the caller did not supply one). Not server-enforced
+unique.
+
+
+
+#### name
+
+Optional human label passed at submit (omitted when the
+caller did not supply one). Free-form, up to 100 chars.
+
+
+
+#### schedule
+
+Schedule block — present only for `type: scheduled`
+jobs.
+
+
+
+#### next\_scheduled\_run
+
+Server-computed timestamp of the next expected fire,
+stamped at submit and re-stamped on every fire. `null`
+for non-scheduled jobs and for one-shot `at(...)`
+schedules that have already fired. Stays computed when
+`schedule_state == paused` — "what would fire next if
+you resumed."
+
+
+
+#### schedule\_state
+
+Set only for `type: scheduled`. Default `active` at
+submit; flip via `POST /v1/jobs/{id}/schedule/state`.
+
+
+
+#### webhook
+
+Webhook delivery config. Present iff a
+webhook is configured. Mutable via `PUT/DELETE
+/v1/jobs/{id}/webhook`; `signature` never appears
+alone — the whole `webhook` key is omitted when no
+URL is set.
+
+
+
+#### latest\_run
+
+Snapshot projection of the latest run. Absent for
+`scheduled` jobs that haven't fired yet.
+
+
+
+## SubmitJobRequest Objects
+
+```python
+class SubmitJobRequest(BaseModel)
+```
+
+
+
+#### status
+
+Initial state. `open` is only allowed for `regular` jobs;
+after the initial run, `open` has no meaning so the job
+is auto-closed.
+
+
+
+#### zenrows\_params
+
+Job-level scraper params, applied to every task of every
+run of the job. Each task can override individual keys
+via its own `zenrows_params` (task wins on collision).
+
+
+
+#### schedule
+
+Schedule block for `type: scheduled`. Required there,
+ignored otherwise.
+
+
+
+#### tasks
+
+Required for closed jobs (1–1000) unless `file_input_id`
+is provided. Optional for open jobs — may be empty if the
+caller will follow up with `addTasks`. Mutually exclusive
+with `file_input_id`.
+
+
+
+#### file\_input\_id
+
+Reference to a previously-uploaded CSV input (see
+`POST /v1/job_inputs`). Mutually exclusive with `tasks`.
+Eligible only for regular-closed and scheduled job types.
+The uploaded CSV is parsed under the saved spec; its rows
+become tasks (regular-closed) or template_tasks
+(scheduled).
+
+
+
+#### external\_id
+
+Optional caller-supplied correlation id for the job —
+same semantics as `task.external_id`. Shape-checked,
+**not** required to be unique. Surfaced verbatim in
+`getJob` / `listJobs` responses.
+
+
+
+#### name
+
+Optional human-readable label for the job. Free-form —
+no shape rules. Up to 100 characters. Surfaced verbatim
+in `getJob` / `listJobs` responses. No uniqueness, no
+indexing; cannot be changed after submit.
+
+
+
+#### webhook
+
+Optional `run.completed` / `run.failed` delivery config.
+A terminal run fires `run.completed` on a natural finish, or
+`run.failed` (with `failure_reason` + partial stats) when the
+run auto-fails on an account-level error. `signature`
+defaults to `false` here so first-time integrations
+don't need an HMAC key. Config is mutable post-submit
+via `PUT /v1/jobs/{id}/webhook` and `DELETE
+/v1/jobs/{id}/webhook`; the current config is surfaced
+on `GET /v1/jobs/{id}`.
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
new file mode 100644
index 0000000..c820a41
--- /dev/null
+++ b/docs/openapi.yaml
@@ -0,0 +1,2177 @@
+openapi: 3.1.0
+
+info:
+ title: ZenRows Batch API
+ summary: Asynchronous batch web-scraping API.
+ description: |
+ The ZenRows Batch API is an asynchronous batch web-scraping service.
+ Submit a job containing many URLs; the API scrapes each one, tracks
+ progress, and serves the results back to you. Errors are returned as
+ RFC 7807 Problem JSON.
+
+ ### Model
+ A **Job** is a *template* — a reusable submission with its
+ configuration. A **Run** is one execution of that template; a job
+ has 1+ runs over its lifetime. A **Task** is one URL within one
+ run. The job carries a `latest_run` projection; the authoritative
+ state for execution lives on the run.
+
+ This document is the public API specification.
+ version: "0.2.0"
+
+servers:
+ - url: https://async.api.zenrows.com/v1
+ description: Production
+
+tags:
+ - name: jobs
+ description: Create, inspect, list, close, rerun, and delete scraping jobs.
+ - name: runs
+ description: Inspect runs of a job. Each run is one execution of the job's template.
+ - name: tasks
+ description: Add tasks to an open job's initial run; fetch per-task results and content.
+ - name: hmac
+ description: |
+ Per-org HMAC key lifecycle. A secret is returned to you exactly
+ once, at `/rotate`. These keys sign outbound webhooks when you
+ opt in via `webhook.signature: true` at submit or PUT, so your
+ receiver can verify each delivery. Supports phased rotation with
+ an active key and a staged candidate.
+ - name: job_inputs
+ description: |
+ Caller-uploaded CSV inputs for jobs. Two-step: create a slot
+ (presigned PUT URL + id), upload the CSV, then reference it via
+ `file_input_id` on /jobs. Unclaimed slots expire after 24h.
+ - name: webhook
+ description: |
+ Webhook config testing. The per-job webhook config lives under
+ the `jobs` tag — this tag is for the `POST /webhook/test`
+ endpoint, which lets you validate a receiver (reachability, TLS,
+ signature) before attaching it to a real job.
+
+security:
+ - apiKey: []
+
+paths:
+ /jobs:
+ post:
+ tags: [jobs]
+ operationId: submitJob
+ summary: Submit a new scraping job
+ description: |
+ Validates inputs and authenticates the caller, then persists
+ the job, its first run, and (for small submits) all tasks
+ before returning. Validation failures (4xx) create nothing. A
+ `503` from a storage or queue failure usually creates nothing
+ either, but a late-stage failure can leave the job visible
+ with its run already `stopped` — safe to `DELETE`. Retry a
+ `503` with a **fresh** `Idempotency-Key`: a key claimed by a
+ failed attempt has no stored response and replays as `409`.
+
+ Submissions under 10,000 inline `tasks` return `201 Created`
+ with the task rows already in place — results are immediately
+ queryable via `GET /v1/jobs/{id}/results`.
+
+ Submissions at or above **10,000 inline tasks** are processed
+ off the request path and return `202 Accepted` — `accepted_tasks`
+ is the final validated count, but task rows stream into
+ storage over the following seconds (large submissions can take
+ longer). `GET /v1/jobs/{id}/results` returns only the rows
+ written so far until ingest completes; the run's `stats.total`
+ is correct from the start so completion polling
+ (`stats.completed >= stats.total`) is reliable from the first
+ response. While ingestion is in progress,
+ `POST /v1/jobs/{id}/tasks` on an open job returns `409` —
+ retry it once results pages fill out. The run's
+ `ingest_status` field reports the ingest state: poll
+ `GET /v1/jobs/{id}` until `latest_run.ingest_status` is
+ `done` to know every task row is visible.
+
+ `type: scheduled` is reserved for a future feature and
+ currently returns `501 not_implemented`.
+ parameters:
+ - $ref: '#/components/parameters/IdempotencyKey'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/SubmitJobRequest' }
+ responses:
+ '201':
+ description: Job created (sync path). Run 1 is started and all task rows are written.
+ headers:
+ X-Request-ID: { $ref: '#/components/headers/RequestID' }
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/SubmitJobResponse' }
+ '202':
+ description: |
+ Job accepted (large submission). The validated task list
+ is durable and `accepted_tasks` is the final count, but
+ individual task rows stream into storage off the request
+ path. `GET /v1/jobs/{id}/results` may return partial pages
+ until ingest completes; the run's
+ `stats.total` is correct from this response forward and
+ `stats.completed` climbs from 0, so the run reaches
+ `completed` only after every task drains — completion
+ polling is safe. `latest_run.ingest_status` is `pending`
+ in this response and flips to `done` on `GET /v1/jobs/{id}`
+ once every task row is visible. Body shape matches the
+ 201 response.
+ headers:
+ X-Request-ID: { $ref: '#/components/headers/RequestID' }
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/SubmitJobResponse' }
+ '400': { $ref: '#/components/responses/InvalidArgument' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '402': { $ref: '#/components/responses/PaymentRequired' }
+ '409': { $ref: '#/components/responses/IdempotencyConflict' }
+ '501': { $ref: '#/components/responses/NotImplemented' }
+ '503': { $ref: '#/components/responses/Unavailable' }
+
+ get:
+ tags: [jobs]
+ operationId: listJobs
+ summary: List jobs for the authenticated caller
+ description: |
+ Newest-first (ULID-ordered). Optional `status` / `type` filters.
+ parameters:
+ - in: query
+ name: status
+ schema: { $ref: '#/components/schemas/JobStatus' }
+ - in: query
+ name: type
+ schema: { $ref: '#/components/schemas/JobType' }
+ - in: query
+ name: limit
+ schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
+ - in: query
+ name: cursor
+ description: Opaque; echo the previous response's `next_cursor` to continue.
+ schema: { type: string }
+ responses:
+ '200':
+ description: Page of jobs.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/ListJobsResponse' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+
+ /jobs/{job_id}:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ get:
+ tags: [jobs]
+ operationId: getJob
+ summary: Get a job (template + latest_run projection)
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Job' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+ delete:
+ tags: [jobs]
+ operationId: deleteJob
+ summary: Delete a job and all its artifacts (asynchronous)
+ description: |
+ Flips the job to `deleted` immediately and **flips the latest
+ run to `deleted`** if it was in-flight (so no new tasks get
+ picked up), then returns 202. The job's stored result bodies
+ and all of its data are deleted asynchronously in the
+ background. Idempotent.
+ responses:
+ '202':
+ description: Accepted for deletion.
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+
+ /jobs/{job_id}/tasks:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ post:
+ tags: [tasks]
+ operationId: addTasks
+ summary: Append tasks to an open job's initial run
+ description: |
+ Valid only when the job is `open` AND its latest run is
+ `run_sequence == 1` AND `last_batch_received == false`.
+ Targets the latest (initial) run. Closed/deleted jobs and
+ reruns reject further adds with 409.
+
+ Setting `last_batch: true` closes the job (status → `closed`)
+ and signals the run to flip to `completed` once tasks drain.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/AddTasksRequest' }
+ responses:
+ '200':
+ description: Tasks queued.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/AddTasksResponse' }
+ '400': { $ref: '#/components/responses/InvalidArgument' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '402': { $ref: '#/components/responses/PaymentRequired' }
+ '404': { $ref: '#/components/responses/NotFound' }
+ '409':
+ description: |
+ Job is not open, last batch already received, or the
+ initial run's task ingestion is still in progress (large
+ `202`-accepted submissions) — in the last case, retry
+ once ingest completes.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+
+ /jobs/{job_id}/close:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ post:
+ tags: [jobs]
+ operationId: closeJob
+ summary: Close an open job (no more tasks coming)
+ description: |
+ Flips `job.status` from `open` to `closed` and sets the
+ initial run's `last_batch_received = true`. Same effect as
+ posting `addTasks` with `last_batch: true` and zero tasks.
+
+ Does NOT stop the job — any pending or processing tasks
+ continue. If all known tasks are already terminal, the run
+ flips to `completed` immediately.
+
+ Idempotent: calling on an already-closed job returns the
+ current state with 200.
+ responses:
+ '200':
+ description: Close accepted. Body is the refreshed Job object.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Job' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+
+ /jobs/{job_id}/stop:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ post:
+ tags: [jobs]
+ operationId: stopJob
+ summary: Stop the current run
+ description: |
+ Synchronously transitions `latest_run.status` from `running`
+ or `pending` to terminal `stopped`. No new tasks are picked
+ up; tasks already in flight may still finish and record their
+ result. Pending tasks are left as-is — not re-queued, not
+ synchronously failed.
+
+ Result bodies are kept. To free the storage, follow up with
+ `DELETE /v1/jobs/{id}/runs/{run_id}`.
+
+ Idempotent on `stopped`. Returns 409 on `completed` /
+ `deleted` (use `/rerun` to start fresh) and 404 on a deleted
+ job.
+ responses:
+ '200':
+ description: Run is stopped. Body is the refreshed Run object.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Run' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+ '409':
+ description: Latest run is not in a stoppable state.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+
+ /jobs/{job_id}/cancel:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ post:
+ tags: [jobs]
+ operationId: cancelJob
+ summary: Cancel the current run
+ description: |
+ User-facing alias for `/stop`. Same semantics, same terminal
+ `stopped` state, same response shape. Provided so callers
+ using the noun "cancel" don't have to learn the internal name.
+ responses:
+ '200':
+ description: Run is stopped. Body is the refreshed Run object.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Run' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+ '409':
+ description: Latest run is not in a stoppable state.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+
+ /jobs/{job_id}/pause:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ post:
+ tags: [jobs]
+ operationId: pauseJob
+ summary: Pause the current run
+ description: |
+ Reversible suspend of the latest run. Sets
+ `latest_run.pause_state = paused`; the dispatcher stops
+ polling this run's queue. The run's `status`
+ (`running` / `pending`) is unchanged — pause is orthogonal.
+ Already-in-flight tasks may still settle.
+
+ Idempotent on already-paused runs. Returns 409 when the
+ latest run is terminal (use `/rerun` to start fresh) and
+ 404 on a deleted job.
+
+ Billing note: in-flight gateway calls still consume credits.
+ Use `/cancel` for a hard cost-stop.
+ responses:
+ '200':
+ description: Run is paused. Body is the refreshed Run object.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Run' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+ '409':
+ description: Latest run is terminal; pause does not apply.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+
+ /jobs/{job_id}/resume:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ post:
+ tags: [jobs]
+ operationId: resumeJob
+ summary: Resume a paused run
+ description: |
+ Inverse of `/pause`. Sets `latest_run.pause_state = active`;
+ the dispatcher resumes polling. Idempotent on already-active
+ runs.
+ responses:
+ '200':
+ description: Run is active. Body is the refreshed Run object.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Run' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+ '409':
+ description: Latest run is terminal; resume does not apply.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+
+ /jobs/{job_id}/rerun:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ post:
+ tags: [jobs]
+ operationId: rerunJob
+ summary: Start a new run (full replay or partial retry of the previous run)
+ description: |
+ Two modes share this endpoint, picked by the optional
+ `?status=` filter:
+
+ **Full rerun (no `status`).** Replays every task from the
+ previous run into a fresh new run. Same `task_id` carries
+ through together with `external_id`, `url`, `metadata`, and
+ per-task `zenrows_params`. **No source-child link** — the
+ new run is fully independent. For scheduled jobs with no
+ prior run, builds from `template_tasks` instead — the
+ manual equivalent of an automatic scheduled fire.
+
+ **Partial retry (`?status=failed[,pending,...]`).** Copies
+ every prev-run task row, but rows matching the filter are
+ reset to `pending` and re-enqueued; everything else is
+ copied verbatim with `source_run_id` (the run that owns the
+ stored result) stamped. The new run's stats are pre-seeded
+ from the inherited terminal rows. On chained retries,
+ `source_run_id` chases back to the run that owns the result
+ so intermediate retries can be deleted freely;
+ `DELETE /v1/jobs/{id}/runs/{run_id}` only 409s when a
+ newer run still has tasks pointing at the targeted run.
+
+ Both modes stamp `trigger: manual` on the new run.
+
+ Accepted `status` values: `pending`, `processing`,
+ `failed`, `successful`. Unknown values → 400. Empty
+ comma-separated list → 400.
+
+ Preconditions:
+ - The job is not `deleted`.
+ - If a previous run exists, it must be terminal-non-deleted
+ (`completed` / `stopped` / `failed`).
+ - `?status=` mode additionally requires a prior run.
+ - Full mode on a scheduled job with no prior run requires
+ non-empty `template_tasks`.
+
+ Side effects:
+ - Auto-closes an `open` job (existing behavior).
+ - Honors `Idempotency-Key`: a repeat call with the same
+ key returns the original new-run id.
+
+ Large reruns are accepted asynchronously: past a server-side
+ row-count threshold (the same one that upgrades large
+ submissions) the call returns `202` and the new run's task
+ rows stream into storage off the request path. Treat `201`
+ and `202` both as success.
+ parameters:
+ - in: query
+ name: status
+ description: |
+ Optional comma-separated list of task statuses to
+ retry. When omitted: full replay (no source-child
+ link). When set: only matching statuses are reset to
+ `pending`; everything else is inherited with
+ `source_run_id`.
+ schema:
+ type: string
+ example: failed,pending
+ - $ref: '#/components/parameters/IdempotencyKey'
+ responses:
+ '201':
+ description: New run created (sync path). All task rows are written.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/RerunJobResponse' }
+ '202':
+ description: |
+ New run accepted (large rerun). `retried_tasks` /
+ `inherited_tasks` are the final counts, but individual
+ task rows stream into storage off the request path.
+ `latest_run.ingest_status` is `pending` in this response
+ and flips to `done` on `GET /v1/jobs/{id}` once every
+ task row is visible; the run's `stats.total` is correct
+ from this response forward, so completion polling is
+ safe. Body shape matches the 201 response.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/RerunJobResponse' }
+ '400': { $ref: '#/components/responses/InvalidArgument' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+ '409':
+ description: Latest run not terminal, no prior run available, no tasks match the filter, or idempotency-key reused with a different body.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+ '503': { $ref: '#/components/responses/Unavailable' }
+
+ /jobs/{job_id}/schedule:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ put:
+ tags: [jobs]
+ operationId: updateJobSchedule
+ summary: Replace the schedule of a scheduled job
+ description: |
+ Wholesale replaces the schedule on a `type: scheduled` job.
+ The body is the same `JobSchedule` shape used at submit.
+ Only valid on scheduled jobs — regular jobs reject with 409.
+
+ An in-flight run keeps running; the new schedule only
+ governs future fires. `next_scheduled_run` is recomputed
+ and re-stamped before the response returns.
+
+ Naturally idempotent — re-applying the same body is a
+ no-op success. No `Idempotency-Key` required.
+
+ The change is applied atomically from your perspective; if a
+ transient failure leaves `next_scheduled_run` briefly stale,
+ re-PUT the same body to recover (the operation is idempotent).
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/JobSchedule' }
+ responses:
+ '200':
+ description: Schedule updated; returns the updated Job.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Job' }
+ '400': { $ref: '#/components/responses/InvalidArgument' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+ '409':
+ description: Job isn't a scheduled job.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+ '503': { $ref: '#/components/responses/Unavailable' }
+
+ /jobs/{job_id}/schedule/state:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ post:
+ tags: [jobs]
+ operationId: updateScheduleState
+ summary: Pause or resume a scheduled job
+ description: |
+ Flips `schedule_state` between `active` and `paused`. Only
+ valid on `type: scheduled` jobs.
+
+ While `paused`, scheduled fires are skipped; resuming makes
+ the next scheduled fire produce a run normally.
+
+ `next_scheduled_run` stays computed while paused —
+ it's "what would fire next if you resumed."
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/UpdateScheduleStateRequest' }
+ responses:
+ '200':
+ description: Schedule state updated; returns the updated Job.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Job' }
+ '400': { $ref: '#/components/responses/InvalidArgument' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+ '409':
+ description: Job isn't a scheduled job.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+ '503': { $ref: '#/components/responses/Unavailable' }
+
+ /jobs/{job_id}/webhook:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ get:
+ tags: [jobs]
+ operationId: getJobWebhook
+ summary: Read the current webhook config
+ description: |
+ Returns the job's webhook config or `404` when none is set.
+ The same shape is also surfaced inline on `GET /v1/jobs/{id}`
+ via the `webhook` field.
+ responses:
+ '200':
+ description: Webhook is configured.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/WebhookConfig' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404':
+ description: Job not found, or no webhook configured.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+ put:
+ tags: [jobs]
+ operationId: putJobWebhook
+ summary: Replace the webhook config
+ description: |
+ Replaces the webhook config wholesale. Both `url` and
+ `signature` are required — no defaulting at the mutate
+ boundary (preventing a `PUT {"url":"..."}` from silently
+ disabling signing on a job that previously had it on).
+
+ Validation matches submit: URL must be HTTPS with a host
+ that resolves via DNS within 1 second. When `signature:
+ true`, the org must already have an active HMAC key
+ (`POST /v1/hmac/keys/rotate`), or the request fails with
+ `400 webhook_signing_requires_active_key`.
+
+ A `PUT` mid-retry-cycle is picked up on the next delivery
+ attempt — config is read fresh on every dispatch hop.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/WebhookConfig' }
+ responses:
+ '200':
+ description: New webhook config persisted.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/WebhookConfig' }
+ '400':
+ description: Validation failed (HTTPS / DNS / missing active HMAC key).
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+ '409':
+ description: Concurrent modification — retry the request.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+ delete:
+ tags: [jobs]
+ operationId: deleteJobWebhook
+ summary: Clear the webhook config
+ description: |
+ Idempotent: returns `204` whether or not a webhook was
+ configured. After this call no further `run.completed` /
+ `run.failed` deliveries are sent for this Job — including a
+ currently-in-flight one that hasn't been delivered yet
+ (a delivery whose Job no longer has a webhook configured is
+ dropped).
+ responses:
+ '204': { description: Webhook cleared. }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+ '409':
+ description: Concurrent modification — retry the request.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+
+ /jobs/{job_id}/runs:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ get:
+ tags: [runs]
+ operationId: listJobRuns
+ summary: List runs of a job (newest first)
+ parameters:
+ - in: query
+ name: limit
+ schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
+ - in: query
+ name: cursor
+ schema: { type: string }
+ responses:
+ '200':
+ description: Page of runs.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/ListJobRunsResponse' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+
+ /jobs/{job_id}/runs/{run_id}:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ - $ref: '#/components/parameters/RunID'
+ get:
+ tags: [runs]
+ operationId: getJobRun
+ summary: Get one run by id
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Run' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+ delete:
+ tags: [runs]
+ operationId: deleteJobRun
+ summary: Delete one run and its artifacts (asynchronous)
+ description: |
+ Async hard-delete of a single run. Synchronously: flips the
+ run to `deleted` and (if this was the latest run) updates
+ the job's `latest_run` projection to the next-newest
+ non-deleted run. The run's stored result bodies and its
+ per-task data are then deleted asynchronously.
+
+ Distinct from `/stop`: this deletes the result bodies; `/stop`
+ keeps them. To stop a run *and* free its storage, call
+ `/stop` then `DELETE /runs/{run_id}` (order matters only
+ for semantics — both are idempotent).
+
+ Idempotent — re-DELETE returns 202.
+ responses:
+ '202':
+ description: Accepted for deletion.
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+
+ /jobs/{job_id}/results:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ get:
+ tags: [tasks]
+ operationId: listLatestRunResults
+ summary: Page through per-task results for the latest run of a job
+ description: Shortcut for the latest run; see `listRunResults` for the explicit form.
+ parameters:
+ - { $ref: '#/components/parameters/ResultStatus' }
+ - { $ref: '#/components/parameters/ResultCursor' }
+ responses:
+ '200':
+ description: Page of results.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/ListResultsResponse' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+
+ /jobs/{job_id}/runs/{run_id}/results:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ - $ref: '#/components/parameters/RunID'
+ get:
+ tags: [tasks]
+ operationId: listRunResults
+ summary: Page through per-task results for a specific run
+ parameters:
+ - { $ref: '#/components/parameters/ResultStatus' }
+ - { $ref: '#/components/parameters/ResultCursor' }
+ responses:
+ '200':
+ description: Page of results.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/ListResultsResponse' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+
+ /jobs/{job_id}/tasks/{task_id}/content:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ - $ref: '#/components/parameters/TaskID'
+ get:
+ tags: [tasks]
+ operationId: getLatestRunTaskContent
+ summary: Fetch the scraped content for a task in the latest run
+ description: Latest-run shortcut. See `getRunTaskContent` for explicit-run form.
+ responses:
+ '200':
+ description: Scraped content.
+ content:
+ text/html:
+ schema: { type: string }
+ application/json:
+ schema: { type: object }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+ '409':
+ description: Task is still pending or processing.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+ '422':
+ description: Task is failed. Body is the captured error as Problem JSON.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+
+ /jobs/{job_id}/runs/{run_id}/tasks/{task_id}/content:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ - $ref: '#/components/parameters/RunID'
+ - $ref: '#/components/parameters/TaskID'
+ get:
+ tags: [tasks]
+ operationId: getRunTaskContent
+ summary: Fetch the scraped content for a task in a specific run
+ responses:
+ '200':
+ description: Scraped content.
+ content:
+ text/html:
+ schema: { type: string }
+ application/json:
+ schema: { type: object }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+ '409':
+ description: Task is still pending or processing.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+ '422':
+ description: Task is failed. Body is the captured error as Problem JSON.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+
+ /jobs/{job_id}/tasks/{task_id}/history:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ - $ref: '#/components/parameters/TaskID'
+ get:
+ tags: [tasks]
+ operationId: getLatestRunTaskHistory
+ summary: Get the attempt history for a task in the latest run
+ responses:
+ '200':
+ description: List of attempt events.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/TaskHistoryResponse' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+
+ /jobs/{job_id}/runs/{run_id}/tasks/{task_id}/history:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ - $ref: '#/components/parameters/RunID'
+ - $ref: '#/components/parameters/TaskID'
+ get:
+ tags: [tasks]
+ operationId: getRunTaskHistory
+ summary: Get the attempt history for a task in a specific run
+ responses:
+ '200':
+ description: List of attempt events.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/TaskHistoryResponse' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+
+ /hmac/keys:
+ get:
+ tags: [hmac]
+ operationId: listHMACKeys
+ summary: List the org's HMAC key metadata
+ description: |
+ Returns kid + created_at for whichever slots are populated
+ (active and/or candidate). NEVER returns secret material —
+ the only chance to capture a secret is the rotate response.
+ responses:
+ '200':
+ description: Key metadata.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/HMACKeyList' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+
+ /hmac/keys/rotate:
+ post:
+ tags: [hmac]
+ operationId: rotateHMACKey
+ summary: Generate the initial key, or stage a candidate
+ description: |
+ Unified rotation endpoint:
+ - No active key yet → generates the org's first active key.
+ - Active exists, no candidate → generates a candidate for
+ phased rotation.
+ - Both slots populated → 409 `candidate_pending`. Finalize
+ or cancel first.
+
+ The response body's `secret` is the **only** time the raw
+ key value leaves the service. Capture it now or call rotate
+ again to issue a new one.
+ responses:
+ '201':
+ description: Key created. `secret` returned once.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/HMACKeyCreated' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '409':
+ description: Candidate already pending.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+ delete:
+ tags: [hmac]
+ operationId: cancelHMACRotation
+ summary: Discard the pending candidate
+ description: |
+ Drops the candidate slot. Idempotent: returns 204 even when
+ no candidate is present.
+ responses:
+ '204':
+ description: Candidate discarded (or none was pending).
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+
+ /hmac/keys/rotate/finalize:
+ post:
+ tags: [hmac]
+ operationId: finalizeHMACKey
+ summary: Promote the candidate to active
+ description: |
+ Promotes the pending candidate to active; the prior active
+ is discarded. Returns the new active's metadata; no secret.
+ Returns 409 `no_candidate` if nothing is pending.
+ responses:
+ '200':
+ description: Candidate promoted.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/HMACKeyFinalized' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '409':
+ description: No candidate to finalize, or concurrent modification.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+
+ /webhook/test:
+ post:
+ tags: [webhook]
+ operationId: testWebhook
+ summary: Send a synthetic test event to a webhook URL
+ description: |
+ Synchronous one-shot dispatch of a `webhook.test` event
+ against a caller-supplied URL. Independent of any job — body
+ carries the same `{ url, signature }` shape as the submit-time
+ webhook field. Used by integrators to verify a receiver is
+ reachable, terminates TLS, and validates the signature before
+ attaching the URL to a real job.
+
+ Validation is identical to submit (HTTPS, 1-second DNS lookup,
+ active HMAC key required when `signature: true`). On validation
+ failure, returns the same Problem JSON codes
+ (`callback_url_must_be_https`, `callback_url_unresolvable`,
+ `webhook_signing_requires_active_key`).
+
+ On validation pass, always returns `200 OK`; the receiver
+ outcome rides in the body so callers can distinguish
+ "the API rejected my config" (4xx) from "my receiver
+ returned 500" (200 + `delivered: false`).
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/TestWebhookRequest' }
+ responses:
+ '200':
+ description: |
+ Dispatch attempted. Inspect `delivered` to see whether the
+ receiver returned 2xx.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/TestWebhookResponse' }
+ '400':
+ description: |
+ Validation failed — HTTPS / DNS / missing active HMAC key.
+ The receiver was not contacted.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '503': { $ref: '#/components/responses/Unavailable' }
+
+ /job_inputs:
+ post:
+ tags: [job_inputs]
+ operationId: createJobInput
+ summary: Create a CSV upload slot
+ description: |
+ Allocates a `file_input_id`, persists the parse spec, and
+ returns a presigned PUT URL the caller uploads the CSV body
+ to. The slot lives 24 h; subsequent `POST /v1/jobs` references
+ it by `file_input_id`.
+
+ The CSV is parsed when the job is submitted, where a
+ 50 MB / 100 000-row cap is enforced.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/CreateJobInputRequest' }
+ responses:
+ '201':
+ description: Slot created.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/CreateJobInputResponse' }
+ '400': { $ref: '#/components/responses/InvalidArgument' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+
+ /jobs/{job_id}/runs/{run_id}/exports:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ - $ref: '#/components/parameters/RunID'
+ post:
+ tags: [runs]
+ operationId: startResultsExport
+ summary: Start an async results export
+ description: |
+ Kicks off an async zip of every task body in the run for
+ short-term download. Returns an `export_id` immediately; poll
+ `getResultsExport` for status and the presigned download URL.
+
+ Each export expires 12 h after creation, after which the id
+ 404s. There is no delete / update — expiry is the cleanup.
+
+ The export fails with
+ `error: "results are larger then 1 gb"` if the combined size
+ of the results exceeds the 1 GiB cap.
+ responses:
+ '202':
+ description: Export accepted; the zip is produced asynchronously.
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/StartExportResponse' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+
+ /jobs/{job_id}/runs/{run_id}/exports/{export_id}:
+ parameters:
+ - $ref: '#/components/parameters/JobID'
+ - $ref: '#/components/parameters/RunID'
+ - $ref: '#/components/parameters/ExportID'
+ get:
+ tags: [runs]
+ operationId: getResultsExport
+ summary: Poll a results export
+ description: |
+ Returns the current status and (on `completed`) a freshly
+ presigned `download_url`. The URL is signed on every call,
+ so callers may stash the metadata but should re-fetch right
+ before downloading.
+
+ 404 covers both "id doesn't exist" and "expired" — callers
+ treat them the same.
+ responses:
+ '200':
+ description: Export state (with `download_url` once complete).
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Export' }
+ '401': { $ref: '#/components/responses/Unauthenticated' }
+ '404': { $ref: '#/components/responses/NotFound' }
+
+components:
+ securitySchemes:
+ apiKey:
+ type: apiKey
+ in: header
+ name: X-API-Key
+ description: |
+ API key issued via the zenrows account. Only the header
+ transport is accepted — query-param keys are rejected.
+
+ parameters:
+ JobID:
+ name: job_id
+ in: path
+ required: true
+ description: ULID assigned by the server on submit.
+ schema: { type: string, pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }
+ RunID:
+ name: run_id
+ in: path
+ required: true
+ description: ULID assigned by the server when a run is created (submit or rerun).
+ schema: { type: string, pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }
+ TaskID:
+ name: task_id
+ in: path
+ required: true
+ description: Task id within a run (ULID-generated or caller-supplied).
+ schema: { type: string, pattern: '^[A-Za-z0-9._-]{1,128}$' }
+ ExportID:
+ name: export_id
+ in: path
+ required: true
+ description: ULID assigned by the server when a results export is started.
+ schema: { type: string, pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }
+ IdempotencyKey:
+ name: Idempotency-Key
+ in: header
+ required: false
+ description: |
+ Optional opaque token scoping a single submit / rerun
+ request. Re-submitting with the same key returns the
+ original response (or 409 on body mismatch).
+ schema: { type: string, maxLength: 128 }
+ ResultStatus:
+ name: status
+ in: query
+ schema:
+ type: string
+ enum: [all, successful, failed, processing]
+ default: all
+ description: |
+ `processing` covers both `pending` and `processing` task
+ states (anything not yet terminal).
+ ResultCursor:
+ name: cursor
+ in: query
+ schema: { type: string }
+ description: Opaque pagination token; echo `next_cursor`.
+
+ headers:
+ RequestID:
+ description: Correlation ID echoed from the server. Use in support requests.
+ schema: { type: string }
+
+ responses:
+ InvalidArgument:
+ description: Request validation failed.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+ Unauthenticated:
+ description: Missing / invalid API key.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+ PaymentRequired:
+ description: Subscription has no credit available.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+ IdempotencyConflict:
+ description: Idempotency-Key has already been used by this caller with a different body.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+ NotFound:
+ description: Resource not found (or not owned by caller).
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+ NotImplemented:
+ description: Feature is reserved but not yet implemented.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+ Unavailable:
+ description: Transient upstream failure; safe to retry.
+ content:
+ application/problem+json:
+ schema: { $ref: '#/components/schemas/Problem' }
+
+ schemas:
+ JobType:
+ type: string
+ enum: [regular, scheduled]
+ default: regular
+ description: |
+ - `regular` — a run is created at submit time.
+ - `scheduled` — fires on a recurring or one-shot schedule.
+ Submit stores the task list as a template on the job row;
+ each scheduled fire produces a fresh Run. `schedule`
+ field required. Read-only template: `addTasks` and
+ `close` return 409. `rerun` (full or filtered) and `stop`
+ are allowed.
+
+ JobStatus:
+ type: string
+ enum: [open, closed, deleted]
+ description: |
+ - `open` — initial run still accepting `addTasks`. Only
+ meaningful while `latest_run.run_sequence == 1`.
+ - `closed` — no more tasks accepted (created closed, or
+ closed via `/close` / `addTasks{last_batch}` / `/rerun`).
+ - `deleted` — async deletion in progress; the job disappears
+ once it finishes.
+
+ ScheduleState:
+ type: string
+ enum: [active, paused]
+ description: |
+ Run/pause flag on a scheduled job. While `paused`, scheduled
+ fires are skipped. Default at submit: `active`. Flip via
+ `POST /v1/jobs/{id}/schedule/state`.
+
+ UpdateScheduleStateRequest:
+ type: object
+ required: [schedule_state]
+ properties:
+ schedule_state: { $ref: '#/components/schemas/ScheduleState' }
+
+ RunTrigger:
+ type: string
+ enum: [manual, scheduled]
+ description: |
+ What set this run in motion. Always set.
+ - `manual` — caller-initiated: `POST /jobs`, `POST /jobs/{id}/rerun`
+ (any job type, full or `?status=`-filtered, manual fire of
+ a scheduled job).
+ - `scheduled` — automatic, fired by the configured
+ schedule (recurring or one-shot).
+
+ RunStatus:
+ type: string
+ enum: [running, pending, completed, stopped, failed, deleted]
+ description: |
+ In-flight:
+ - `running` — work queued / in flight.
+ - `pending` — initial run of an open job, idle between batches.
+
+ Terminal:
+ - `completed` — natural finish.
+ - `stopped` — caller called `POST /jobs/{id}/stop`. No new
+ tasks are picked up; in-flight tasks may still finish.
+ Result bodies are kept. `stats.completed < stats.total`
+ signals "stopped early".
+ - `failed` — the run was auto-failed on an account-level error
+ (insufficient credits / inactive subscription). No new tasks
+ are picked up; `failure_reason` carries the cause. Re-runnable
+ once the account is resolved. Result bodies already produced
+ are kept.
+ - `deleted` — caller called
+ `DELETE /v1/jobs/{id}/runs/{run_id}`. The run's result
+ bodies and data are being deleted; once complete the run
+ disappears.
+
+ TaskStatus:
+ type: string
+ enum: [pending, processing, successful, failed]
+
+ ResultType:
+ type: string
+ enum: [html, json, markdown, plaintext, pdf]
+ description: Body format of a successful task result; matches the job's `format` 1:1.
+
+ Format:
+ type: string
+ enum: [html, json, markdown, plaintext, pdf]
+ description: |
+ Derived server-side from `zenrows_params` at submit time.
+ Precedence:
+ - `response_type: markdown|plaintext|pdf` → matching format.
+ - `autoparse: true`, `json_response: true`, or non-empty
+ `css_extractor` → `json`.
+ - otherwise → `html`.
+ Stamped on every successful task result and used to set the
+ right `Content-Type` when you fetch the content.
+
+ Metadata:
+ type: object
+ description: Opaque user-supplied key/value pairs. Stored on the task; not forwarded.
+ additionalProperties:
+ type: string
+ maxLength: 1024
+ maxProperties: 20
+
+ ScraperParams:
+ type: object
+ description: |
+ Subset of the zenrows universal-scraper-API parameter overview
+ (https://docs.zenrows.com/universal-scraper-api/api-reference).
+ The API pins the surface to a known set — unknown keys are
+ rejected with `400 invalid_argument` (`unknown_param`). The
+ `screenshot*` family is deliberately excluded; `mode` accepts
+ `"auto"` only for now.
+
+ Each value can be any JSON scalar (string, boolean, integral
+ number); the API coerces to a string internally.
+
+ Per-task `zenrows_params` override job-level `zenrows_params`
+ on key collision.
+
+ Supported keys: `mode`, `js_render`, `js_instructions`,
+ `custom_headers`, `premium_proxy`, `proxy_country`,
+ `session_id`, `original_status`, `allowed_status_codes`,
+ `wait_for`, `wait`, `block_resources`, `json_response`,
+ `css_extractor`, `autoparse`, `response_type`, `outputs`.
+
+ `custom_headers` additionally accepts an object of header
+ name → value pairs (or that object serialised as a JSON
+ string), e.g. `{"custom_headers": {"Referer":
+ "https://www.google.com/"}}`. The headers are forwarded to
+ the scraping target, subject to Fetch's usual
+ header sanitisation. At most 32 headers / 8 KB serialised;
+ names must be valid HTTP tokens. Header filtering and
+ browser-header sanitisation are owned by Fetch,
+ not Conveyor. A task-level
+ `custom_headers` replaces the job-level object wholesale —
+ no per-name merging.
+ additionalProperties:
+ oneOf:
+ - { type: string }
+ - { type: boolean }
+ - { type: integer }
+ - { type: object, additionalProperties: { type: string } }
+
+ TaskInput:
+ type: object
+ required: [url]
+ properties:
+ external_id:
+ type: string
+ maxLength: 128
+ pattern: '^[A-Za-z0-9._-]+$'
+ description: |
+ Optional caller-supplied correlation id — typically an
+ identifier from the caller's own system. Surfaced verbatim
+ in result/content responses so callers can match results
+ back to their records. **Not required to be unique** —
+ callers may reuse the same value across tasks (e.g. when
+ multiple scrapes correlate to the same upstream record).
+ Independent of the server-
+ assigned `task_id`.
+ url:
+ type: string
+ format: uri
+ maxLength: 2048
+ description: Must be http(s). Other schemes rejected at submit.
+ metadata: { $ref: '#/components/schemas/Metadata' }
+ method:
+ type: string
+ enum: [GET, POST]
+ default: GET
+ description: |
+ HTTP method used against `url`. Case-insensitive. POST is
+ for **safe/idempotent** requests only (GraphQL queries,
+ search endpoints): tasks are retried on transient failures
+ and reruns, so the target may see the same POST more than
+ once. Callers that cannot tolerate a duplicate should
+ disable reruns. POST rides the standard (non-headless)
+ scraping path — combining it with `js_render`,
+ `js_instructions`, or `json_response` is rejected with
+ 400 `method_param_conflict`.
+ body:
+ description: |
+ Request body, only with `method: POST`. Any JSON value,
+ 16 KiB max. An object/array/number/boolean is sent as its
+ JSON encoding with `Content-Type: application/json`; a
+ string is sent verbatim with
+ `Content-Type: application/x-www-form-urlencoded`. Set a
+ different target Content-Type via the `custom_headers`
+ zenrows param. Never echoed in results listings.
+ zenrows_params:
+ $ref: '#/components/schemas/ScraperParams'
+ description: |
+ Per-task scraper params. Override the job-level
+ `zenrows_params` on key collision (task wins).
+
+ SubmitJobRequest:
+ type: object
+ properties:
+ type: { $ref: '#/components/schemas/JobType' }
+ status:
+ type: string
+ enum: [open, closed]
+ default: closed
+ description: |
+ Initial state. `open` is only allowed for `regular` jobs;
+ after the initial run, `open` has no meaning so the job
+ is auto-closed.
+ zenrows_params:
+ $ref: '#/components/schemas/ScraperParams'
+ description: |
+ Job-level scraper params, applied to every task of every
+ run of the job. Each task can override individual keys
+ via its own `zenrows_params` (task wins on collision).
+ schedule:
+ $ref: '#/components/schemas/JobSchedule'
+ description: |
+ Schedule block for `type: scheduled`. Required there,
+ ignored otherwise.
+ tasks:
+ type: array
+ description: |
+ Required for closed jobs (1–1000) unless `file_input_id`
+ is provided. Optional for open jobs — may be empty if the
+ caller will follow up with `addTasks`. Mutually exclusive
+ with `file_input_id`.
+ minItems: 0
+ maxItems: 1000
+ items: { $ref: '#/components/schemas/TaskInput' }
+ file_input_id:
+ type: string
+ description: |
+ Reference to a previously-uploaded CSV input (see
+ `POST /v1/job_inputs`). Mutually exclusive with `tasks`.
+ Eligible only for regular-closed and scheduled job types.
+ The uploaded CSV is parsed under the saved spec; its rows
+ become tasks (regular-closed) or template_tasks
+ (scheduled).
+ external_id:
+ type: string
+ maxLength: 128
+ pattern: '^[A-Za-z0-9._-]+$'
+ description: |
+ Optional caller-supplied correlation id for the job —
+ same semantics as `task.external_id`. Shape-checked,
+ **not** required to be unique. Surfaced verbatim in
+ `getJob` / `listJobs` responses.
+ name:
+ type: string
+ maxLength: 100
+ description: |
+ Optional human-readable label for the job. Free-form —
+ no shape rules. Up to 100 characters. Surfaced verbatim
+ in `getJob` / `listJobs` responses. No uniqueness, no
+ indexing; cannot be changed after submit.
+ metadata: { $ref: '#/components/schemas/Metadata' }
+ webhook:
+ $ref: '#/components/schemas/WebhookConfig'
+ description: |
+ Optional `run.completed` / `run.failed` delivery config.
+ A terminal run fires `run.completed` on a natural finish, or
+ `run.failed` (with `failure_reason` + partial stats) when the
+ run auto-fails on an account-level error. `signature`
+ defaults to `false` here so first-time integrations
+ don't need an HMAC key. Config is mutable post-submit
+ via `PUT /v1/jobs/{id}/webhook` and `DELETE
+ /v1/jobs/{id}/webhook`; the current config is surfaced
+ on `GET /v1/jobs/{id}`.
+
+ SubmitJobResponse:
+ type: object
+ required: [job_id, status, accepted_tasks]
+ properties:
+ job_id: { type: string }
+ status: { $ref: '#/components/schemas/JobStatus' }
+ latest_run:
+ $ref: '#/components/schemas/Run'
+ description: Absent for `scheduled` jobs that haven't fired yet.
+ accepted_tasks: { type: integer }
+ webhook:
+ $ref: '#/components/schemas/WebhookConfig'
+ description: |
+ Echo of the webhook config persisted on the job (when
+ one was supplied). Omitted when no webhook was set.
+
+ AddTasksRequest:
+ type: object
+ required: [tasks]
+ properties:
+ tasks:
+ type: array
+ minItems: 1
+ maxItems: 1000
+ items: { $ref: '#/components/schemas/TaskInput' }
+ last_batch:
+ type: boolean
+ default: false
+ description: |
+ Set true on the final batch. Closes the job (status →
+ `closed`) and marks the run's `last_batch_received`.
+
+ AddTasksResponse:
+ type: object
+ required: [accepted_tasks, job_status, latest_run]
+ properties:
+ accepted_tasks: { type: integer }
+ job_status: { $ref: '#/components/schemas/JobStatus' }
+ latest_run: { $ref: '#/components/schemas/Run' }
+
+ RerunJobResponse:
+ type: object
+ required: [job_id, status, latest_run, retried_tasks, inherited_tasks]
+ properties:
+ job_id: { type: string }
+ status: { $ref: '#/components/schemas/JobStatus' }
+ latest_run: { $ref: '#/components/schemas/Run' }
+ rerun_of:
+ type: string
+ description: |
+ `run_id` of the previous run that was replayed. Empty on
+ the first manual fire of a scheduled job (no prior run).
+ retried_tasks:
+ type: integer
+ description: |
+ Number of rows reset to `pending` and re-enqueued. Equals
+ `latest_run.stats.total` for a full rerun; equals the
+ filter-matched count for a `?status=` partial retry.
+ inherited_tasks:
+ type: integer
+ description: |
+ Number of rows copied verbatim from the previous run with
+ `source_run_id` stamped. Zero for a full rerun; non-zero
+ only when `?status=` is set.
+
+ Spend:
+ type: object
+ description: |
+ Indicative `{credits, cost}` pair — what was charged for
+ the scoped work. **Not billing-grade**; your account
+ statement is authoritative. Use as a "how much did this
+ cost?" indicator, not for reconciliation.
+ required: [credits, cost]
+ properties:
+ credits: { type: integer, minimum: 0 }
+ cost: { type: number, format: double, minimum: 0 }
+
+ TaskSpend:
+ type: object
+ description: |
+ Per-task indicative spend with two roll-ups: `total`
+ accumulates across every attempt (including retries),
+ `last_attempt` carries just the most recent gateway call.
+ On a task that succeeded on its first try the two are
+ equal; on a retried task they diverge.
+ required: [total, last_attempt]
+ properties:
+ total: { $ref: '#/components/schemas/Spend' }
+ last_attempt: { $ref: '#/components/schemas/Spend' }
+
+ RunStats:
+ type: object
+ required: [total, completed, successful, failed]
+ properties:
+ total: { type: integer, description: Number of tasks in this run. }
+ completed: { type: integer, description: successful + failed. }
+ successful: { type: integer }
+ failed: { type: integer }
+ failure_reasons:
+ type: object
+ description: |
+ Coarse rollup of terminal failures keyed by a small public
+ taxonomy. Lets you answer "what kinds of failures did I
+ get?" without paging every error blob. Best-effort,
+ indicative — omitted on runs with no failures yet and on
+ runs that predate the feature.
+
+ Vocabulary (the only keys that appear):
+ - `auth_failed` — credentials or billing
+ - `blocked` — anti-bot / policy denials
+ - `bad_target` — target URL is the problem (bad host, 404, 410, too large)
+ - `rate_limited` — target throttled the request
+ - `timeout` — the scrape didn't complete in time
+ - `gateway_error` — ZenRows-side transport / 5xx
+ - `other` — anything else
+ additionalProperties:
+ type: integer
+ minimum: 0
+ example:
+ blocked: 9
+ gateway_error: 4
+ spend:
+ allOf: [{ $ref: '#/components/schemas/Spend' }]
+ description: |
+ Indicative spend summed across every task attempt in
+ this run. Absent on runs whose tasks predate this
+ field (treat as zero).
+
+ Run:
+ type: object
+ required: [run_id, job_id, run_sequence, status, stats, created_at, updated_at]
+ properties:
+ run_id: { type: string }
+ job_id: { type: string }
+ run_sequence: { type: integer, minimum: 1 }
+ status: { $ref: '#/components/schemas/RunStatus' }
+ stats: { $ref: '#/components/schemas/RunStats' }
+ last_batch_received:
+ type: boolean
+ description: |
+ Meaningful only for the initial run of an open job.
+ Once true, `addTasks` is rejected and the run drains
+ into `completed`.
+ pause_state:
+ type: string
+ enum: [active, paused]
+ description: |
+ Reversible-suspend flag, orthogonal to `status`.
+ Omitted from responses when `active` / absent (legacy
+ rows). Flip via `POST /jobs/{id}/pause` and
+ `/resume`.
+ ingest_status:
+ type: string
+ enum: [pending, done]
+ description: |
+ Present only on runs created by a large (202) submission
+ or a large (202) rerun. `pending` — task rows are still
+ streaming into storage; reads may return partial pages
+ and `addTasks` returns `409`. `done` — every accepted
+ task row is visible. Omitted on runs whose tasks were
+ written on the request path (201 submissions and
+ reruns, `addTasks` batches).
+ created_at: { type: string, format: date-time }
+ updated_at: { type: string, format: date-time }
+ failure_reason:
+ type: string
+ enum: [insufficient_credits, subscription_inactive]
+ description: |
+ Present only when `status == failed`: the account-level
+ cause of the auto-fail. `insufficient_credits` (out of
+ credits) or `subscription_inactive` (subscription not
+ active). Omitted otherwise. Distinct from
+ `stats.failure_reasons` (the per-task rollup).
+
+ JobSchedule:
+ type: object
+ description: |
+ Structured scheduling block attached to `type: scheduled`
+ jobs. Exactly one of `at`, `rate`, or `calendar` must be
+ set.
+ properties:
+ at:
+ type: string
+ description: |
+ One-shot fire at a specific wall-clock timestamp.
+ Mutually exclusive with `rate` and `schedule`.
+
+ **Must be tz-naive** — no trailing `Z`, no offset. The
+ sibling `timezone` field (mandatory) is the single
+ authoritative interpreter. This keeps DST transitions
+ deterministic.
+ example: "2026-09-01T09:00:00"
+ rate:
+ $ref: '#/components/schemas/ScheduleRate'
+ calendar:
+ $ref: '#/components/schemas/ScheduleCalendar'
+ timezone:
+ type: string
+ description: |
+ IANA timezone name (e.g. `Europe/Berlin`, `UTC`).
+ **Required** when `at` or `calendar` is set;
+ ignored by `rate` (interval-based, no wall-clock
+ meaning). Anchoring wall-clock times to a named zone
+ (rather than a UTC offset baked into the string) keeps
+ DST transitions deterministic.
+ example: "Europe/Berlin"
+
+ ScheduleRate:
+ type: object
+ required: [every, unit]
+ description: Interval-based fire policy — every N units.
+ properties:
+ every:
+ type: integer
+ minimum: 1
+ example: 15
+ unit:
+ type: string
+ enum: [minute, hour, day]
+
+ ScheduleCalendar:
+ type: object
+ required: [times_of_day, cadence]
+ description: |
+ Calendar-style fire policy. Fires at every `times_of_day`
+ entry on every day matching the cadence.
+ properties:
+ times_of_day:
+ type: array
+ minItems: 1
+ description: |
+ Wall-clock times on a 24-hour clock, full hours only
+ (`"09:00"`, `"18:00"`). Minute granularity is rejected
+ with 400.
+ items:
+ type: string
+ pattern: '^([01][0-9]|2[0-3]):00$'
+ example: ["09:00", "18:00"]
+ cadence:
+ $ref: '#/components/schemas/ScheduleCadence'
+
+ ScheduleCadence:
+ type: object
+ description: |
+ Picks which days the schedule fires on. Exactly one of
+ `daily`, `weekly`, `monthly` must be set.
+ properties:
+ daily:
+ type: object
+ description: Fire every day. No knobs.
+ weekly:
+ type: object
+ required: [days]
+ properties:
+ days:
+ type: array
+ minItems: 1
+ items:
+ type: string
+ enum: [mon, tue, wed, thu, fri, sat, sun]
+ example: [mon, wed, fri]
+ monthly:
+ type: object
+ required: [days]
+ properties:
+ days:
+ type: array
+ minItems: 1
+ items:
+ type: integer
+ minimum: 1
+ maximum: 31
+ example: [1, 15]
+
+ Job:
+ type: object
+ required: [job_id, type, status, created_at, updated_at]
+ properties:
+ job_id: { type: string }
+ type: { $ref: '#/components/schemas/JobType' }
+ status: { $ref: '#/components/schemas/JobStatus' }
+ format: { $ref: '#/components/schemas/Format' }
+ zenrows_params:
+ type: object
+ additionalProperties: { type: string }
+ description: |
+ Stored canonical form — values are always strings even
+ though submit accepts any JSON scalar (see ScraperParams).
+ external_id:
+ type: string
+ description: |
+ Caller-supplied correlation id passed at submit (omitted
+ when the caller did not supply one). Not server-enforced
+ unique.
+ name:
+ type: string
+ description: |
+ Optional human label passed at submit (omitted when the
+ caller did not supply one). Free-form, up to 100 chars.
+ metadata: { $ref: '#/components/schemas/Metadata' }
+ schedule:
+ $ref: '#/components/schemas/JobSchedule'
+ description: |
+ Schedule block — present only for `type: scheduled`
+ jobs.
+ next_scheduled_run:
+ type: string
+ format: date-time
+ nullable: true
+ description: |
+ Server-computed timestamp of the next expected fire,
+ stamped at submit and re-stamped on every fire. `null`
+ for non-scheduled jobs and for one-shot `at(...)`
+ schedules that have already fired. Stays computed when
+ `schedule_state == paused` — "what would fire next if
+ you resumed."
+ schedule_state:
+ $ref: '#/components/schemas/ScheduleState'
+ description: |
+ Set only for `type: scheduled`. Default `active` at
+ submit; flip via `POST /v1/jobs/{id}/schedule/state`.
+ webhook:
+ $ref: '#/components/schemas/WebhookConfig'
+ description: |
+ Webhook delivery config. Present iff a
+ webhook is configured. Mutable via `PUT/DELETE
+ /v1/jobs/{id}/webhook`; `signature` never appears
+ alone — the whole `webhook` key is omitted when no
+ URL is set.
+ latest_run:
+ $ref: '#/components/schemas/Run'
+ description: |
+ Snapshot projection of the latest run. Absent for
+ `scheduled` jobs that haven't fired yet.
+ created_at: { type: string, format: date-time }
+ updated_at: { type: string, format: date-time }
+
+ ListJobsResponse:
+ type: object
+ required: [jobs]
+ properties:
+ jobs:
+ type: array
+ items: { $ref: '#/components/schemas/Job' }
+ next_cursor:
+ type: string
+ nullable: true
+
+ ListJobRunsResponse:
+ type: object
+ required: [runs]
+ properties:
+ runs:
+ type: array
+ items: { $ref: '#/components/schemas/Run' }
+ next_cursor:
+ type: string
+ nullable: true
+
+ TaskResult:
+ type: object
+ required: [task_id, run_id, url, status]
+ properties:
+ task_id: { type: string }
+ external_id:
+ type: string
+ description: |
+ Caller-supplied correlation id from submit/AddTasks.
+ Omitted when the caller did not supply one.
+ run_id: { type: string }
+ url: { type: string, format: uri }
+ metadata: { $ref: '#/components/schemas/Metadata' }
+ method:
+ type: string
+ enum: [GET, POST]
+ description: |
+ The task's HTTP method. Omitted for GET (the default).
+ The request `body` is intentionally not part of listing
+ responses.
+ status: { $ref: '#/components/schemas/TaskStatus' }
+ type: { $ref: '#/components/schemas/ResultType' }
+ result_url:
+ type: string
+ nullable: true
+ description: |
+ 24-hour presigned download URL for the result body, or a
+ `/v1/jobs//runs//tasks//content` URL you can
+ fetch directly. Empty for non-successful tasks.
+ error:
+ $ref: '#/components/schemas/Problem'
+ description: |
+ Present on failed tasks. The scraping engine's error
+ response as Problem JSON, or a synthesised envelope with
+ `code: "gateway_unreachable"` when it couldn't be reached.
+ source_run_id:
+ type: string
+ description: |
+ Set when this row was copied from another run by
+ `/rerun?status=`. The row is terminal at creation, is
+ never re-executed, and its `result_url` resolves to the
+ source run's stored result. On chained retries,
+ `source_run_id` chases back to the run that actually
+ owns the result. Empty for normally-executed rows.
+ spend: { $ref: '#/components/schemas/TaskSpend' }
+
+ ListResultsResponse:
+ type: object
+ required: [results]
+ properties:
+ results:
+ type: array
+ items: { $ref: '#/components/schemas/TaskResult' }
+ next_cursor:
+ type: string
+ nullable: true
+
+ TaskHistoryEvent:
+ type: object
+ required: [started_at, ended_at, attempt]
+ properties:
+ started_at: { type: string, format: date-time }
+ ended_at: { type: string, format: date-time }
+ attempt:
+ type: integer
+ minimum: 1
+ description: 1-indexed attempt within the run.
+ error:
+ $ref: '#/components/schemas/Problem'
+ spend:
+ allOf: [{ $ref: '#/components/schemas/Spend' }]
+ description: |
+ Indicative spend charged for this single attempt. Zero
+ on attempts that didn't reach the scraping engine or
+ were not charged.
+
+ TaskHistoryResponse:
+ type: object
+ required: [events]
+ properties:
+ events:
+ type: array
+ items: { $ref: '#/components/schemas/TaskHistoryEvent' }
+
+ WebhookConfig:
+ type: object
+ required: [url, signature]
+ description: |
+ Webhook delivery config for `run.completed` / `run.failed`. Returned on
+ `GET /v1/jobs/{id}` (inline under `webhook`) and on
+ `GET /v1/jobs/{id}/webhook`. `PUT` requires both fields —
+ no defaulting at the mutate boundary (otherwise toggling
+ `url` would silently disable signing). Submit body accepts
+ the same shape with `signature` optional (defaults `false`).
+ properties:
+ url:
+ type: string
+ format: uri
+ description: |
+ HTTPS only. Host must resolve via DNS within 1 second
+ (1+ A/AAAA record).
+ signature:
+ type: boolean
+ description: |
+ Opt-in HMAC signing. When `true`, each delivery is signed
+ with the org's active HMAC key
+ (`POST /v1/hmac/keys/rotate`) and carries
+ `X-Signature: t=,v1=,kid=`. The signed
+ input is `t + "." + raw_body`, HMAC-SHA256. When `false`,
+ deliveries carry **no** `X-Signature` header — header
+ absence is the signal.
+
+ TestWebhookRequest:
+ type: object
+ required: [url]
+ description: |
+ Body for `POST /v1/webhook/test`. Same shape as the submit-time
+ `webhook` field; `signature` is optional and defaults `false`.
+ properties:
+ url:
+ type: string
+ format: uri
+ description: |
+ HTTPS only. Host must resolve via DNS within 1 second.
+ Identical validation to the submit/PUT webhook URL.
+ signature:
+ type: boolean
+ default: false
+ description: |
+ When `true`, the test event is signed with the org's
+ active HMAC key — same `X-Signature: t,v1,kid` header
+ real deliveries use. Returns `400
+ webhook_signing_requires_active_key` when no active key
+ exists.
+
+ TestWebhookResponse:
+ type: object
+ required: [delivered, event_id, elapsed_ms]
+ description: |
+ Outcome of the synthetic test dispatch. HTTP status is always
+ `200` when the request was validated — the receiver outcome
+ is in the body. The synthetic envelope uses `event_type:
+ "webhook.test"` and stable sentinel IDs (`job_id`/`run_id` =
+ `"test"`) so receivers can recognise and discard test traffic.
+ properties:
+ delivered:
+ type: boolean
+ description: |
+ `true` iff the receiver responded with a 2xx status. `false`
+ on non-2xx, timeout, or transport error.
+ event_id:
+ type: string
+ description: |
+ ULID of the synthetic event. Different on every call so
+ receivers' dedup tables don't suppress repeated tests.
+ status_code:
+ type: integer
+ description: |
+ HTTP status returned by the receiver. Absent on timeout or
+ transport error (no response was received).
+ error:
+ type: string
+ description: |
+ Human-readable reason when `delivered: false`. Same
+ vocabulary as real webhook deliveries
+ (`http_4xx:`, `http_5xx:`, `timeout`,
+ `transport_error:`). Absent on success.
+ elapsed_ms:
+ type: integer
+ description: Wall-clock duration of the receiver POST in milliseconds.
+
+ FileInputColumnRef:
+ description: |
+ Either a column name (string) — requires `csv.header: true` —
+ or a 0-based column index (integer). Other shapes are
+ rejected at create-time.
+ oneOf:
+ - type: string
+ minLength: 1
+ - type: integer
+ minimum: 0
+
+ CreateJobInputRequest:
+ type: object
+ required: [type]
+ properties:
+ type:
+ type: string
+ enum: [csv]
+ description: Only `csv` is supported in v1.
+ csv:
+ type: object
+ required: [fields]
+ properties:
+ delimiter:
+ type: string
+ minLength: 1
+ maxLength: 1
+ default: ","
+ description: Single-character field delimiter.
+ quote:
+ type: string
+ minLength: 1
+ maxLength: 1
+ default: "\""
+ description: Single-character quoting character.
+ header:
+ type: boolean
+ default: false
+ description: |
+ When true, the first CSV row is consumed as a header
+ row and `csv.fields.*` values may be column names.
+ fields:
+ type: object
+ required: [url]
+ properties:
+ url: { $ref: '#/components/schemas/FileInputColumnRef' }
+ external_id: { $ref: '#/components/schemas/FileInputColumnRef' }
+ additionalProperties: false
+ description: |
+ Map from canonical task field → CSV column. Only
+ `url` (required) and `external_id` (optional) are
+ accepted. Each value is a column index (int) or a
+ column name (string, requires `header: true`).
+
+ FileInputUploadTarget:
+ type: object
+ required: [method, url, expires_at]
+ properties:
+ method:
+ type: string
+ enum: [PUT]
+ url:
+ type: string
+ format: uri
+ description: |
+ Presigned PUT URL. Caller MUST send the body with the
+ exact `Content-Type` shown in `headers` — the signature
+ binds the content-type.
+ headers:
+ type: object
+ additionalProperties: { type: string }
+ example: { "Content-Type": "text/csv" }
+ expires_at:
+ type: string
+ format: date-time
+ description: PUT URL TTL (~30 min).
+
+ CreateJobInputResponse:
+ type: object
+ required: [file_input_id, upload, expires_at]
+ properties:
+ file_input_id: { type: string }
+ upload: { $ref: '#/components/schemas/FileInputUploadTarget' }
+ expires_at:
+ type: string
+ format: date-time
+ description: |
+ 24 h slot lifetime — beyond this the slot and its uploaded
+ body are removed and the `file_input_id` returns 404.
+
+ HMACKeyMeta:
+ type: object
+ required: [kid, created_at]
+ description: |
+ Public view of one HMAC key — id + creation time. Never
+ includes secret material; that's only returned at /rotate.
+ properties:
+ kid:
+ type: string
+ description: ULID identifying this key. Stable for the
+ life of the slot; a new candidate gets a new kid.
+ pattern: '^[0-9A-HJKMNP-TV-Z]{26}$'
+ created_at: { type: string, format: date-time }
+
+ HMACKeyList:
+ type: object
+ description: Slots populated at the time of the call.
+ properties:
+ active: { $ref: '#/components/schemas/HMACKeyMeta' }
+ candidate: { $ref: '#/components/schemas/HMACKeyMeta' }
+
+ HMACKeyCreated:
+ type: object
+ required: [kid, secret, created_at]
+ description: |
+ Response to `/rotate`. `secret` is base64-encoded raw key
+ material. **This is the ONLY response that ever contains
+ the secret value** — capture it now or generate a new one
+ via another /rotate call.
+ properties:
+ kid: { type: string, pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }
+ secret: { type: string, description: "Base64-encoded 32-byte HMAC key." }
+ created_at: { type: string, format: date-time }
+
+ HMACKeyFinalized:
+ type: object
+ required: [active_kid, created_at]
+ description: Response to `/rotate/finalize`. No secret.
+ properties:
+ active_kid: { type: string, pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }
+ created_at: { type: string, format: date-time }
+
+ Problem:
+ type: object
+ description: |
+ RFC 7807 Problem Details.
+ required: [type, title, status, code]
+ properties:
+ type: { type: string, format: uri }
+ title: { type: string }
+ status: { type: integer }
+ detail: { type: string }
+ code: { type: string }
+ instance: { type: string }
+ invalid_tasks:
+ type: array
+ description: Present on validation errors.
+ items:
+ type: object
+ required: [index, reason]
+ properties:
+ index: { type: integer }
+ reason:
+ type: string
+ enum:
+ - malformed_url
+ - unsupported_scheme
+ - missing_host
+ - url_too_long
+ - metadata_too_large
+ - metadata_key_invalid
+ - invalid_external_id
+ - unknown_param
+ - invalid_param_value
+ value:
+ type: string
+ description: |
+ Offending input. For URL / metadata reasons this is
+ a redacted/truncated form of the bad value. For
+ `unknown_param` / `invalid_param_value` this is the
+ param key.
+ additionalProperties: true
+
+ ExportStatus:
+ type: string
+ enum: [pending, running, completed, failed]
+ description: |
+ Lifecycle state of a results export.
+ * `pending` — export accepted, not started yet.
+ * `running` — the zip is being produced.
+ * `completed` — `download_url` will be present.
+ * `failed` — `error` carries the reason.
+
+ StartExportResponse:
+ type: object
+ required: [export_id, status, created_at, expires_at]
+ description: Returned by `startResultsExport`.
+ properties:
+ export_id:
+ type: string
+ description: ULID identifying this export. Use it for `getResultsExport`.
+ pattern: '^[0-9A-HJKMNP-TV-Z]{26}$'
+ status:
+ $ref: '#/components/schemas/ExportStatus'
+ created_at: { type: string, format: date-time }
+ expires_at:
+ type: string
+ format: date-time
+ description: |
+ 12 h after `created_at`. Past this point the export and
+ its download are removed and the export id 404s.
+
+ Export:
+ type: object
+ required: [export_id, status, created_at, expires_at]
+ description: |
+ Polled view of a results export. `download_url` is presigned
+ fresh on every successful poll — stash the metadata, but
+ re-fetch the URL right before you download.
+ properties:
+ export_id:
+ type: string
+ pattern: '^[0-9A-HJKMNP-TV-Z]{26}$'
+ status:
+ $ref: '#/components/schemas/ExportStatus'
+ error:
+ type: string
+ nullable: true
+ description: |
+ Non-empty only when `status = failed`. Stable strings —
+ e.g. `"results are larger then 1 gb"` when the combined
+ results exceed the 1 GiB cap.
+ download_url:
+ type: string
+ format: uri
+ description: |
+ Presigned download URL for the zipped run results.
+ Present only when `status = completed`. Short-lived — the
+ server mints a new one on every poll.
+ created_at: { type: string, format: date-time }
+ expires_at:
+ type: string
+ format: date-time
+ description: |
+ 12 h after `created_at`. The download is unavailable
+ after this point.
diff --git a/examples/01_submit_and_wait.py b/examples/01_submit_and_wait.py
new file mode 100644
index 0000000..afc993d
--- /dev/null
+++ b/examples/01_submit_and_wait.py
@@ -0,0 +1,54 @@
+"""01: Submit → wait → iterate results.
+
+The canonical end-to-end flow. Demonstrates:
+ - `submit_regular(urls, ...)` — the type-specific shortcut that
+ skips the `type=`/`status=` boilerplate. Accepts either bare URL
+ strings or inline dicts with per-task `external_id` / `metadata`
+ / `params`.
+ - The returned `JobRef` knows its client, so current-run ops chain
+ off the `.run` facet (`job.run.wait()` / `job.run.results()`)
+ without re-passing the job id.
+ - `job.run.wait()` returns a `RunHandle` for the run you waited on
+ (guaranteed `.data`), so `run.results()` and
+ `run.download_to_dir(...)` are one call away.
+
+Run with:
+ export ZENROWS_API_KEY=zr_...
+ python examples/01_submit_and_wait.py
+"""
+
+import os
+
+from zenrows import ZenRowsBatchClient
+
+
+def main() -> None:
+ client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"])
+
+ job = client.submit_regular(
+ [
+ {"url": "https://example.com/a", "external_id": "order-1"},
+ {"url": "https://example.com/b", "external_id": "order-2"},
+ {"url": "https://example.com/c", "external_id": "order-3"},
+ ],
+ zenrows_params={"js_render": "true", "premium_proxy": "true"},
+ )
+ print(f"submitted {job}")
+
+ # Block until the current run is terminal (default target:
+ # `completed | stopped | deleted`). Internally jittered
+ # exponential backoff — friendly to the API for both 5-second
+ # and 5-minute jobs. Returns the RunHandle.
+ run = job.run.wait(timeout=600.0)
+ t = run.data.stats
+ print(f"run {run.run_id} {run.data.status.value}: {t.successful}/{t.total} successful")
+
+ for row in run.results(status="successful"):
+ # external_id is whatever the caller supplied at submit; it
+ # rides through to results unmodified so caller systems can
+ # correlate without knowing our task_id.
+ print(f" {row.external_id or row.task_id} -> {row.result_url}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/02_download_to_dir.py b/examples/02_download_to_dir.py
new file mode 100644
index 0000000..3c185f8
--- /dev/null
+++ b/examples/02_download_to_dir.py
@@ -0,0 +1,81 @@
+"""02: Bulk download every successful body to disk.
+
+The Batch API doesn't ship bodies inline — results give you metadata
++ a presigned `result_url` per task. For real bulk ingestion you
+usually want every body on disk; `download_to_dir` bundles
+"list → presigned GET → write" into one call with built-in safety
+caps + optional parallelism + progress bar.
+
+Highlights:
+ - Resource-style: `client.get_job(...)` returns a `JobHandle` so
+ the current-run wait + download are one chain off `.run`.
+ - `concurrency=N` fans body-fetches across a ThreadPool.
+ - `progress=True` shows a tqdm bar (soft dep — degrades to no-op
+ if tqdm isn't installed).
+ - `use_external_id=True` writes `.` filenames
+ instead of the default `.`. Useful when the
+ downstream pipeline addresses files by your own ids; ids are
+ coerced to safe filenames (chars outside `[A-Za-z0-9._-]` become
+ `_`), missing ids fall back to task_id, and clashes get `_01`,
+ `_02`, … appended.
+ - `max_files` + `max_bytes_per_file` are tunable safety caps that
+ raise `DownloadLimitExceeded` — a runaway job can't silently
+ fill the disk.
+ - `status="successful"` (the default) skips failed rows; pass
+ `status=None` to grab everything.
+
+Run with:
+ export ZENROWS_API_KEY=zr_...
+ python examples/02_download_to_dir.py --job-id 01J…
+"""
+
+import argparse
+import os
+
+from zenrows import ZenRowsBatchClient
+from zenrows.batch import DownloadLimitExceeded
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument(
+ "--job-id",
+ required=True,
+ help="Job id whose results you want to download.",
+ )
+ parser.add_argument(
+ "--out",
+ default="./out",
+ help="Target directory (default: ./out).",
+ )
+ parser.add_argument(
+ "--concurrency",
+ type=int,
+ default=8,
+ help="Parallel body-fetch workers (default: 8).",
+ )
+ args = parser.parse_args()
+
+ client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"])
+ job = client.get_job(args.job_id)
+ run = job.run.wait(timeout=600.0, progress=True)
+
+ try:
+ written = run.download_to_dir(
+ args.out,
+ use_external_id=True, # name files by caller's id
+ concurrency=args.concurrency, # parallel body fetches
+ progress=True, # live tqdm bar
+ max_files=20_000, # cap row count
+ max_bytes_per_file=10 * 1024 * 1024, # cap single body @ 10 MiB
+ )
+ print(f"wrote {written} files to {args.out}/")
+ except DownloadLimitExceeded as exc:
+ # `limit_name` is one of: max_files, max_bytes_per_file.
+ # `limit` + `observed` for diagnosis.
+ print(f"aborted: {exc.limit_name} cap hit ({exc.observed} > {exc.limit})")
+ raise
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/03_csv_input.py b/examples/03_csv_input.py
new file mode 100644
index 0000000..d392d26
--- /dev/null
+++ b/examples/03_csv_input.py
@@ -0,0 +1,60 @@
+"""03: Submit thousands of URLs via a CSV upload.
+
+For jobs with too many URLs to fit comfortably in a JSON payload,
+the Batch API exposes a two-step upload flow:
+
+ 1. POST /job_inputs → presigned PUT URL + file_input_id
+ 2. PUT → upload the CSV body
+ 3. POST /jobs → reference the file_input_id
+
+`upload_csv` collapses (1)+(2) into one call; `submit_regular`
+takes the resulting `file_input_id` as a kwarg instead of inline
+`urls`. Eligible for `regular` (closed) and `scheduled` jobs.
+
+The `fields` map says how to interpret the CSV. Each value is either:
+ - an integer → 0-based column index (works regardless of header)
+ - a string → the column's header name (requires `header=True`)
+
+`url` is required; `external_id` is optional.
+
+Run with:
+ export ZENROWS_API_KEY=zr_...
+ python examples/03_csv_input.py
+"""
+
+import os
+import tempfile
+from pathlib import Path
+
+from zenrows import ZenRowsBatchClient
+
+
+def main() -> None:
+ client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"])
+
+ # Toy CSV for the sake of the example. In real use this is a
+ # large file on disk you'd pass a Path to.
+ csv = "URL,Customer Ref\nhttps://example.com/a,cust-1\nhttps://example.com/b,cust-2\n"
+ with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False) as tmp:
+ tmp.write(csv)
+ csv_path = Path(tmp.name)
+
+ file_input_id = client.upload_csv(
+ csv_path,
+ fields={"url": "URL", "external_id": "Customer Ref"},
+ header=True,
+ )
+ print(f"uploaded slot {file_input_id}")
+
+ # `submit_regular(file_input_id=...)` — no inline urls, no
+ # `type=`/`status=` boilerplate. Returns a JobRef ready to
+ # `.run.wait()` and `.run.results()` against.
+ job = client.submit_regular(
+ file_input_id=file_input_id,
+ zenrows_params={"js_render": "true"},
+ )
+ print(f"submitted {job}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/04_paginated_scanners.py b/examples/04_paginated_scanners.py
new file mode 100644
index 0000000..c61e053
--- /dev/null
+++ b/examples/04_paginated_scanners.py
@@ -0,0 +1,61 @@
+"""04: Cursor-free browsing with the paginated scanners.
+
+Every list endpoint on the Batch API is cursor-paginated (`limit` +
+`next_cursor`). Threading cursors by hand is the #1 source of bugs
+in user code, so the SDK ships scanners that drain to exhaustion:
+
+ - `client.iter_jobs(...)` → `Iterator[JobHandle]`
+ - `client.iter_runs(job_id)` → `Iterator[RunHandle]`
+ - `client.iter_results(job_id, ...)` → `Iterator[TaskResult]`
+
+Each is a generator — memory stays bounded; pages are fetched on
+demand. Filtering kwargs (`job_type=`, `status=`) pass through to
+the underlying list call.
+
+You still have `list_jobs / list_runs / list_results` if you want
+the raw page + cursor.
+
+Run with:
+ export ZENROWS_API_KEY=zr_...
+ python examples/04_paginated_scanners.py
+ python examples/04_paginated_scanners.py --job-id 01J…
+"""
+
+import argparse
+import os
+
+from zenrows import ZenRowsBatchClient
+from zenrows.batch import JobStatus, JobType
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument(
+ "--job-id",
+ default=None,
+ help="Optional job id; if set, also lists every run of that job.",
+ )
+ args = parser.parse_args()
+
+ client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"])
+
+ # Show every closed regular job, newest first. Each yielded item
+ # is a JobHandle with `.data` already populated from the page.
+ print("--- closed regular jobs ---")
+ for job in client.iter_jobs(job_type=JobType.REGULAR, status=JobStatus.CLOSED):
+ latest = job.data.latest_run
+ total = latest.stats.total if latest else 0
+ print(f"{job.job_id} {job.status.value:<8} tasks={total}")
+
+ # For one job, walk all its runs (handy for `/rerun`-heavy
+ # workflows). `client.iter_runs` yields RunHandle; `.data` is
+ # pre-populated from the page.
+ if args.job_id:
+ print(f"\n--- runs of {args.job_id} ---")
+ for run in client.iter_runs(args.job_id, page_size=50):
+ r = run.data
+ print(f" {run.run_id} #{r.run_sequence} {r.status.value}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/05_error_handling.py b/examples/05_error_handling.py
new file mode 100644
index 0000000..7ee7f21
--- /dev/null
+++ b/examples/05_error_handling.py
@@ -0,0 +1,49 @@
+"""05: RFC 7807 → `BatchAPIError` branching.
+
+Every non-2xx response from the Batch API is a Problem JSON body
+with a stable `code` field. The SDK parses it once and exposes:
+
+ - `BatchAPIError.code` — short string (`not_found`,
+ `idempotency_key_conflict`,
+ `file_input_not_found`, ...). Safe
+ to switch on.
+ - `BatchAPIError.status_code`— int from the HTTP response.
+ - `BatchAPIError.problem` — full `ProblemDetail` if the body
+ parsed; `None` otherwise.
+
+Switching on `code` is the recommended pattern — status codes are
+not 1:1 with semantics (multiple 409s, multiple 404s, etc.).
+
+Run with:
+ export ZENROWS_API_KEY=zr_...
+ python examples/05_error_handling.py
+"""
+
+import os
+
+from zenrows import ZenRowsBatchClient
+from zenrows.batch import BatchAPIError
+
+
+def main() -> None:
+ client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"])
+
+ try:
+ client.get_job("definitely-not-a-real-job-id")
+ except BatchAPIError as exc:
+ match exc.code:
+ case "not_found":
+ print(f"job missing or foreign: {exc.problem and exc.problem.detail}")
+ case "unauthenticated":
+ print("bad API key — check ZENROWS_API_KEY")
+ case "payment_required":
+ print("out of credits — top up at zenrows.com")
+ case _:
+ # Unknown code: log + re-raise so it surfaces to a
+ # monitoring layer instead of being silently swallowed.
+ print(f"unexpected {exc.status_code} ({exc.code}): {exc}")
+ raise
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/06_retry_failed.py b/examples/06_retry_failed.py
new file mode 100644
index 0000000..b53d46b
--- /dev/null
+++ b/examples/06_retry_failed.py
@@ -0,0 +1,59 @@
+"""06: Retry only the failed tasks of a finished run.
+
+`job.retry_failed()` starts a fresh run that re-executes ONLY the
+previous run's failed tasks (partial retry, SPEC §3.5). Successful
+tasks are inherited verbatim — you don't pay to re-scrape them, and
+the new run's totals already carry the prior successes. Pass
+`include_pending=True` to also re-enqueue tasks that never started
+(handy after a `stop()`). It's a thin shortcut for
+`job.rerun(status="failed")`.
+
+Requires the previous run to be terminal (`completed` / `stopped`);
+otherwise the API returns `409 run_not_terminal` — call
+`job.run.stop()` first if it's still live.
+
+Run with:
+ export ZENROWS_API_KEY=zr_...
+ python examples/06_retry_failed.py --job-id 01J…
+"""
+
+import argparse
+import os
+
+from zenrows import ZenRowsBatchClient
+from zenrows.batch import BatchAPIError
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("--job-id", required=True, help="Job whose latest run finished.")
+ parser.add_argument(
+ "--include-pending",
+ action="store_true",
+ help="Also retry tasks that never started (status=failed,pending).",
+ )
+ args = parser.parse_args()
+
+ client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"])
+ job = client.get_job(args.job_id)
+
+ before = job.data.latest_run
+ if before:
+ t = before.stats
+ print(f"latest run {before.run_id}: {t.failed} failed / {t.total} total")
+
+ try:
+ run = job.retry_failed(include_pending=args.include_pending)
+ except BatchAPIError as e:
+ # e.g. no_matching_tasks (nothing failed) or run_not_terminal.
+ print(f"retry skipped: {e.code}")
+ return
+
+ print(f"retry run {run.run_id} started; waiting…")
+ run = run.wait(timeout=600.0)
+ t = run.data.stats
+ print(f"run {run.run_id} {run.data.status.value}: {t.successful}/{t.total} successful")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/07_hmac_rotation.py b/examples/07_hmac_rotation.py
new file mode 100644
index 0000000..d3aea5d
--- /dev/null
+++ b/examples/07_hmac_rotation.py
@@ -0,0 +1,58 @@
+"""07: Per-org HMAC key lifecycle.
+
+The Batch API holds an HMAC keyset per org for signing future
+outbound webhooks. Two slots — `active` (used for signing) and
+`candidate` (staged next-active during rotation). The state machine:
+
+ (empty) ──rotate──► [active=K1]
+ [K1] ──rotate──► [active=K1, candidate=K2]
+ [K1,K2] ─finalize→ [active=K2] (K1 discarded)
+ [K1,K2] ─cancel──► [active=K1] (K2 discarded)
+
+The `secret` field is returned **only** in the `/rotate` response —
+this is your one chance to capture it. Subsequent reads give you
+just `kid` + `created_at`.
+
+Typical client flow:
+ 1. `rotate_hmac_key()` → install K2 alongside K1 in your verifier
+ 2. (deploy K2 everywhere)
+ 3. `finalize_hmac_key()` → server starts signing with K2; drop K1
+
+Run with:
+ export ZENROWS_API_KEY=zr_...
+ python examples/07_hmac_rotation.py
+"""
+
+import os
+
+from zenrows import ZenRowsBatchClient
+from zenrows.batch import BatchAPIError
+
+
+def main() -> None:
+ client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"])
+
+ keys = client.list_hmac_keys()
+ print(f"active: {keys.active and keys.active.kid}")
+ print(f"candidate: {keys.candidate and keys.candidate.kid}")
+
+ # Initial generation OR staging a candidate — same endpoint, the
+ # server decides which based on current state.
+ try:
+ rotated = client.rotate_hmac_key()
+ # Capture `rotated.secret` HERE — it is not returned again.
+ print(f"new key kid={rotated.kid} secret={rotated.secret!r}")
+ except BatchAPIError as exc:
+ if exc.code == "candidate_pending":
+ print("a candidate is already staged — finalize or cancel first")
+ return
+ raise
+
+ # Once your verifiers accept the new key, promote it:
+ # client.finalize_hmac_key()
+ # ...or back out:
+ # client.cancel_hmac_rotation()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/08_download_all_results.py b/examples/08_download_all_results.py
new file mode 100644
index 0000000..26cfb79
--- /dev/null
+++ b/examples/08_download_all_results.py
@@ -0,0 +1,48 @@
+"""08: Download a whole run as one zip (server-side export).
+
+`download_all_results(path)` drives the async export flow:
+`POST .../exports` kicks off a server-side zip of every task body,
+the SDK polls until it's `completed`, then streams the presigned zip
+to disk. Use this when you want a single artifact; for one file per
+task instead, see `download_to_dir` (example 02).
+
+The zip has a 12h TTL and is capped at 1 GiB server-side — an
+oversize run fails the export and raises `BatchAPIError`. For larger
+runs (or one file per task), use the iterative client-side download
+`download_to_dir` (example 02): it has no size limit but is slower,
+fetching bodies one at a time (tunable via `concurrency=`).
+
+`job.run.download_all_results` targets the job's current run; address
+a specific run with `client.get_run(job_id, run_id=...).download_all_results(...)`.
+
+Run with:
+ export ZENROWS_API_KEY=zr_...
+ python examples/08_download_all_results.py --job-id 01J… --out results.zip
+"""
+
+import argparse
+import os
+
+from zenrows import ZenRowsBatchClient
+from zenrows.batch import BatchAPIError
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("--job-id", required=True, help="Job whose latest run to export.")
+ parser.add_argument("--out", default="results.zip", help="Where to write the zip.")
+ args = parser.parse_args()
+
+ client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"])
+ job = client.get_job(args.job_id)
+
+ try:
+ path = job.run.download_all_results(args.out)
+ print(f"wrote {path}")
+ except BatchAPIError as e:
+ # e.g. the run exceeded the 1 GiB export cap.
+ print(f"export failed: {e}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/09_scheduled_jobs.py b/examples/09_scheduled_jobs.py
new file mode 100644
index 0000000..fe6df1f
--- /dev/null
+++ b/examples/09_scheduled_jobs.py
@@ -0,0 +1,140 @@
+"""09: Scheduled jobs — the full lifecycle.
+
+A `scheduled` job runs its task template automatically on a cadence;
+each fire produces a fresh Run. This walks the actions you'll actually
+use:
+
+ - Create with the three typed cadence builders: `Rate` (fixed
+ interval), `Calendar` (times-of-day on a Daily/Weekly/Monthly
+ cadence), and `At` (a one-shot future fire).
+ - Attach a webhook so each fire's outcome is pushed to you — the
+ natural signal for a fire-and-forget job (`run.completed`, or
+ `run.failed` with a `failure_reason` on an account-level error).
+ - Manage the schedule via the `job.schedule` facet:
+ `pause()` / `resume()` the fires and `update()` the cadence.
+ (Distinct from `job.run.pause()`, which suspends the current run.)
+ - Inspect the Runs each fire produced and re-run a fire's failures.
+ - Tear it down with `delete()`.
+
+Run with:
+ export ZENROWS_API_KEY=zr_...
+ python examples/09_scheduled_jobs.py
+"""
+
+import os
+from datetime import datetime
+
+from zenrows import ZenRowsBatchClient
+from zenrows.batch import At, Calendar, JobHandle, Monthly, Rate, Weekly
+
+
+def _schedule_state(job: JobHandle) -> str:
+ """schedule_state is None on non-scheduled / legacy rows; here it's
+ always set, but keep the read total for the type-checker."""
+ state = job.data.schedule_state
+ return state.value if state else "?"
+
+
+def main() -> None:
+ client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"])
+
+ # --- 1. Create: a recurring job on a fixed interval -----------------
+ # `Rate` fires every N minutes/hours/days from creation. Attach a
+ # webhook: scheduled jobs are fire-and-forget, so a webhook is how you
+ # learn each fire finished (`run.completed`) or auto-failed on an
+ # account error (`run.failed`, carrying `failure_reason`).
+ job = client.submit_scheduled(
+ Rate(every=6, unit="hour"),
+ [
+ {"url": "https://example.com/prices", "external_id": "prices"},
+ {"url": "https://example.com/stock", "external_id": "stock"},
+ ],
+ zenrows_params={"js_render": "true"},
+ name="market-poller",
+ webhook={"url": "https://hooks.example.com/zenrows", "signature": True},
+ )
+ print(f"created scheduled job {job.job_id} — every 6h ({job.status.value})")
+
+ # --- 2. Other cadences ----------------------------------------------
+ # Calendar: specific wall-clock times on a Weekly/Daily/Monthly
+ # cadence, in an explicit timezone.
+ weekly = client.submit_scheduled(
+ Calendar(
+ times_of_day=["09:00", "18:00"],
+ cadence=Weekly(days=["mon", "wed", "fri"]),
+ timezone="Europe/Berlin",
+ ),
+ ["https://example.com/report"],
+ name="triweekly-report",
+ )
+ print(f"created {weekly.job_id} — Mon/Wed/Fri 09:00+18:00 Berlin")
+
+ # Monthly on given day-of-month numbers.
+ monthly = client.submit_scheduled(
+ Calendar(
+ times_of_day=["00:00"],
+ cadence=Monthly(days=[1, 15]),
+ timezone="UTC",
+ ),
+ ["https://example.com/invoice"],
+ name="billing-scrape",
+ )
+ print(f"created {monthly.job_id} — 1st + 15th at midnight UTC")
+
+ # At: a single future fire (naive local datetime + timezone).
+ oneshot = client.submit_scheduled(
+ At(datetime(2026, 9, 1, 9, 0), timezone="Europe/Berlin"),
+ ["https://example.com/launch-day"],
+ name="launch-day",
+ )
+ print(f"created {oneshot.job_id} — one-shot 2026-09-01 09:00 Berlin")
+
+ # --- 3. Manage the schedule (via the `job.schedule` facet) ----------
+ # Pause: the schedule keeps ticking server-side but fires are dropped
+ # until resume(). Idempotent. Each op returns a fresh JobHandle with
+ # the updated state.
+ paused = job.schedule.pause()
+ print(f"paused {job.job_id} (schedule_state={_schedule_state(paused)})")
+
+ # Change the cadence while paused. An in-flight run keeps running; the
+ # new schedule governs only future fires.
+ job.schedule.update(Rate(every=30, unit="minute"))
+ print(f"re-scheduled {job.job_id} → every 30m")
+
+ # Resume fires.
+ resumed = job.schedule.resume()
+ print(f"resumed {job.job_id} (schedule_state={_schedule_state(resumed)})")
+
+ # --- 4. Fire it now and inspect the resulting run -------------------
+ # A scheduled job's fires ARE runs. Rather than wait for the cadence,
+ # `rerun()` on a scheduled job with no prior run fires it from the
+ # template immediately — handy to smoke-test the job right after
+ # creating it. (`job.run.load()` would raise here otherwise, since
+ # a fresh schedule hasn't fired yet.)
+ run = job.rerun()
+ print(f"manually fired {job.job_id} → run {run.run_id}")
+
+ run.wait(timeout=600.0) # block until this fire is terminal
+ s = run.data.stats
+ print(f" {run.data.status.value}: {s.successful}/{s.total} ok")
+ # A fire that auto-failed on an account error exposes why.
+ if run.data.status.value == "failed":
+ print(f" auto-failed: {run.data.failure_reason}")
+ # Re-run just this fire's failures — successful tasks are inherited, so
+ # you only pay to re-scrape what failed.
+ if s.failed:
+ retry = job.retry_failed()
+ print(f" retried failures → run {retry.run_id}")
+
+ # Every fire (scheduled or manual) is a Run under the job, newest-first.
+ for r in job.runs():
+ print(f" run {r.run_id} #{r.data.run_sequence} {r.data.status.value}")
+
+ # --- 5. Tear down ---------------------------------------------------
+ for handle in (job, weekly, monthly, oneshot):
+ handle.delete()
+ print("deleted all demo jobs")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/10_lightweight_handles.py b/examples/10_lightweight_handles.py
new file mode 100644
index 0000000..ec039f0
--- /dev/null
+++ b/examples/10_lightweight_handles.py
@@ -0,0 +1,54 @@
+"""10: Lightweight refs — act on a job/run id without a GET.
+
+`client.job(job_id)` and `client.run(job_id, run_id)` mint a `JobRef` /
+`RunRef` with **no network call**. Lifecycle operations (`delete`, `stop`,
+`close`, `rerun`, `retry_failed`, `add_tasks`) act on the id directly — so
+when an id arrives from a webhook, a queue, or your own DB, you skip the
+round-trip that `get_job` would spend just to fetch data you don't need.
+
+A ref carries no `.data`; call `.load()` for a `JobHandle` / `RunHandle`
+whose `.data` snapshot is ready. Reach for `get_job` / `get_run` when you
+want that data eagerly up front (they're `client.job(id).load()` in one
+call).
+
+Run with:
+ export ZENROWS_API_KEY=zr_...
+ python examples/10_lightweight_handles.py --job-id 01J... [--run-id 01J...] [--delete]
+"""
+
+import argparse
+import os
+
+from zenrows import ZenRowsBatchClient
+
+
+def main() -> None:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--job-id", required=True)
+ ap.add_argument("--run-id", default=None, help="scrub just this run (with --delete)")
+ ap.add_argument("--delete", action="store_true", help="actually stop + delete (destructive)")
+ args = ap.parse_args()
+
+ client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"])
+
+ # Minting a ref is free — no request goes out on this line.
+ job = client.job(args.job_id)
+
+ # `.load()` does the one GET, returning a JobHandle with `.data`.
+ print(f"{args.job_id}: status={job.load().data.status.value}")
+
+ if args.delete:
+ # Act on the id directly — each of these is a single request with no
+ # preceding GET (contrast `get_job(id).delete()`, which fetches first).
+ if args.run_id:
+ client.run(args.job_id, args.run_id).delete() # DELETE one run only
+ print(f"deleted run {args.run_id}")
+ client.job(args.job_id).run.stop() # POST /jobs/{id}/stop (current run)
+ client.job(args.job_id).delete() # DELETE /jobs/{id}
+ print(f"stopped + deleted {args.job_id}")
+ else:
+ print("(re-run with --delete to stop + delete via GET-free refs)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..ca93890
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,32 @@
+# Examples
+
+Runnable samples for the Batch API client (`ZenRowsBatchClient`).
+Each script reads the API key from the environment:
+
+```bash
+export ZENROWS_API_KEY=zr_...
+```
+
+Script-specific flags (`--job-id`, `--out`, …) are surfaced via
+`argparse`; each file's `--help` lists them.
+
+```bash
+uv run python examples/01_submit_and_wait.py
+uv run python examples/02_download_to_dir.py --job-id 01J...
+```
+
+| Sample | Highlights |
+|---|---|
+| `01_submit_and_wait.py` | `submit_regular` → `job.wait()` → `run.results()` |
+| `02_download_to_dir.py` | bulk download to disk with `concurrency=` + `progress=` |
+| `03_csv_input.py` | `upload_csv` helper end-to-end (slot + PUT + submit) |
+| `04_paginated_scanners.py` | `iter_jobs` + `iter_runs` for cursor-free browsing |
+| `05_error_handling.py` | RFC 7807 → `BatchAPIError.code` branching |
+| `06_retry_failed.py` | `retry_failed()` — partial rerun of only the failed tasks |
+| `07_hmac_rotation.py` | rotate / finalize / cancel lifecycle |
+| `08_download_all_results.py` | `download_all_results()` — server-side export zip of a whole run |
+| `09_scheduled_jobs.py` | scheduled cadences (`Rate`/`Calendar`/`At`), `pause`/`resume`/`update_schedule`, per-fire runs, `retry_failed` |
+| `10_lightweight_handles.py` | `client.job(id)` / `client.run(id, rid)` — act on a known id without a GET (`.data` lazily fetches) |
+
+The samples are deliberately small — each demonstrates one feature
+end-to-end so they double as living documentation.
diff --git a/flake8 b/flake8
deleted file mode 100644
index 6deafc2..0000000
--- a/flake8
+++ /dev/null
@@ -1,2 +0,0 @@
-[flake8]
-max-line-length = 120
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..6cd8752
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,114 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "zenrows"
+version = "1.4.0"
+description = "ZenRows Python SDK — synchronous scraping + Batch (async job) API"
+readme = "README.md"
+license = { text = "MIT" }
+authors = [
+ { name = "ZenRows", email = "support@zenrows.com" },
+]
+keywords = ["zenrows", "scraper", "scraping", "async", "batch"]
+classifiers = [
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Topic :: Internet :: WWW/HTTP",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+]
+requires-python = ">=3.10"
+dependencies = [
+ # Legacy scraping client (zenrows.ZenRowsClient).
+ "requests>=2.31",
+ # Batch (zenrows.batch) — pydantic v2 models + httpx transport.
+ "httpx>=0.27",
+ "pydantic>=2.9",
+ # Progress bars for `wait_for_run` / `download_to_*`. Tiny, no
+ # transitive deps; users in minimal envs can still uninstall it
+ # — the helpers lazy-import and degrade to no-op.
+ "tqdm>=4.66",
+ # `typing.NotRequired` is 3.11+; on 3.10 it comes from here.
+ # (pydantic already pulls this transitively — declared explicitly.)
+ "typing-extensions>=4.0; python_version < '3.11'",
+]
+
+[project.urls]
+Homepage = "https://www.zenrows.com/"
+Documentation = "https://www.zenrows.com/documentation"
+Repository = "https://github.com/ZenRows/zenrows-python-sdk"
+Issues = "https://github.com/ZenRows/zenrows-python-sdk/issues"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/zenrows"]
+
+[tool.hatch.build.targets.sdist]
+include = ["src/zenrows", "README.md", "LICENSE"]
+
+# --- uv ---
+[tool.uv]
+dev-dependencies = [
+ "pytest>=8",
+ "pytest-asyncio>=0.24",
+ "pytest-httpx>=0.30",
+ "ruff>=0.7",
+ "respx>=0.21",
+ # Astral static type checker (preview). Dev-only — never ships.
+ # Unpinned on purpose: we ride latest. It moves fast; if a release
+ # turns `make check` red, that's the signal, not a regression to revert.
+ "ty",
+ # OpenAPI → pydantic-v2 models. Run via `uv run make-models`
+ # (Makefile recipe). Output lands at src/zenrows/batch/models.py.
+ "datamodel-code-generator>=0.26",
+]
+
+# --- ruff ---
+[tool.ruff]
+line-length = 100
+target-version = "py310"
+# Generated code is owned by the codegen tool; don't lint it.
+extend-exclude = ["src/zenrows/batch/models.py"]
+
+[tool.ruff.lint]
+# Tasteful default. Add rules over time; don't import-storm now.
+select = [
+ "E", # pycodestyle errors
+ "F", # pyflakes
+ "W", # pycodestyle warnings
+ "I", # isort
+ "B", # flake8-bugbear
+ "UP", # pyupgrade — keeps us on modern syntax (no __future__)
+ "SIM", # flake8-simplify
+ "RUF", # ruff-specific
+]
+ignore = [
+ # We deliberately don't use `from __future__ import annotations` —
+ # 3.10 is the floor and `X | Y` is native.
+ "UP037",
+]
+
+[tool.ruff.lint.isort]
+known-first-party = ["zenrows"]
+
+[tool.ruff.format]
+quote-style = "double"
+indent-style = "space"
+
+# --- ty (static type checker) ---
+[tool.ty.src]
+# Generated code is owned by the codegen tool; don't type-check it
+# (still imported for type resolution — exclude only suppresses its diagnostics).
+exclude = ["src/zenrows/batch/models.py"]
+
+# --- pytest ---
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "-ra --strict-markers"
+asyncio_mode = "auto"
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 0c27a5b..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-flake8==7.1.1
-wheel==0.46.2
diff --git a/scripts/build_reference.py b/scripts/build_reference.py
new file mode 100644
index 0000000..3e1d5ec
--- /dev/null
+++ b/scripts/build_reference.py
@@ -0,0 +1,73 @@
+"""Generate docs/batch-client-reference.md — a PUBLIC-facing markdown API reference.
+
+Renders each public module with pydoc-markdown, then presents it as the
+public import surface (`from zenrows.batch import ...`) rather than where
+things physically live:
+
+ - the internal module headers (`# zenrows.batch._resources`) become
+ friendly section titles, and
+ - the `zenrows.batch._x.` qualifiers are stripped from anchors and inline
+ references,
+
+so the reference never exposes the private `_module` layout. The modules
+stay private on purpose (curated `__all__` re-export from the package) —
+this only fixes how the docs are presented.
+
+Run via `make docs` (which supplies pydoc-markdown through `uv --with`).
+"""
+
+import re
+import subprocess
+import sys
+
+# (module, public section title) — order defines the reference layout.
+SECTIONS = [
+ ("zenrows.batch.client", "Client"),
+ ("zenrows.batch._resources", "Job, run & export handles"),
+ ("zenrows.batch._waiters", "Waiters"),
+ ("zenrows.batch._download", "Downloads"),
+ ("zenrows.batch._estimate", "Cost estimation"),
+ ("zenrows.batch._schedule", "Schedule builders"),
+ ("zenrows.batch.errors", "Errors"),
+ ("zenrows.batch.models", "Models"),
+]
+
+HEADER = (
+ "# ZenRows Batch — Python SDK Reference\n\n"
+ "_Auto-generated from the SDK docstrings via `make docs`. Do not edit by "
+ "hand. Everything below is imported from the top-level `zenrows.batch` "
+ "package (`from zenrows.batch import ...`)._\n"
+)
+
+# Member-qualified references like `zenrows.batch._resources.JobHandle`
+# (anchors + inline) → bare `JobHandle` (the public import name).
+_QUALIFIER = re.compile(r"zenrows\.batch\.[A-Za-z_][A-Za-z0-9_]*\.")
+
+
+def render(module: str, title: str) -> str:
+ out = subprocess.run(
+ ["pydoc-markdown", "-m", module, "-I", "src"],
+ capture_output=True,
+ text=True,
+ check=True,
+ ).stdout
+ # Drop the leading module anchor + `# zenrows.batch.` header and put
+ # the friendly section title in its place.
+ out = re.sub(
+ r'\A\n\n# [^\n]*\n',
+ f"# {title}\n",
+ out,
+ count=1,
+ )
+ # Strip the private/module qualifiers everywhere else.
+ out = _QUALIFIER.sub("", out)
+ return out.rstrip() + "\n"
+
+
+def main() -> None:
+ parts = [HEADER, *(render(mod, title) for mod, title in SECTIONS)]
+ sys.stdout.write("\n".join(parts))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 588730b..0000000
--- a/setup.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import os
-from setuptools import setup
-
-from zenrows.__version__ import __version__
-
-
-def read(fname):
- return open(os.path.join(os.path.dirname(__file__), fname)).read()
-
-
-setup(
- name="zenrows",
- version=__version__,
- author="Ander Rodriguez",
- author_email="ander@zenrows.com",
- description="Python client for ZenRows API",
- license="MIT",
- keywords="zenrows scraper scraping",
- url="https://github.com/ZenRows/zenrows-python-sdk",
- project_urls={
- "Bug Tracker": "https://github.com/ZenRows/zenrows-python-sdk/issues",
- "Documentation": "https://www.zenrows.com/documentation",
- },
- packages=["zenrows"],
- long_description=read("README.md"),
- long_description_content_type="text/markdown",
- classifiers=[
- "License :: OSI Approved :: MIT License",
- "Operating System :: OS Independent",
- "Programming Language :: Python",
- "Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.6",
- "Programming Language :: Python :: 3.7",
- "Programming Language :: Python :: 3.8",
- "Programming Language :: Python :: 3.9",
- "Topic :: Internet :: Proxy Servers",
- "Topic :: Internet :: WWW/HTTP",
- "Topic :: Software Development :: Libraries :: Python Modules",
- ],
- python_requires=">=3.6",
- install_requires=["requests"],
-)
diff --git a/src/zenrows/__init__.py b/src/zenrows/__init__.py
new file mode 100644
index 0000000..312ed5e
--- /dev/null
+++ b/src/zenrows/__init__.py
@@ -0,0 +1,17 @@
+"""ZenRows Python SDK.
+
+Two clients live here:
+
+ - `ZenRowsClient` — the original synchronous scraping API client.
+ Backward-compatible with pre-1.4 usage; freshened-up internals.
+
+ - `ZenRowsBatchClient` — the new async-job / batch API client.
+ Built on a generated OpenAPI core with a hand-written ergonomic
+ facade on top.
+"""
+
+from zenrows.__version__ import __version__
+from zenrows.batch import ZenRowsBatchClient
+from zenrows.client import ZenRowsClient
+
+__all__ = ["ZenRowsBatchClient", "ZenRowsClient", "__version__"]
diff --git a/zenrows/__version__.py b/src/zenrows/__version__.py
similarity index 100%
rename from zenrows/__version__.py
rename to src/zenrows/__version__.py
diff --git a/src/zenrows/batch/__init__.py b/src/zenrows/batch/__init__.py
new file mode 100644
index 0000000..1a0ebe7
--- /dev/null
+++ b/src/zenrows/batch/__init__.py
@@ -0,0 +1,132 @@
+"""Public surface for the ZenRows Batch (async-job) API.
+
+What's where:
+ - `client.ZenRowsBatchClient` — the friendly typed facade. One
+ method per OpenAPI operation, returning real pydantic models.
+ - `models` — pydantic v2 models, regenerated from the backend's
+ canonical `../docs/openapi.yaml` via `make generate`. Do NOT hand-edit.
+ - `errors.BatchAPIError` / `errors.ProblemDetail` — RFC 7807 mapping.
+ - `_transport._Transport` — httpx wrapper; internal.
+
+Re-exports below are the curated, stable surface. Anything in
+`models` that callers need but isn't here can be imported directly.
+
+Completion is surfaced via waiters (`job.wait()` / `run.wait()`) and
+webhooks — poll or get notified; there's no callback to wire up.
+"""
+
+from zenrows.batch._download import DownloadedResult, DownloadLimitExceeded
+from zenrows.batch._estimate import (
+ CostEstimate,
+ CostLine,
+ TaskCost,
+ Tier,
+)
+from zenrows.batch._resources import (
+ CurrentRun,
+ ExportHandle,
+ ExportRef,
+ JobHandle,
+ JobRef,
+ RunHandle,
+ RunRef,
+ ScheduleControls,
+)
+from zenrows.batch._schedule import (
+ At,
+ Cadence,
+ Calendar,
+ Daily,
+ Monthly,
+ Rate,
+ Schedule,
+ Weekly,
+)
+from zenrows.batch._typed_dicts import (
+ AddTasksDict,
+ CreateJobInputDict,
+ CSVFieldsDict,
+ CSVSpecDict,
+ JobScheduleDict,
+ SubmitJobDict,
+ TaskInputDict,
+ WebhookDict,
+)
+from zenrows.batch._waiters import WaiterError, WaiterTimeout
+from zenrows.batch.client import TERMINAL_RUN_STATUSES, ZenRowsBatchClient
+from zenrows.batch.errors import BatchAPIError, ProblemDetail
+from zenrows.batch.models import (
+ AddTasksRequest,
+ CreateJobInputRequest,
+ CreateJobInputResponse,
+ IngestStatus,
+ Job,
+ JobStatus,
+ JobType,
+ Run,
+ RunStatus,
+ SubmitJobRequest,
+ SubmitJobResponse,
+ TaskInput,
+ TaskResult,
+ TaskStatus,
+ TestWebhookRequest,
+ TestWebhookResponse,
+ WebhookConfig,
+)
+
+__all__ = [
+ "TERMINAL_RUN_STATUSES",
+ "AddTasksDict",
+ "AddTasksRequest",
+ "At",
+ "BatchAPIError",
+ "CSVFieldsDict",
+ "CSVSpecDict",
+ "Cadence",
+ "Calendar",
+ "CostEstimate",
+ "CostLine",
+ "CreateJobInputDict",
+ "CreateJobInputRequest",
+ "CreateJobInputResponse",
+ "CurrentRun",
+ "Daily",
+ "DownloadLimitExceeded",
+ "DownloadedResult",
+ "ExportHandle",
+ "ExportRef",
+ "IngestStatus",
+ "Job",
+ "JobHandle",
+ "JobRef",
+ "JobScheduleDict",
+ "JobStatus",
+ "JobType",
+ "Monthly",
+ "ProblemDetail",
+ "Rate",
+ "Run",
+ "RunHandle",
+ "RunRef",
+ "RunStatus",
+ "Schedule",
+ "ScheduleControls",
+ "SubmitJobDict",
+ "SubmitJobRequest",
+ "SubmitJobResponse",
+ "TaskCost",
+ "TaskInput",
+ "TaskInputDict",
+ "TaskResult",
+ "TaskStatus",
+ "TestWebhookRequest",
+ "TestWebhookResponse",
+ "Tier",
+ "WaiterError",
+ "WaiterTimeout",
+ "WebhookConfig",
+ "WebhookDict",
+ "Weekly",
+ "ZenRowsBatchClient",
+]
diff --git a/src/zenrows/batch/_download.py b/src/zenrows/batch/_download.py
new file mode 100644
index 0000000..b06ff50
--- /dev/null
+++ b/src/zenrows/batch/_download.py
@@ -0,0 +1,469 @@
+"""Download helpers for the Batch SDK.
+
+Every helper fetches task bodies straight from each result's presigned
+`result_url` (a signed storage URL) — no auth header, no API content
+endpoint in the loop.
+
+Bulk, both with optional concurrency + a tqdm progress bar:
+
+ - `download_to_dir(...)` writes every task body to a directory on
+ disk. Streams one task at a time so memory stays bounded; safety
+ caps are `max_files` (count) and `max_bytes_per_file` (per body).
+ - `download_to_memory(...)` loads bodies into a list of
+ `DownloadedResult`. Has BOTH `max_count` and `max_total_bytes`
+ safety caps; refuses to start (or raises mid-stream) before
+ exhausting RAM.
+
+Single task (for a `TaskResult` you already hold from `results()`):
+
+ - `download_task_to_file(task, target)` writes one body to a path or
+ an open binary file object.
+ - `download_task_to_memory(task)` returns one body as raw `bytes`.
+
+`concurrency > 1` fans the body GETs out across a `ThreadPoolExecutor`.
+Results are NOT guaranteed to be in iteration order in either bulk
+flavour. The iteration of `iter_results` itself stays single-threaded
+so we never page faster than we drain.
+
+`progress=True` installs a `rich.progress` bar if `rich` is
+importable; falls back to no-op if it isn't (so the SDK works in
+minimal envs without a hard dep).
+"""
+
+import os
+import threading
+from collections.abc import Callable, Iterable, Iterator
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from contextlib import contextmanager, nullcontext
+from dataclasses import dataclass
+from pathlib import Path
+from typing import IO, Any, Protocol
+
+import httpx
+
+from zenrows.batch.errors import BatchAPIError
+from zenrows.batch.models import TaskResult, TaskStatus
+
+
+class _BodyFetcher(Protocol):
+ """The slice of `ZenRowsBatchClient` the helpers need — just result
+ pagination. Bodies are pulled from each row's presigned `result_url`,
+ so no content endpoint is involved.
+
+ Declared so unit tests can substitute a fake without spinning up
+ the whole client.
+ """
+
+ def _iter_results_raw(
+ self,
+ job_id: str,
+ *,
+ run_id: str | None = ...,
+ status: str | None = ...,
+ ) -> Iterator[TaskResult]: ...
+
+
+@dataclass(slots=True)
+class DownloadedResult:
+ """One row's worth of downloaded content held in memory."""
+
+ task_id: str
+ external_id: str | None
+ content_type: str
+ body: bytes
+
+
+# Safety caps. Deliberate, conservative defaults — bulk downloads of
+# scraping jobs can easily reach gigabytes if not bounded.
+DEFAULT_MAX_FILES = 100_000
+DEFAULT_MAX_BYTES_PER_FILE = 50 * 1024 * 1024 # 50 MiB
+DEFAULT_MAX_COUNT_IN_MEMORY = 10_000
+DEFAULT_MAX_TOTAL_BYTES_IN_MEMORY = 500 * 1024 * 1024 # 500 MiB
+
+
+class DownloadLimitExceeded(RuntimeError):
+ """Raised when a download exceeds a configured cap."""
+
+ def __init__(self, limit_name: str, limit: int, observed: int):
+ self.limit_name = limit_name
+ self.limit = limit
+ self.observed = observed
+ super().__init__(f"download: {limit_name} cap exceeded ({observed} > {limit})")
+
+
+# ----- to-disk -----
+
+
+def download_to_dir(
+ client: _BodyFetcher,
+ job_id: str,
+ target_dir: Path,
+ *,
+ run_id: str | None = None,
+ status: str | None = TaskStatus.SUCCESSFUL.value,
+ name_fn: Callable[[TaskResult], str] | None = None,
+ use_external_id: bool = False,
+ concurrency: int = 1,
+ progress: bool = False,
+ max_files: int = DEFAULT_MAX_FILES,
+ max_bytes_per_file: int = DEFAULT_MAX_BYTES_PER_FILE,
+) -> int:
+ """Stream every task's body into `target_dir`. Returns the count.
+
+ `concurrency` parallelises the body-fetch + write. With the
+ default 1, behaviour is exactly serial. With N>1, up to N bodies
+ are fetched in flight; iteration of the result list stays
+ single-threaded so we never out-pace the server's pagination.
+
+ `progress=True` shows a live count via `tqdm` if it's
+ importable.
+
+ See module docstring for the naming + capping rules.
+ """
+ target_dir = Path(target_dir)
+ target_dir.mkdir(parents=True, exist_ok=True)
+ if not name_fn:
+ name_fn = _external_id_filename if use_external_id else _default_filename
+
+ # `external_id` is NOT unique on the server side (callers can
+ # reuse it), so multiple rows can ask for the same filename.
+ # The allocator hands the first claimer the bare name and tacks
+ # `_01`, `_02`, … on subsequent claimers. Thread-safe under
+ # concurrency > 1.
+ allocator = _NameAllocator()
+
+ def handle(row: TaskResult) -> int:
+ body, _ct = _fetch_result_url(row)
+ if len(body) > max_bytes_per_file:
+ raise DownloadLimitExceeded("max_bytes_per_file", max_bytes_per_file, len(body))
+ chosen = allocator.claim(name_fn(row))
+ path = target_dir / chosen
+ # parent dirs may have been requested by a custom name_fn
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(body)
+ return 1
+
+ return _run_bulk(
+ client,
+ job_id,
+ run_id=run_id,
+ status=status,
+ per_row=handle,
+ concurrency=concurrency,
+ progress=progress,
+ max_rows=max_files,
+ max_rows_limit_name="max_files",
+ progress_description="downloading to disk",
+ )
+
+
+def _default_filename(row: TaskResult) -> str:
+ """`.`. Server-assigned ULID — collision-free."""
+ return f"{row.task_id}.{_ext_for(row)}"
+
+
+def _external_id_filename(row: TaskResult) -> str:
+ """`.`, coerced to a filesystem-safe name.
+
+ `external_id` is caller-controlled *data* and can legitimately hold
+ characters that aren't valid in a filename (spaces, slashes, …), so
+ when writing files we **coerce** rather than reject: every char
+ outside `[A-Za-z0-9._-]` becomes `_`, and a missing/empty
+ external_id falls back to `task_id`. (Validating here would fail on
+ data the API already accepted; enforce clean ids at submit time if
+ you need to.) Pass a custom `name_fn` for different behaviour.
+
+ `external_id` is not server-enforced unique; the name allocator
+ (see `_NameAllocator`) handles cross-row collisions by appending
+ `_01`, `_02`, … to later claimers.
+ """
+ raw = row.external_id or ""
+ safe = "".join(c if (c.isalnum() or c in "._-") else "_" for c in raw)
+ base = safe or row.task_id
+ return f"{base}.{_ext_for(row)}"
+
+
+class _NameAllocator:
+ """Hands out unique filenames against a running set.
+
+ `claim("order-1.html")` returns `"order-1.html"` the first time
+ and `"order-1_01.html"`, `"order-1_02.html"`, … on subsequent
+ calls with the same input. Thread-safe; `download_to_dir` with
+ `concurrency=N` shares one allocator across workers.
+
+ Doesn't peek at the filesystem — only the in-memory counter
+ matters. Pre-existing files in `target_dir` from a previous run
+ are NOT considered for collision; if you re-download into a
+ populated directory, you'll overwrite the first occurrence of
+ each name. Use a fresh dir if that matters.
+ """
+
+ def __init__(self) -> None:
+ self._counts: dict[str, int] = {}
+ self._lock = threading.Lock()
+
+ def claim(self, name: str) -> str:
+ with self._lock:
+ n = self._counts.get(name, 0)
+ self._counts[name] = n + 1
+ if n == 0:
+ return name
+ stem, ext = os.path.splitext(name)
+ return f"{stem}_{n:02d}{ext}"
+
+
+def _ext_for(row: TaskResult) -> str:
+ """File extension for one result. `row.type` is the pydantic
+ `ResultType` enum or None; failed-but-downloaded rows have no
+ type and fall back to `.bin`."""
+ if not row.type:
+ return "bin"
+ return row.type.value
+
+
+# ----- single task (straight from the presigned result_url) -----
+#
+# NOTE (please read before "improving" this): the API also exposes a
+# `GET .../tasks/{id}/content` endpoint, but it exists for the **web UI**
+# — it proxies the body back through the API with display-friendly
+# headers so a browser can render it inline. The SDK deliberately does
+# NOT use it: `result_url` is a presigned storage URL, so fetching it is
+# one hop straight to the bucket (no API round-trip, no auth header, and
+# it keeps body bandwidth off the API). Do not reintroduce a
+# `/content`-based download path here — pull from `result_url`.
+
+
+def _fetch_result_url(task: TaskResult) -> tuple[bytes, str]:
+ """GET a task's presigned `result_url` directly — no API round-trip
+ and no auth header (it's a signed storage URL). Returns
+ `(body, content_type)`. Raises `ValueError` when the task carries no
+ result (e.g. a failed task)."""
+ if not task.result_url:
+ raise ValueError(
+ f"task {task.task_id!r} has no result_url — "
+ "only successful tasks have a downloadable body"
+ )
+ with httpx.Client(timeout=httpx.Timeout(60.0)) as bare:
+ r = bare.get(str(task.result_url))
+ if r.status_code >= 400:
+ raise BatchAPIError(r.status_code, problem=None, raw=r.content)
+ return r.content, r.headers.get("Content-Type", "")
+
+
+def download_task_to_file(task: TaskResult, target: "str | Path | IO[bytes]") -> None:
+ """Download one task's body straight from its `result_url` and write
+ it to `target` — a filesystem path (`str` / `Path`) or an already-open
+ binary file object."""
+ body, _ct = _fetch_result_url(task)
+ if isinstance(target, (str, Path)):
+ path = Path(target)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(body)
+ else:
+ target.write(body)
+
+
+def download_task_to_memory(task: TaskResult) -> bytes:
+ """Download one task's body straight from its `result_url` and
+ return the raw bytes."""
+ return _fetch_result_url(task)[0]
+
+
+# ----- to-memory -----
+
+
+def download_to_memory(
+ client: _BodyFetcher,
+ job_id: str,
+ *,
+ run_id: str | None = None,
+ status: str | None = TaskStatus.SUCCESSFUL.value,
+ concurrency: int = 1,
+ progress: bool = False,
+ max_count: int = DEFAULT_MAX_COUNT_IN_MEMORY,
+ max_total_bytes: int = DEFAULT_MAX_TOTAL_BYTES_IN_MEMORY,
+ max_bytes_per_file: int = DEFAULT_MAX_BYTES_PER_FILE,
+) -> list[DownloadedResult]:
+ """Load every (matching) task body into a list and return it.
+
+ Three independent caps that all raise `DownloadLimitExceeded`:
+ - `max_count` — how many rows total
+ - `max_total_bytes` — running sum of body sizes
+ - `max_bytes_per_file` — any single oversize body aborts
+
+ `concurrency` + `progress` work the same as on `download_to_dir`.
+ Returned list ordering is NOT guaranteed when concurrency > 1.
+ """
+ out: list[DownloadedResult] = []
+ total = [0] # boxed so the closure can mutate
+
+ def handle(row: TaskResult) -> int:
+ body, content_type = _fetch_result_url(row)
+ if len(body) > max_bytes_per_file:
+ raise DownloadLimitExceeded("max_bytes_per_file", max_bytes_per_file, len(body))
+ total[0] += len(body)
+ if total[0] > max_total_bytes:
+ raise DownloadLimitExceeded("max_total_bytes", max_total_bytes, total[0])
+ out.append(
+ DownloadedResult(
+ task_id=row.task_id,
+ external_id=row.external_id,
+ content_type=content_type,
+ body=body,
+ )
+ )
+ return 1
+
+ _run_bulk(
+ client,
+ job_id,
+ run_id=run_id,
+ status=status,
+ per_row=handle,
+ concurrency=concurrency,
+ progress=progress,
+ max_rows=max_count,
+ max_rows_limit_name="max_count",
+ progress_description="downloading to memory",
+ )
+ return out
+
+
+# ----- shared driver -----
+
+
+def _run_bulk(
+ client: _BodyFetcher,
+ job_id: str,
+ *,
+ run_id: str | None,
+ status: str | None,
+ per_row: Callable[[TaskResult], int],
+ concurrency: int,
+ progress: bool,
+ max_rows: int,
+ max_rows_limit_name: str,
+ progress_description: str,
+) -> int:
+ """Iterate results + apply `per_row` to each, with optional
+ parallelism + progress bar.
+
+ Returns the number of rows processed.
+
+ Cap checking is done *here* rather than in `per_row` so the
+ count is authoritative even when the closures run out of order.
+ """
+ rows = client._iter_results_raw(job_id, run_id=run_id, status=status)
+
+ with _maybe_progress(progress, progress_description) as advance:
+ if concurrency <= 1:
+ written = 0
+ for row in rows:
+ if written >= max_rows:
+ raise DownloadLimitExceeded(max_rows_limit_name, max_rows, written + 1)
+ per_row(row)
+ written += 1
+ advance(1)
+ return written
+
+ return _run_parallel(
+ rows,
+ per_row=per_row,
+ concurrency=concurrency,
+ max_rows=max_rows,
+ max_rows_limit_name=max_rows_limit_name,
+ advance=advance,
+ )
+
+
+def _run_parallel(
+ rows: Iterable[TaskResult],
+ *,
+ per_row: Callable[[TaskResult], int],
+ concurrency: int,
+ max_rows: int,
+ max_rows_limit_name: str,
+ advance: Callable[[int], None],
+) -> int:
+ """Fan `per_row` across a thread pool.
+
+ We submit at most `concurrency * 2` futures at a time so the
+ upstream paginator doesn't race ahead and buffer the entire
+ result set in memory. The pool is sized to `concurrency`; the
+ extra slack absorbs latency without unbounded queuing.
+ """
+ written = 0
+ in_flight: set[Any] = set()
+ pool = ThreadPoolExecutor(max_workers=concurrency)
+ iterator = iter(rows)
+ try:
+ # Prime the pool.
+ for _ in range(concurrency * 2):
+ try:
+ row = next(iterator)
+ except StopIteration:
+ break
+ if written + len(in_flight) >= max_rows:
+ raise DownloadLimitExceeded(max_rows_limit_name, max_rows, max_rows + 1)
+ in_flight.add(pool.submit(per_row, row))
+
+ while in_flight:
+ # Drain completed futures one at a time so we can keep
+ # filling the pool incrementally.
+ done = next(as_completed(in_flight))
+ in_flight.discard(done)
+ done.result() # re-raises any worker exception
+ written += 1
+ advance(1)
+
+ # Refill — but stop submitting once we've reached the cap.
+ if written + len(in_flight) >= max_rows:
+ # Drain the rest of the in-flight futures; do NOT
+ # submit more.
+ continue
+ try:
+ row = next(iterator)
+ except StopIteration:
+ continue
+ in_flight.add(pool.submit(per_row, row))
+ finally:
+ pool.shutdown(wait=True)
+ return written
+
+
+@contextmanager
+def _maybe_progress(enabled: bool, description: str):
+ """Yields an `advance(n)` callable. With `tqdm` installed and
+ `enabled=True`, drives a `tqdm` bar; otherwise a no-op closure.
+
+ `tqdm` is a soft dependency — leaving it out is fine, you just
+ don't get a progress bar."""
+ if not enabled:
+ with nullcontext():
+ yield lambda _n: None
+ return
+ try:
+ from tqdm.auto import tqdm
+ except ImportError:
+ # tqdm not installed — quietly degrade.
+ with nullcontext():
+ yield lambda _n: None
+ return
+
+ # `total=None` shows an indeterminate spinner that fills in once
+ # the count is known; we don't know it upfront because the result
+ # set is streamed via cursor pagination.
+ with tqdm(total=None, desc=description, unit="task") as bar:
+ yield bar.update
+
+
+__all__ = [
+ "DEFAULT_MAX_BYTES_PER_FILE",
+ "DEFAULT_MAX_COUNT_IN_MEMORY",
+ "DEFAULT_MAX_FILES",
+ "DEFAULT_MAX_TOTAL_BYTES_IN_MEMORY",
+ "DownloadLimitExceeded",
+ "DownloadedResult",
+ "download_to_dir",
+ "download_to_memory",
+]
diff --git a/src/zenrows/batch/_estimate.py b/src/zenrows/batch/_estimate.py
new file mode 100644
index 0000000..9deb559
--- /dev/null
+++ b/src/zenrows/batch/_estimate.py
@@ -0,0 +1,272 @@
+"""Client-side cost estimation for Batch jobs.
+
+The Batch API prices a scrape per **successful request**, driven by two
+scraper knobs (`js_render`, `premium_proxy`) plus the dynamic
+`mode=auto`. The rate card is small, stable, and identical for every
+caller, so estimation lives client-side rather than behind a network
+round-trip — there is intentionally no `POST /v1/jobs/estimate`
+endpoint.
+
+What an estimate answers: *if every URL succeeds exactly once, what
+is the charge?* It is **not** a consumption forecast — it ignores
+failures, `retries`, and `reruns`, which affect realized usage but
+not the per-success price. Realized cost is whatever teller bills
+post-factum; this is advisory.
+
+The math is a sum of per-task intervals:
+
+ task in `mode=auto` → [1, 25] (dynamic, billed post-factum)
+ otherwise, by merged flags → exact 1 | 5 | 10 | 25
+
+ job.min = Σ taskᵢ.min job.max = Σ taskᵢ.max
+
+An estimate is *exact* (`min == max`) iff it contains no auto tasks;
+the interval width is exactly `24 x (number of auto tasks)`.
+
+`mode=auto` and the explicit `js_render` / `premium_proxy` flags are
+mutually exclusive at submit, so every task sits in
+exactly one tier. If a malformed param map carries both, `auto`
+wins here (it's what the engine would honor) — but the server would
+reject that body at submit anyway.
+
+The per-task `method` / `body` fields (POST tasks) do NOT affect the
+rate card — pricing is driven by the flags above regardless of HTTP
+method, and the render-tier combinations the platform can't execute
+for POST (`js_render`, `js_instructions`, `json_response`) are
+rejected at submit, so a priced job is a billable job.
+"""
+
+from collections.abc import Iterable
+from dataclasses import dataclass
+from enum import Enum
+
+from zenrows.batch.models import TaskInput
+
+# Credits charged per *successful* request, by configuration.
+BASE_CREDITS = 1
+JS_CREDITS = 5
+PREMIUM_PROXY_CREDITS = 10
+JS_AND_PROXY_CREDITS = 25
+# `mode=auto` is charged dynamically post-factum, anywhere in this range.
+AUTO_MIN_CREDITS = 1
+AUTO_MAX_CREDITS = 25
+
+# Param values arrive as str | bool | int. These spellings
+# count as "on" for the boolean flags.
+_TRUTHY = frozenset({"true", "1", "yes", "on"})
+
+# What `Tier` keys can appear, in the order a breakdown should render.
+ParamValue = str | bool | int | dict[str, str] # dict form: `custom_headers` map
+ParamMap = dict[str, ParamValue]
+TaskLike = str | TaskInput | dict
+
+
+class Tier(str, Enum):
+ """The pricing tier a task falls into. Exactly one per task."""
+
+ BASE = "base"
+ JS = "js_render"
+ PREMIUM = "premium_proxy"
+ JS_AND_PREMIUM = "js_render+premium_proxy"
+ AUTO = "auto"
+
+
+# Stable render order for breakdown lines.
+_TIER_ORDER = (
+ Tier.BASE,
+ Tier.JS,
+ Tier.PREMIUM,
+ Tier.JS_AND_PREMIUM,
+ Tier.AUTO,
+)
+
+
+@dataclass(slots=True, frozen=True)
+class TaskCost:
+ """The credit interval for a single task. `min == max` for every
+ tier except `auto`."""
+
+ tier: Tier
+ min: int
+ max: int
+
+ @property
+ def exact(self) -> bool:
+ return self.min == self.max
+
+
+@dataclass(slots=True, frozen=True)
+class CostLine:
+ """One row of a breakdown: all tasks sharing a tier, aggregated."""
+
+ tier: Tier
+ count: int
+ unit_min: int
+ unit_max: int
+
+ @property
+ def subtotal_min(self) -> int:
+ return self.count * self.unit_min
+
+ @property
+ def subtotal_max(self) -> int:
+ return self.count * self.unit_max
+
+ @property
+ def exact(self) -> bool:
+ return self.unit_min == self.unit_max
+
+
+@dataclass(slots=True, frozen=True)
+class CostEstimate:
+ """Result of `client.estimate_cost`. Credits assuming every task succeeds
+ once. `min == max` (`exact`) when no task uses `mode=auto`.
+
+ Designed-for-later (not built today): money pricing. Credits are
+ the only unit now. Money would layer in as `money = credits x
+ price_per_credit`, where the per-credit price is plan-dependent and
+ not part of the static rate card. The natural, non-breaking
+ extension is additive fields — e.g. an optional `money_min` /
+ `money_max` (or a nested `money` object) on this class and a
+ matching subtotal on `CostLine` — populated only once a per-credit
+ price is supplied. Nothing here is renamed to a credit-specific
+ name precisely so that addition reads naturally."""
+
+ task_count: int
+ min: int
+ max: int
+ breakdown: tuple[CostLine, ...]
+
+ @property
+ def exact(self) -> bool:
+ """True when the charge is a single number (no auto tasks)."""
+ return self.min == self.max
+
+ @property
+ def auto_tasks(self) -> int:
+ """How many tasks use `mode=auto` — the only source of range."""
+ return sum(line.count for line in self.breakdown if line.tier is Tier.AUTO)
+
+ def __str__(self) -> str:
+ credits = f"{self.min}" if self.exact else f"{self.min}-{self.max}"
+ return f"{credits} credits ({self.task_count} tasks)"
+
+ def format(self) -> str:
+ """Multi-line breakdown table, e.g.::
+
+ 1000 tasks → 4800-6000 credits
+ 950 x base (1) = 950
+ 50 x auto (1-25) = 50-1250
+ """
+ head = f"{self.task_count} tasks → {self}"
+ lines = [head]
+ for line in self.breakdown:
+ unit = f"{line.unit_min}" if line.exact else f"{line.unit_min}-{line.unit_max}"
+ sub = (
+ f"{line.subtotal_min}" if line.exact else f"{line.subtotal_min}-{line.subtotal_max}"
+ )
+ lines.append(f" {line.count:>6} x {line.tier.value} ({unit}) = {sub}")
+ return "\n".join(lines)
+
+
+def _truthy(value: ParamValue | None) -> bool:
+ """Coerce a scraper-param scalar to a boolean. Booleans pass
+ through; ints are nonzero-truthy; strings match the on-spellings."""
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, int):
+ return value != 0
+ if isinstance(value, str):
+ return value.strip().lower() in _TRUTHY
+ return False
+
+
+def _is_auto(params: ParamMap) -> bool:
+ return str(params.get("mode", "")).strip().lower() == "auto"
+
+
+def _cost_for_params(params: ParamMap) -> TaskCost:
+ """Price one task from its **merged** scraper params (job-level
+ overlaid with per-task, task wins)."""
+ if _is_auto(params):
+ return TaskCost(Tier.AUTO, AUTO_MIN_CREDITS, AUTO_MAX_CREDITS)
+ js = _truthy(params.get("js_render"))
+ px = _truthy(params.get("premium_proxy"))
+ if js and px:
+ return TaskCost(Tier.JS_AND_PREMIUM, JS_AND_PROXY_CREDITS, JS_AND_PROXY_CREDITS)
+ if px:
+ return TaskCost(Tier.PREMIUM, PREMIUM_PROXY_CREDITS, PREMIUM_PROXY_CREDITS)
+ if js:
+ return TaskCost(Tier.JS, JS_CREDITS, JS_CREDITS)
+ return TaskCost(Tier.BASE, BASE_CREDITS, BASE_CREDITS)
+
+
+def _task_params(task: TaskLike) -> ParamMap:
+ """Pull per-task `zenrows_params` from any accepted task shape."""
+ if isinstance(task, str):
+ return {}
+ if isinstance(task, TaskInput):
+ return dict(task.zenrows_params or {})
+ if isinstance(task, dict):
+ return dict(task.get("zenrows_params") or {})
+ raise TypeError(f"unsupported task type for estimation: {type(task).__name__}")
+
+
+def _estimate_cost(
+ tasks: Iterable[TaskLike],
+ *,
+ zenrows_params: ParamMap | None = None,
+) -> CostEstimate:
+ """Estimate the credit cost of a job, assuming every task succeeds
+ once. Pure and offline — no network call.
+
+ `tasks` is the same shape `submit_regular` accepts: bare URL
+ strings, ``TaskInput`` models, or task dicts. Per-task
+ ``zenrows_params`` override the job-level ``zenrows_params`` on key
+ collision (task wins), matching the worker's merge.
+
+ Returns a `CostEstimate` with `min`/`max` credits and a per-tier
+ `breakdown`. `min == max` (``.exact``) when no task uses
+ ``mode=auto``.
+
+ Note: `file_input` (CSV) jobs can't be estimated this way — the
+ row count isn't known client-side. Estimate from the in-memory
+ task list, or count the rows yourself first.
+ """
+ job_params = dict(zenrows_params or {})
+ # tier -> [count, unit_min, unit_max]
+ agg: dict[Tier, list[int]] = {}
+ total_min = 0
+ total_max = 0
+ count = 0
+ for task in tasks:
+ count += 1
+ merged = {**job_params, **_task_params(task)}
+ tc = _cost_for_params(merged)
+ total_min += tc.min
+ total_max += tc.max
+ if tc.tier in agg:
+ agg[tc.tier][0] += 1
+ else:
+ agg[tc.tier] = [1, tc.min, tc.max]
+
+ breakdown = tuple(
+ CostLine(tier, agg[tier][0], agg[tier][1], agg[tier][2])
+ for tier in _TIER_ORDER
+ if tier in agg
+ )
+ return CostEstimate(task_count=count, min=total_min, max=total_max, breakdown=breakdown)
+
+
+__all__ = [
+ "AUTO_MAX_CREDITS",
+ "AUTO_MIN_CREDITS",
+ "BASE_CREDITS",
+ "JS_AND_PROXY_CREDITS",
+ "JS_CREDITS",
+ "PREMIUM_PROXY_CREDITS",
+ "CostEstimate",
+ "CostLine",
+ "TaskCost",
+ "Tier",
+]
diff --git a/src/zenrows/batch/_logging.py b/src/zenrows/batch/_logging.py
new file mode 100644
index 0000000..59a8526
--- /dev/null
+++ b/src/zenrows/batch/_logging.py
@@ -0,0 +1,96 @@
+"""Structured logging for the Batch SDK.
+
+One logger per module under the `zenrows.batch` namespace. Logging
+follows the stdlib `logging` conventions — callers opt in by
+configuring a handler on `zenrows.batch` (or any ancestor); we never
+install one ourselves. That keeps quiet libraries quiet by default
+and lets apps route Batch SDK events into whatever observability
+stack they already have.
+
+Log levels we use:
+ - DEBUG one record per HTTP request (method, path, status, ms).
+ - INFO notable client-side events (waiter started, download
+ completed, key rotation).
+ - WARNING soft errors a caller probably wants to know about
+ (progress dep missing while progress=True, etc.).
+ - ERROR non-2xx responses that turned into BatchAPIError.
+
+Fields are passed as `logging.LogRecord` `extra=` so structured-log
+backends (json, OpenTelemetry, Datadog) can index them. Plain-text
+formatters see the message string and ignore the extras.
+"""
+
+import logging
+from collections.abc import Mapping
+from typing import Any
+
+# Root logger for the Batch SDK. Submodules get child loggers via
+# `_logger("zenrows.batch.client")` etc. — children inherit handlers
+# from this one when callers configure it.
+ROOT = "zenrows.batch"
+
+
+def logger(name: str) -> logging.Logger:
+ """`logging.getLogger` namespaced under `zenrows.batch`.
+
+ Pass the bare module name (e.g. `"client"`); the prefix is
+ added here so callers never have to remember the full string.
+ """
+ return logging.getLogger(f"{ROOT}.{name}")
+
+
+def log_request(
+ log: logging.Logger,
+ *,
+ method: str,
+ path: str,
+ status: int,
+ elapsed_ms: float,
+ extra: Mapping[str, Any] | None = None,
+) -> None:
+ """Emit a per-request DEBUG record. Cheap when the level is off.
+
+ The structured fields (`extra=`) match what most backends index
+ on out of the box: `http.method`, `http.path`, `http.status`,
+ `elapsed_ms`. Plain-text formatters get a single-line message.
+ """
+ if not log.isEnabledFor(logging.DEBUG):
+ return
+ payload: dict[str, Any] = {
+ "http.method": method,
+ "http.path": path,
+ "http.status": status,
+ "elapsed_ms": round(elapsed_ms, 2),
+ }
+ if extra:
+ payload.update(extra)
+ log.debug("%s %s -> %d (%.1f ms)", method, path, status, elapsed_ms, extra=payload)
+
+
+def log_error(
+ log: logging.Logger,
+ *,
+ method: str,
+ path: str,
+ status: int,
+ code: str,
+ detail: str | None,
+) -> None:
+ """Emit an ERROR record for a non-2xx that becomes BatchAPIError."""
+ log.error(
+ "%s %s -> %d %s%s",
+ method,
+ path,
+ status,
+ code,
+ f": {detail}" if detail else "",
+ extra={
+ "http.method": method,
+ "http.path": path,
+ "http.status": status,
+ "error.code": code,
+ },
+ )
+
+
+__all__ = ["ROOT", "log_error", "log_request", "logger"]
diff --git a/src/zenrows/batch/_resources.py b/src/zenrows/batch/_resources.py
new file mode 100644
index 0000000..748c008
--- /dev/null
+++ b/src/zenrows/batch/_resources.py
@@ -0,0 +1,921 @@
+"""Resource handles for the Batch SDK — a two-tier (typestate) design
+with namespaced sub-facets.
+
+For each resource there's a *reference* and a *loaded handle*:
+
+ - ``JobRef`` wraps ``(client, job_id)`` and exposes every
+ job-template operation that needs **only the id** — ``close``,
+ ``delete``, ``rerun`` / ``retry_failed``, ``add_tasks``, ``runs()``.
+ It holds no snapshot; ``ref.load()`` fetches and returns a…
+ - ``JobHandle`` — a ``JobRef`` plus a guaranteed, synchronous
+ ``data: Job``. Returned by ``get_job`` / ``iter_jobs`` and the ops
+ that echo fresh state.
+
+Two sub-facets hang off every job (ref or handle), because the API has
+two distinct scopes that both use the word "pause":
+
+ - ``job.run.*`` — operations on the **current run**: ``pause`` /
+ ``resume`` (run-level suspend), ``stop`` / ``cancel``, ``wait``,
+ ``results``, downloads, ``start_export``.
+ - ``job.schedule.*`` — operations on the **schedule**: ``pause`` /
+ ``resume`` (skip future fires) and ``update``.
+
+Address a *specific historical* run with ``client.run(job_id, run_id)``
+(a ``RunRef``); it deliberately has no ``pause`` / ``stop``, since the
+API only pauses or stops the current run.
+
+Handles are immutable snapshots: mutating ops (``pause``, ``stop``, …)
+return a *new* loaded handle carrying the server's fresh state rather
+than updating in place. Every method here delegates to the client's
+transport, so behaviour is identical to the flat, pydantic-typed
+surface.
+"""
+
+from collections.abc import Callable, Iterator
+from pathlib import Path
+from typing import IO, TYPE_CHECKING
+
+import httpx
+
+from zenrows.batch._download import download_task_to_file as _download_task_to_file
+from zenrows.batch._download import download_task_to_memory as _download_task_to_memory
+from zenrows.batch._typed_dicts import AddTasksDict
+from zenrows.batch.errors import BatchAPIError
+from zenrows.batch.models import (
+ AddTasksRequest,
+ AddTasksResponse,
+ Export,
+ ExportStatus,
+ Job,
+ JobStatus,
+ RerunJobResponse,
+ Run,
+ RunStats,
+ RunStatus,
+ StartExportResponse,
+ SubmitJobResponse,
+ TaskHistoryResponse,
+ TaskResult,
+ WebhookConfig,
+)
+
+if TYPE_CHECKING:
+ from zenrows.batch._download import DownloadedResult
+ from zenrows.batch._schedule import Schedule
+ from zenrows.batch._typed_dicts import JobScheduleDict
+ from zenrows.batch.client import ZenRowsBatchClient
+
+
+# ======================= specific runs =======================
+
+
+class RunRef:
+ """A reference to a **specific** run by ``(job_id, run_id)``.
+
+ Read/download operations on one (usually historical) run. No
+ ``pause`` / ``stop``: the API only suspends or stops the *current*
+ run (see ``job.run`` — the :class:`CurrentRun` facet). Call
+ ``load()`` for a :class:`RunHandle` with data.
+ """
+
+ def __init__(
+ self,
+ client: "ZenRowsBatchClient",
+ job_id: str,
+ run_id: str,
+ ):
+ self._client = client
+ self.job_id = job_id
+ self.run_id = run_id
+
+ def __repr__(self) -> str:
+ return f"<{type(self).__name__} job_id={self.job_id!r} run_id={self.run_id!r}>"
+
+ def load(self) -> "RunHandle":
+ """``GET /jobs/{id}/runs/{run_id}`` — fetch the run and return a
+ loaded :class:`RunHandle`."""
+ return RunHandle(
+ self._client,
+ self.job_id,
+ self.run_id,
+ self._client._get_run_data(self.job_id, self.run_id),
+ )
+
+ # ----- lifecycle -----
+
+ def delete(self) -> None:
+ """``DELETE /jobs/{id}/runs/{run_id}`` — scrub one run only."""
+ self._client._delete_run(self.job_id, self.run_id)
+
+ # ----- results / content -----
+
+ def results(self, *, status: str | None = None) -> Iterator[TaskResult]:
+ return self._client._iter_results_raw(self.job_id, run_id=self.run_id, status=status)
+
+ def task_history(self, task_id: str) -> TaskHistoryResponse:
+ return self._client._get_task_history_raw(self.job_id, task_id, run_id=self.run_id)
+
+ # ----- bulk download -----
+
+ def download_to_dir(
+ self,
+ target_dir: str | Path,
+ *,
+ status: str | None = "successful",
+ name_fn: Callable[[TaskResult], str] | None = None,
+ use_external_id: bool = False,
+ concurrency: int = 1,
+ progress: bool = False,
+ max_files: int | None = None,
+ max_bytes_per_file: int | None = None,
+ ) -> int:
+ return self._client._download_dir(
+ self.job_id,
+ self.run_id,
+ target_dir,
+ status=status,
+ name_fn=name_fn,
+ use_external_id=use_external_id,
+ concurrency=concurrency,
+ progress=progress,
+ max_files=max_files,
+ max_bytes_per_file=max_bytes_per_file,
+ )
+
+ def download_to_memory(
+ self,
+ *,
+ status: str | None = "successful",
+ concurrency: int = 1,
+ progress: bool = False,
+ max_count: int | None = None,
+ max_total_bytes: int | None = None,
+ max_bytes_per_file: int | None = None,
+ ) -> "list[DownloadedResult]":
+ return self._client._download_memory(
+ self.job_id,
+ self.run_id,
+ status=status,
+ concurrency=concurrency,
+ progress=progress,
+ max_count=max_count,
+ max_total_bytes=max_total_bytes,
+ max_bytes_per_file=max_bytes_per_file,
+ )
+
+ # ----- single-task download -----
+
+ def download_task_to_file(self, task: TaskResult, target: "str | Path | IO[bytes]") -> None:
+ """Download one ``task``'s body straight from its presigned
+ ``result_url`` to ``target`` — a path (``str`` / ``Path``) or an
+ open binary file object."""
+ _download_task_to_file(task, target)
+
+ def download_task_to_memory(self, task: TaskResult) -> bytes:
+ """Download one ``task``'s body straight from its presigned
+ ``result_url`` and return the raw bytes."""
+ return _download_task_to_memory(task)
+
+ # ----- waiter -----
+
+ def wait(
+ self,
+ *,
+ target_statuses: set[str] | frozenset[str] | None = None,
+ failure_statuses: set[str] | frozenset[str] | None = None,
+ timeout: float = 300.0,
+ poll_interval: float = 2.0,
+ progress: bool = False,
+ ) -> "RunHandle":
+ """Block until this run reaches a target state. Returns a fresh
+ loaded :class:`RunHandle` so chains like
+ ``run.wait().download_to_dir(...)`` work without re-wrapping."""
+ run = self._client._wait_for_run_raw(
+ self.job_id,
+ run_id=self.run_id,
+ target_statuses=target_statuses,
+ failure_statuses=failure_statuses,
+ timeout=timeout,
+ poll_interval=poll_interval,
+ progress=progress,
+ )
+ return RunHandle(self._client, self.job_id, self.run_id, run)
+
+ # ----- async results-zip export -----
+
+ def start_export(self) -> "ExportRef":
+ """``POST .../exports`` — kick off an async zip of this run's
+ bodies. Returns an :class:`ExportRef` carrying the just-issued
+ id; chain ``.wait()`` to block for completion."""
+ return self._client.start_results_export(self.job_id, self.run_id)
+
+ def export(self, export_id: str) -> "ExportRef":
+ """Address a specific export of this run by id (no network call).
+ Lazy — the first method call surfaces 404 if the id is wrong or
+ TTL-swept."""
+ return ExportRef(self._client, self.job_id, self.run_id, export_id)
+
+ def download_all_results(
+ self,
+ target_path: str | Path,
+ *,
+ wait_timeout: float = 600.0,
+ poll_interval: float = 2.0,
+ ) -> Path:
+ """Start an export of this run's results, wait for it, and save
+ the zip to ``target_path``. See
+ ``ZenRowsBatchClient.download_all_results`` for the contract.
+
+ Capped at 1 GiB per run server-side; for larger runs use
+ ``download_to_dir`` (no size limit, but slower — one body at a
+ time, tunable via ``concurrency=``)."""
+ return self._client.download_all_results(
+ self.job_id,
+ self.run_id,
+ target_path,
+ wait_timeout=wait_timeout,
+ poll_interval=poll_interval,
+ )
+
+
+class RunHandle(RunRef):
+ """A :class:`RunRef` plus a guaranteed, synchronous ``data`` snapshot."""
+
+ def __init__(
+ self,
+ client: "ZenRowsBatchClient",
+ job_id: str,
+ run_id: str,
+ data: Run,
+ ):
+ super().__init__(client, job_id, run_id)
+ self.data = data
+
+ def __repr__(self) -> str:
+ t = self.data.stats
+ return (
+ f""
+ )
+
+ @property
+ def status(self) -> RunStatus:
+ """This run's status. Shortcut for ``self.data.status``."""
+ return self.data.status
+
+ @property
+ def stats(self) -> RunStats:
+ """This run's task rollup (``total``, ``successful``, ``failed``,
+ ``spend``, …). Shortcut for ``self.data.stats``."""
+ return self.data.stats
+
+
+# ============================ exports ============================
+
+
+class ExportRef:
+ """A reference to a results-export by id.
+
+ A results export is async: ``start_export()`` returns immediately
+ with a ``pending`` ref; the server zips the run in the background
+ and it reaches ``completed`` (download URL ready) or ``failed``
+ (with an ``error`` message — e.g. the 1 GiB size cap). Call
+ ``load()`` / ``wait()`` for an :class:`ExportHandle` with data.
+ """
+
+ def __init__(
+ self,
+ client: "ZenRowsBatchClient",
+ job_id: str,
+ run_id: str,
+ export_id: str,
+ *,
+ start_response: StartExportResponse | None = None,
+ ):
+ self._client = client
+ self.job_id = job_id
+ self.run_id = run_id
+ self.export_id = export_id
+ # Only set on the ref returned by start_export; carries the
+ # initial status/timestamps without forcing a GET.
+ self.start_response = start_response
+
+ def __repr__(self) -> str:
+ bits = [f"export_id={self.export_id!r}"]
+ if self.start_response:
+ bits.append(f"status={self.start_response.status.value!r}")
+ return f"<{type(self).__name__} {' '.join(bits)}>"
+
+ def load(self) -> "ExportHandle":
+ """``GET .../exports/{id}`` — fetch the export and return a
+ loaded :class:`ExportHandle`."""
+ data = self._client._get_export(self.job_id, self.run_id, self.export_id)
+ return ExportHandle(
+ self._client,
+ self.job_id,
+ self.run_id,
+ self.export_id,
+ data,
+ start_response=self.start_response,
+ )
+
+ # ----- waiter -----
+
+ def wait(
+ self,
+ *,
+ target_statuses: set[str] | frozenset[str] | None = None,
+ timeout: float = 600.0,
+ poll_interval: float = 2.0,
+ ) -> "ExportHandle":
+ """Block until the export reaches a terminal state (defaults to
+ ``{completed, failed}``). Returns the loaded handle."""
+ from zenrows.batch.client import TERMINAL_EXPORT_STATUSES
+
+ data = self._client._wait_for_export_raw(
+ self.job_id,
+ self.run_id,
+ self.export_id,
+ target_statuses=target_statuses or TERMINAL_EXPORT_STATUSES,
+ timeout=timeout,
+ poll_interval=poll_interval,
+ )
+ return ExportHandle(
+ self._client,
+ self.job_id,
+ self.run_id,
+ self.export_id,
+ data,
+ start_response=self.start_response,
+ )
+
+ # ----- download -----
+
+ def download_to_path(
+ self,
+ target_path: str | Path,
+ *,
+ chunk_size: int = 1 << 20,
+ ) -> Path:
+ """Stream the export zip to ``target_path``. The export must
+ already be ``completed`` — call ``.wait()`` first or pair with
+ ``client.download_all_results(...)`` for the one-shot flow.
+
+ Fetches once for a fresh presigned URL (the server signs a new
+ URL per request)."""
+ data = self.load().data
+ if data.status != ExportStatus.COMPLETED:
+ raise BatchAPIError(
+ status_code=0,
+ problem=None,
+ raw=(data.error if data.error else "export not completed").encode(),
+ )
+ if not data.download_url:
+ raise BatchAPIError(
+ status_code=0,
+ problem=None,
+ raw=b"export completed but server returned no download_url",
+ )
+ target = Path(target_path)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ with (
+ httpx.Client(timeout=httpx.Timeout(60.0)) as bare,
+ bare.stream("GET", str(data.download_url)) as r,
+ ):
+ if r.status_code >= 400:
+ raise BatchAPIError(r.status_code, problem=None, raw=r.read())
+ with target.open("wb") as f:
+ for chunk in r.iter_bytes(chunk_size):
+ f.write(chunk)
+ return target
+
+
+class ExportHandle(ExportRef):
+ """An :class:`ExportRef` plus a guaranteed, synchronous ``data`` snapshot."""
+
+ def __init__(
+ self,
+ client: "ZenRowsBatchClient",
+ job_id: str,
+ run_id: str,
+ export_id: str,
+ data: Export,
+ *,
+ start_response: StartExportResponse | None = None,
+ ):
+ super().__init__(client, job_id, run_id, export_id, start_response=start_response)
+ self.data = data
+
+ def __repr__(self) -> str:
+ return f""
+
+ @property
+ def status(self) -> ExportStatus:
+ """This export's status. Shortcut for ``self.data.status``."""
+ return self.data.status
+
+
+# ===================== current-run facet =====================
+
+
+class CurrentRun:
+ """Operations on a job's **current run**, reached via ``job.run``.
+
+ The pause / stop family only ever targets the latest run (the API's
+ run-less endpoints resolve it server-side), which is why they live
+ here and not on :class:`RunRef`. Minted lazily by the ``job.run``
+ property; holds no snapshot of its own.
+ """
+
+ def __init__(self, client: "ZenRowsBatchClient", job_id: str):
+ self._client = client
+ self.job_id = job_id
+
+ def __repr__(self) -> str:
+ return f""
+
+ def _current_run_id(self) -> str:
+ job = self._client._get_job_data(self.job_id)
+ if not job.latest_run:
+ raise ValueError(f"job {self.job_id!r} has no run yet")
+ return job.latest_run.run_id
+
+ def load(self) -> "RunHandle":
+ """``GET /jobs/{id}`` → the latest run as a loaded
+ :class:`RunHandle`."""
+ job = self._client._get_job_data(self.job_id)
+ if not job.latest_run:
+ raise ValueError(f"job {self.job_id!r} has no run yet")
+ return RunHandle(self._client, self.job_id, job.latest_run.run_id, job.latest_run)
+
+ # ----- lifecycle -----
+
+ def pause(self) -> "RunHandle":
+ """``POST /jobs/{id}/pause`` — reversibly suspend the current
+ run: the dispatcher stops pulling its queue (in-flight tasks may
+ still settle), setting ``latest_run.pause_state = paused``.
+ Orthogonal to ``status``; undo with ``resume()``. Returns the
+ fresh loaded :class:`RunHandle`."""
+ run = self._client._post_pause(self.job_id)
+ return RunHandle(self._client, self.job_id, run.run_id, run)
+
+ def resume(self) -> "RunHandle":
+ """``POST /jobs/{id}/resume`` — un-pause the current run (the
+ dispatcher resumes polling). Returns the fresh loaded handle."""
+ run = self._client._post_resume(self.job_id)
+ return RunHandle(self._client, self.job_id, run.run_id, run)
+
+ def stop(self) -> "RunHandle":
+ """``POST /jobs/{id}/stop`` — terminally stop the current run.
+ Returns the fresh loaded :class:`RunHandle`."""
+ run = self._client._post_stop(self.job_id)
+ return RunHandle(self._client, self.job_id, run.run_id, run)
+
+ def cancel(self) -> "RunHandle":
+ """Alias for :meth:`stop`."""
+ return self.stop()
+
+ # ----- waiter -----
+
+ def wait(
+ self,
+ *,
+ target_statuses: set[str] | frozenset[str] | None = None,
+ failure_statuses: set[str] | frozenset[str] | None = None,
+ timeout: float = 300.0,
+ poll_interval: float = 2.0,
+ progress: bool = False,
+ ) -> "RunHandle":
+ """Block until the current run reaches a target state. Returns a
+ loaded :class:`RunHandle`, so chains like
+ ``job.run.wait().download_to_dir(...)`` work cleanly.
+
+ See ``ZenRowsBatchClient.wait_for_run`` for the full contract."""
+ run = self._client._wait_for_run_raw(
+ self.job_id,
+ run_id=None,
+ target_statuses=target_statuses,
+ failure_statuses=failure_statuses,
+ timeout=timeout,
+ poll_interval=poll_interval,
+ progress=progress,
+ )
+ return RunHandle(self._client, self.job_id, run.run_id, run)
+
+ # ----- results / content -----
+
+ def results(self, *, status: str | None = None) -> Iterator[TaskResult]:
+ """Auto-paginate task results from the current run."""
+ return self._client._iter_results_raw(self.job_id, status=status)
+
+ def task_history(self, task_id: str) -> TaskHistoryResponse:
+ """Current-run per-attempt event log for one task."""
+ return self._client._get_task_history_raw(self.job_id, task_id)
+
+ # ----- bulk download -----
+
+ def download_to_dir(
+ self,
+ target_dir: str | Path,
+ *,
+ status: str | None = "successful",
+ name_fn: Callable[[TaskResult], str] | None = None,
+ use_external_id: bool = False,
+ concurrency: int = 1,
+ progress: bool = False,
+ max_files: int | None = None,
+ max_bytes_per_file: int | None = None,
+ ) -> int:
+ return self._client._download_dir(
+ self.job_id,
+ None,
+ target_dir,
+ status=status,
+ name_fn=name_fn,
+ use_external_id=use_external_id,
+ concurrency=concurrency,
+ progress=progress,
+ max_files=max_files,
+ max_bytes_per_file=max_bytes_per_file,
+ )
+
+ def download_to_memory(
+ self,
+ *,
+ status: str | None = "successful",
+ concurrency: int = 1,
+ progress: bool = False,
+ max_count: int | None = None,
+ max_total_bytes: int | None = None,
+ max_bytes_per_file: int | None = None,
+ ) -> "list[DownloadedResult]":
+ return self._client._download_memory(
+ self.job_id,
+ None,
+ status=status,
+ concurrency=concurrency,
+ progress=progress,
+ max_count=max_count,
+ max_total_bytes=max_total_bytes,
+ max_bytes_per_file=max_bytes_per_file,
+ )
+
+ # ----- single-task download -----
+
+ def download_task_to_file(self, task: TaskResult, target: "str | Path | IO[bytes]") -> None:
+ """Download one ``task``'s body straight from its presigned
+ ``result_url`` to ``target`` — a path (``str`` / ``Path``) or an
+ open binary file object. The per-task counterpart of
+ ``download_to_dir`` — handy inside a ``results()`` loop when a
+ Python-side filter picks which bodies to keep."""
+ _download_task_to_file(task, target)
+
+ def download_task_to_memory(self, task: TaskResult) -> bytes:
+ """Download one ``task``'s body straight from its presigned
+ ``result_url`` and return the raw bytes."""
+ return _download_task_to_memory(task)
+
+ # ----- async results-zip export -----
+
+ def start_export(self) -> "ExportRef":
+ """``POST .../exports`` — kick off an async zip of the current
+ run's bodies. Resolves the current run id first (one GET), then
+ returns an :class:`ExportRef`."""
+ return self._client.start_results_export(self.job_id, self._current_run_id())
+
+ def download_all_results(
+ self,
+ target_path: str | Path,
+ *,
+ wait_timeout: float = 600.0,
+ poll_interval: float = 2.0,
+ ) -> Path:
+ """Start an export of the current run's results, wait for it,
+ and save the zip to ``target_path``. See
+ ``ZenRowsBatchClient.download_all_results`` for the contract.
+
+ Capped at 1 GiB per run server-side; for larger runs use
+ ``download_to_dir`` (no size limit, but slower — one body at a
+ time, tunable via ``concurrency=``)."""
+ return self._client.download_all_results(
+ self.job_id,
+ self._current_run_id(),
+ target_path,
+ wait_timeout=wait_timeout,
+ poll_interval=poll_interval,
+ )
+
+
+# ===================== schedule facet =====================
+
+
+class ScheduleControls:
+ """Operations on a scheduled job's schedule, reached via
+ ``job.schedule``. Scheduled jobs only — regular jobs 409."""
+
+ def __init__(self, client: "ZenRowsBatchClient", job_id: str):
+ self._client = client
+ self.job_id = job_id
+
+ def __repr__(self) -> str:
+ return f""
+
+ def pause(self) -> "JobHandle":
+ """``POST /jobs/{id}/schedule/state`` → ``paused`` — skip future
+ scheduled fires (an in-flight run keeps running). The schedule
+ keeps ticking server-side but fires are dropped until
+ ``resume()``. Idempotent; returns the fresh loaded handle."""
+ return JobHandle(
+ self._client, self.job_id, self._client._post_schedule_state(self.job_id, "paused")
+ )
+
+ def resume(self) -> "JobHandle":
+ """``POST /jobs/{id}/schedule/state`` → ``active`` — re-enable
+ scheduled fires on a paused job. Idempotent; returns the fresh
+ loaded handle."""
+ return JobHandle(
+ self._client, self.job_id, self._client._post_schedule_state(self.job_id, "active")
+ )
+
+ def update(self, schedule: "Schedule | JobScheduleDict") -> "JobHandle":
+ """``PUT /jobs/{id}/schedule`` — replace the schedule. Accepts a
+ typed builder (``At`` / ``Rate`` / ``Calendar``) or a raw
+ ``JobScheduleDict``, same as ``submit_scheduled``.
+
+ An in-flight run keeps running; the new schedule governs only
+ future fires. Returns the fresh loaded handle."""
+ resolved = self._client._resolve_schedule(schedule)
+ return JobHandle(
+ self._client, self.job_id, self._client._put_schedule(self.job_id, resolved)
+ )
+
+
+# ============================ jobs ============================
+
+
+class JobRef:
+ """A reference to a job by id — job-template operations, plus the
+ ``run`` and ``schedule`` sub-facets.
+
+ Minted with **no network call** by ``client.job(id)`` and returned
+ by the ``submit_*`` methods (with ``submit_response`` attached).
+ Call ``load()`` for a :class:`JobHandle` with data. Construction is
+ handled by the SDK — callers never build these directly.
+ """
+
+ def __init__(
+ self,
+ client: "ZenRowsBatchClient",
+ job_id: str,
+ *,
+ submit_response: SubmitJobResponse | None = None,
+ ):
+ self._client = client
+ self.job_id = job_id
+ # Populated only when the ref was returned by a submit_* call;
+ # exposes accepted_tasks + the initial status/latest_run without
+ # forcing a follow-up GET.
+ self.submit_response = submit_response
+ self._run_facet: CurrentRun | None = None
+ self._schedule_facet: ScheduleControls | None = None
+
+ def __repr__(self) -> str:
+ bits = [f"job_id={self.job_id!r}"]
+ if self.submit_response:
+ bits.append(f"status={self.submit_response.status.value!r}")
+ bits.append(f"accepted={self.submit_response.accepted_tasks}")
+ return f"<{type(self).__name__} {' '.join(bits)}>"
+
+ # ----- sub-facets -----
+
+ @property
+ def run(self) -> CurrentRun:
+ """Operations on the **current run** — ``pause`` / ``resume`` /
+ ``stop`` / ``wait`` / ``results`` / downloads / ``start_export``."""
+ if self._run_facet is None:
+ self._run_facet = CurrentRun(self._client, self.job_id)
+ return self._run_facet
+
+ @property
+ def schedule(self) -> ScheduleControls:
+ """Operations on the **schedule** — ``pause`` / ``resume`` /
+ ``update`` (scheduled jobs only)."""
+ if self._schedule_facet is None:
+ self._schedule_facet = ScheduleControls(self._client, self.job_id)
+ return self._schedule_facet
+
+ # ----- snapshot accessors (submit-response backed) -----
+
+ @property
+ def status(self) -> JobStatus | None:
+ """The job status from the submit response. Only known on refs
+ returned by a ``submit_*`` call (no network); ``None`` otherwise
+ — ``load()`` for a :class:`JobHandle` whose ``.status`` reads the
+ fetched ``.data``."""
+ return self.submit_response.status if self.submit_response else None
+
+ @property
+ def accepted_tasks(self) -> int | None:
+ """How many tasks landed at submit. Only known on refs returned
+ by a ``submit_*`` call; returns ``None`` otherwise."""
+ return self.submit_response.accepted_tasks if self.submit_response else None
+
+ def load(self) -> "JobHandle":
+ """``GET /jobs/{id}`` — fetch the full job and return a loaded
+ :class:`JobHandle` whose ``.data`` is ready."""
+ return JobHandle(self._client, self.job_id, self._client._get_job_data(self.job_id))
+
+ # ----- lifecycle -----
+
+ def close(self) -> "JobHandle":
+ """``POST /jobs/{id}/close`` — lock the job (no more ``add_tasks``).
+ Returns the fresh loaded handle."""
+ return JobHandle(self._client, self.job_id, self._client._post_close(self.job_id))
+
+ def delete(self) -> None:
+ """``DELETE /jobs/{id}`` — async hard delete."""
+ self._client._delete(self.job_id)
+
+ def rerun(
+ self,
+ *,
+ status: str | list[str] | None = None,
+ idempotency_key: str | None = None,
+ ) -> "RunHandle":
+ """``POST /jobs/{id}/rerun[?status=...]`` — start a new run.
+
+ Without ``status``: full rerun of the previous run's tasks
+ (or, for a scheduled job with no prior run, a manual fire
+ from the template). No source-child link.
+
+ With ``status`` (single value like ``"failed"`` or list like
+ ``["failed", "pending"]``): partial retry — matching statuses
+ are reset to ``pending`` and re-enqueued; everything else is
+ inherited verbatim with ``source_run_id`` stamped.
+
+ Returns a :class:`RunHandle` for the newly-created run."""
+ resp: RerunJobResponse = self._client._post_rerun(
+ self.job_id, status=status, idempotency_key=idempotency_key
+ )
+ return RunHandle(self._client, self.job_id, resp.latest_run.run_id, resp.latest_run)
+
+ def retry_failed(
+ self,
+ *,
+ include_pending: bool = False,
+ idempotency_key: str | None = None,
+ ) -> "RunHandle":
+ """Start a new run that re-executes only the previous run's
+ **failed** tasks (partial retry). Successful tasks
+ are inherited verbatim, so the new run's totals already carry
+ the prior successes — you only pay to re-scrape what failed.
+
+ Shortcut for ``rerun(status="failed")``. Set
+ ``include_pending=True`` to also re-enqueue tasks that never
+ started (``status="failed,pending"``) — the usual move after a
+ ``stop()`` left orphan ``pending`` rows.
+
+ Returns a :class:`RunHandle` for the new run. Requires the
+ previous run to be terminal (``completed`` / ``stopped``); raises
+ ``BatchAPIError`` (409 ``run_not_terminal``) otherwise, and
+ (409 ``no_matching_tasks``) when nothing matched the filter.
+ """
+ statuses = ["failed", "pending"] if include_pending else "failed"
+ return self.rerun(status=statuses, idempotency_key=idempotency_key)
+
+ # ----- tasks (write path) -----
+
+ def add_tasks(self, body: AddTasksRequest | AddTasksDict) -> AddTasksResponse:
+ """``POST /jobs/{id}/tasks`` — append to the open initial run."""
+ return self._client._post_tasks(self.job_id, body)
+
+ # ----- runs (list) -----
+
+ def runs(self, *, page_size: int | None = None) -> Iterator["RunHandle"]:
+ """Auto-paginate runs of this job, yielding :class:`RunHandle`s.
+ To address a *specific* run by id, use
+ ``client.run(job_id, run_id)``; for the current run, ``job.run``."""
+ for run in self._client._iter_runs_raw(self.job_id, page_size=page_size):
+ yield RunHandle(self._client, self.job_id, run.run_id, run)
+
+ # ----- file inputs -----
+
+ def add_file_input(
+ self,
+ source: str | Path | IO[bytes],
+ *,
+ fields: dict[str, int | str],
+ header: bool = False,
+ delimiter: str = ",",
+ quote: str = '"',
+ ) -> str:
+ """Upload a CSV that *this job* would consume on a future
+ submission. Returns the ``file_input_id``. (File inputs are
+ not tied to a job at create-time, but living off the handle
+ keeps the call site discoverable.)"""
+ return self._client.upload_csv(
+ source,
+ fields=fields,
+ header=header,
+ delimiter=delimiter,
+ quote=quote,
+ )
+
+ # ----- webhooks -----
+
+ def get_webhook(self) -> WebhookConfig:
+ """`GET /jobs/{id}/webhook` — this job's current webhook config.
+ Raises `BatchAPIError` (404) when none is set."""
+ return self._client.get_job_webhook(self.job_id)
+
+ def set_webhook(self, url: str, *, signature: bool) -> WebhookConfig:
+ """`PUT /jobs/{id}/webhook` — replace this job's webhook config.
+ Both fields are required (no defaulting, so you can't silently
+ toggle signing); pass `signature=False` explicitly for an
+ unsigned receiver. Returns the persisted config."""
+ return self._client.put_job_webhook(self.job_id, {"url": url, "signature": signature})
+
+ def delete_webhook(self) -> None:
+ """`DELETE /jobs/{id}/webhook` — clear this job's webhook config.
+ Idempotent."""
+ self._client.delete_job_webhook(self.job_id)
+
+ # ----- ingest waiter -----
+
+ def wait_for_ingest(
+ self,
+ *,
+ timeout: float = 300.0,
+ poll_interval: float = 2.0,
+ max_poll_interval: float = 15.0,
+ ) -> "JobHandle":
+ """Block until the current run's async-carrier ingestion has
+ finished writing task rows.
+
+ Large submissions return ``202 Accepted`` and stream task rows
+ into storage off the request path; until that finishes,
+ results pages may be partial and ``add_tasks`` on an open job
+ is rejected with 409. This polls ``GET /jobs/{id}`` until
+ ``latest_run.ingest_status`` leaves ``pending`` (also satisfied
+ by a terminal run — a mid-ingest stop flips the field to
+ ``done``). Runs that never ingested asynchronously (any 201
+ submission) are done on the first poll.
+
+ Raises ``WaiterTimeout`` after ``timeout`` seconds. Returns the
+ fresh loaded handle, so chains like
+ ``job.wait_for_ingest().add_tasks(...)`` work. Or opt in at
+ submit time via ``submit_job(..., wait_for_ingest=True)``."""
+ return JobHandle(
+ self._client,
+ self.job_id,
+ self._client._wait_for_ingest_raw(
+ self.job_id,
+ timeout=timeout,
+ poll_interval=poll_interval,
+ max_poll_interval=max_poll_interval,
+ ),
+ )
+
+
+class JobHandle(JobRef):
+ """A :class:`JobRef` plus a guaranteed, synchronous ``data`` snapshot.
+
+ Returned by ``get_job`` / ``iter_jobs`` and the ops that echo fresh
+ state (``close``, ``job.schedule.pause``, …). Inherits the ``run``
+ and ``schedule`` facets from :class:`JobRef`.
+ """
+
+ def __init__(
+ self,
+ client: "ZenRowsBatchClient",
+ job_id: str,
+ data: Job,
+ *,
+ submit_response: SubmitJobResponse | None = None,
+ ):
+ super().__init__(client, job_id, submit_response=submit_response)
+ self.data = data
+
+ def __repr__(self) -> str:
+ bits = [f"job_id={self.job_id!r}", f"status={self.data.status.value!r}"]
+ if self.data.latest_run:
+ t = self.data.latest_run.stats
+ bits.append(f"tasks={t.successful + t.failed}/{t.total}")
+ return f""
+
+ @property
+ def status(self) -> JobStatus:
+ """This job's status. Shortcut for ``self.data.status``."""
+ return self.data.status
+
+
+__all__ = [
+ "CurrentRun",
+ "ExportHandle",
+ "ExportRef",
+ "JobHandle",
+ "JobRef",
+ "RunHandle",
+ "RunRef",
+ "ScheduleControls",
+]
diff --git a/src/zenrows/batch/_schedule.py b/src/zenrows/batch/_schedule.py
new file mode 100644
index 0000000..2731d17
--- /dev/null
+++ b/src/zenrows/batch/_schedule.py
@@ -0,0 +1,257 @@
+"""Pythonic schedule builders for `submit_scheduled`.
+
+The wire format is a structured dict (`JobScheduleDict`) — three
+mutually exclusive shapes (`at` / `rate` / `calendar`) plus an
+optional `timezone`. The dict form is fine for power users; everyone
+else gets these typed classes:
+
+ from zenrows.batch import At, Rate, Calendar, Weekly
+ from datetime import datetime
+
+ client.submit_scheduled(
+ At(datetime(2026, 9, 1, 9, 0), timezone="Europe/Berlin"),
+ urls=["https://example.com/once"],
+ )
+ client.submit_scheduled(
+ Rate(every=15, unit="minute"),
+ urls=["https://example.com/poll"],
+ )
+ client.submit_scheduled(
+ Calendar(
+ times_of_day=["09:00", "18:00"],
+ cadence=Weekly(days=["mon", "wed", "fri"]),
+ timezone="Europe/Berlin",
+ ),
+ urls=["https://example.com/recurring"],
+ )
+
+Each class validates its inputs in `__post_init__`. The same rules
+the server enforces — naive `at`, full-hour `times_of_day`, day
+name spelling, valid IANA timezone — fail fast in Python rather
+than round-tripping through a 400.
+
+The low-level `client.submit_job({...})` path stays a pure
+passthrough: callers who hand-build a dict bypass these checks and
+rely on the server's response for error reporting.
+"""
+
+from __future__ import annotations
+
+import zoneinfo
+from dataclasses import dataclass
+from datetime import datetime
+from typing import TYPE_CHECKING, Literal, Union
+
+if TYPE_CHECKING:
+ from ._typed_dicts import JobScheduleDict
+
+# Public alias for type-checkers and docs.
+Schedule = Union["At", "Rate", "Calendar"]
+
+
+_DAYS_OF_WEEK = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
+
+
+def _validate_timezone(tz: str, field: str) -> None:
+ """Reject empty or non-IANA timezones."""
+ if not tz:
+ raise ValueError(f"{field} is required (IANA name, e.g. `Europe/Berlin`)")
+ try:
+ zoneinfo.ZoneInfo(tz)
+ except zoneinfo.ZoneInfoNotFoundError as e:
+ raise ValueError(f"{field}: {tz!r} is not a valid IANA timezone") from e
+
+
+def _validate_full_hour(s: str) -> None:
+ """Accept only `HH:00` (24h, full hours)."""
+ if len(s) != 5 or s[2] != ":" or s[3:] != "00":
+ raise ValueError(
+ f"times_of_day entry {s!r} must be on the hour (`HH:00`); "
+ "minute granularity is rejected."
+ )
+ try:
+ h = int(s[:2])
+ except ValueError as e:
+ raise ValueError(f"times_of_day entry {s!r} has a non-numeric hour") from e
+ if not 0 <= h <= 23:
+ raise ValueError(f"times_of_day entry {s!r} is not a valid hour (00..23)")
+
+
+@dataclass
+class At:
+ """One-shot fire at a specific wall-clock time.
+
+ `at` accepts either a tz-naive `datetime` (no tzinfo) or a
+ tz-naive ISO string (`"2026-09-01T09:00:00"`). Aware datetimes
+ and offset-bearing strings are rejected — `timezone` is the
+ single authoritative interpreter, which keeps DST transitions
+ deterministic.
+ """
+
+ at: str | datetime
+ timezone: str
+
+ def __post_init__(self) -> None:
+ _validate_timezone(self.timezone, "At.timezone")
+ if isinstance(self.at, datetime):
+ if self.at.tzinfo is not None and self.at.utcoffset() is not None:
+ raise ValueError(
+ "At.at must be a naive datetime (no tzinfo); supply "
+ "timezone separately to keep DST transitions deterministic."
+ )
+ elif isinstance(self.at, str):
+ if not self.at.strip():
+ raise ValueError("At.at must be a non-empty ISO timestamp string.")
+ if _has_tz_suffix(self.at):
+ raise ValueError(
+ f"At.at {self.at!r} must be tz-naive (no `Z`, no offset). "
+ "Supply timezone separately to keep DST transitions deterministic."
+ )
+ else:
+ raise TypeError(f"At.at must be a string or datetime, got {type(self.at).__name__}")
+
+ def to_dict(self) -> "JobScheduleDict":
+ at_str = self.at.strftime("%Y-%m-%dT%H:%M:%S") if isinstance(self.at, datetime) else self.at
+ return {"at": at_str, "timezone": self.timezone}
+
+
+@dataclass
+class Rate:
+ """Interval-based fire policy — every N units, no alignment to
+ wall clock. Timezone is irrelevant and not accepted here."""
+
+ every: int
+ unit: Literal["minute", "hour", "day"]
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.every, int) or isinstance(self.every, bool):
+ raise TypeError(f"Rate.every must be int, got {type(self.every).__name__}")
+ if self.every < 1:
+ raise ValueError(f"Rate.every must be >= 1, got {self.every}")
+ if self.unit not in ("minute", "hour", "day"):
+ raise ValueError(f"Rate.unit must be one of `minute`, `hour`, `day`; got {self.unit!r}")
+
+ def to_dict(self) -> "JobScheduleDict":
+ return {"rate": {"every": self.every, "unit": self.unit}}
+
+
+@dataclass
+class Daily:
+ """Fire every day. No knobs."""
+
+
+@dataclass
+class Weekly:
+ """Fire on specific days of the week.
+
+ `days` is a list of lower-case 3-letter day names — at least one,
+ deduplicated server-side.
+ """
+
+ days: list[str]
+
+ def __post_init__(self) -> None:
+ if not self.days:
+ raise ValueError("Weekly.days must be non-empty")
+ for d in self.days:
+ if d not in _DAYS_OF_WEEK:
+ raise ValueError(
+ f"Weekly.days entry {d!r} is not a valid day "
+ f"(use one of {', '.join(_DAYS_OF_WEEK)})"
+ )
+
+
+@dataclass
+class Monthly:
+ """Fire on specific days of the month.
+
+ `days` is a list of integers 1-31. Days that don't exist in a
+ given month (e.g. 31 in April) are silently skipped by the
+ scheduler.
+ """
+
+ days: list[int]
+
+ def __post_init__(self) -> None:
+ if not self.days:
+ raise ValueError("Monthly.days must be non-empty")
+ for d in self.days:
+ if not isinstance(d, int) or isinstance(d, bool):
+ raise TypeError(f"Monthly.days entries must be int, got {type(d).__name__}")
+ if not 1 <= d <= 31:
+ raise ValueError(f"Monthly.days entry {d} is out of range (1..31)")
+
+
+Cadence = Daily | Weekly | Monthly
+
+
+@dataclass
+class Calendar:
+ """Calendar-style fire policy: a list of times-of-day on a
+ daily / weekly / monthly cadence.
+
+ `times_of_day` are full-hour 24h strings (`"09:00"`, not
+ `"09:30"`). `cadence` is exactly one of `Daily()`, `Weekly(...)`,
+ or `Monthly(...)`. `timezone` is mandatory (IANA name).
+ """
+
+ times_of_day: list[str]
+ cadence: Cadence
+ timezone: str
+
+ def __post_init__(self) -> None:
+ _validate_timezone(self.timezone, "Calendar.timezone")
+ if not self.times_of_day:
+ raise ValueError("Calendar.times_of_day must be non-empty")
+ for t in self.times_of_day:
+ _validate_full_hour(t)
+ if not isinstance(self.cadence, (Daily, Weekly, Monthly)):
+ raise TypeError(
+ f"Calendar.cadence must be Daily/Weekly/Monthly, got {type(self.cadence).__name__}"
+ )
+
+ def to_dict(self) -> "JobScheduleDict":
+ cadence_dict: dict
+ if isinstance(self.cadence, Daily):
+ cadence_dict = {"daily": {}}
+ elif isinstance(self.cadence, Weekly):
+ cadence_dict = {"weekly": {"days": list(self.cadence.days)}}
+ elif isinstance(self.cadence, Monthly):
+ cadence_dict = {"monthly": {"days": list(self.cadence.days)}}
+ else: # pragma: no cover — caught in __post_init__
+ raise TypeError(f"unknown cadence: {type(self.cadence).__name__}")
+ return {
+ "calendar": {
+ "times_of_day": list(self.times_of_day),
+ "cadence": cadence_dict,
+ },
+ "timezone": self.timezone,
+ }
+
+
+def _has_tz_suffix(raw: str) -> bool:
+ """Return True if `raw` carries an RFC 3339-style tz tail (`Z` /
+ `+HH:MM` / `-HH:MM`)."""
+ raw = raw.strip()
+ # Find the time portion (after T or space).
+ for sep in ("T", " "):
+ i = raw.find(sep)
+ if i >= 0:
+ tail = raw[i + 1 :]
+ if tail.endswith(("Z", "z")):
+ return True
+ # `+HH:MM` or `-HH:MM` at the end (6 chars).
+ return bool(len(tail) >= 6 and tail[-6] in "+-")
+ return False
+
+
+__all__ = [
+ "At",
+ "Cadence",
+ "Calendar",
+ "Daily",
+ "Monthly",
+ "Rate",
+ "Schedule",
+ "Weekly",
+]
diff --git a/src/zenrows/batch/_transport.py b/src/zenrows/batch/_transport.py
new file mode 100644
index 0000000..20e45c5
--- /dev/null
+++ b/src/zenrows/batch/_transport.py
@@ -0,0 +1,228 @@
+"""HTTP transport for the Batch client.
+
+Owns the httpx Client, the `X-API-Key` auth header, default
+User-Agent, automatic retries for transient failures, and the RFC
+7807 → `BatchAPIError` mapping. The facade (`client.py`) is thin glue
+over this — one method per endpoint, all typed via pydantic v2 models.
+"""
+
+import random
+import time
+from typing import Any, TypeVar
+
+import httpx
+from pydantic import BaseModel
+
+from zenrows.batch._logging import log_error, log_request, logger
+from zenrows.batch.errors import BatchAPIError
+
+M = TypeVar("M", bound=BaseModel)
+
+_log = logger("transport")
+
+# Transient statuses worth retrying: 429 (rate limited), 502/503/504
+# (gateway / transient upstream — the spec marks 503 "safe to retry").
+_RETRYABLE_STATUSES = frozenset({429, 502, 503, 504})
+
+# Methods safe to replay without side effects. POST is added only when
+# the caller supplied an Idempotency-Key (submit / rerun).
+_IDEMPOTENT_METHODS = frozenset({"GET", "PUT", "DELETE", "HEAD", "OPTIONS"})
+
+# Retry tuning (mirrors the Node SDK): ~250ms · 2**attempt, ±20% jitter,
+# capped at 10s.
+_BACKOFF_BASE_MS = 250
+_BACKOFF_CAP_MS = 10_000
+_DEFAULT_RETRIES = 3
+
+
+def _has_idempotency_key(headers: dict[str, str] | None) -> bool:
+ return bool(headers) and any(k.lower() == "idempotency-key" for k in headers)
+
+
+def _backoff_ms(attempt: int) -> float:
+ """Jittered exponential backoff for retry `attempt` (0-based)."""
+ base = min(_BACKOFF_BASE_MS * 2**attempt, _BACKOFF_CAP_MS)
+ return base * (1 + (random.random() * 2 - 1) * 0.2)
+
+
+def _retry_after_ms(response: httpx.Response) -> float | None:
+ """Parse a `Retry-After` header (delta-seconds only) to milliseconds."""
+ raw = response.headers.get("Retry-After")
+ if not raw:
+ return None
+ try:
+ secs = float(raw)
+ except ValueError:
+ return None
+ return secs * 1000 if secs >= 0 else None
+
+
+class _Transport:
+ """Thin wrapper over `httpx.Client` with the Batch API's conventions.
+
+ Why not vanilla httpx? Three reasons we want centralised:
+ - Auth header (`X-API-Key`) is set once, not per call site.
+ - Every non-2xx is decoded as RFC 7807 and raised — handlers
+ never see a raw status code, they see `BatchAPIError.code`.
+ - Pydantic encode/decode lives in one place; method bodies in
+ `client.py` stay readable.
+ """
+
+ def __init__(
+ self,
+ *,
+ base_url: str,
+ api_key: str,
+ user_agent: str,
+ timeout: float | httpx.Timeout,
+ retries: int = _DEFAULT_RETRIES,
+ verify: bool | str = True,
+ httpx_args: dict[str, Any] | None = None,
+ ):
+ self._retries = max(0, retries)
+ self._client = httpx.Client(
+ base_url=base_url.rstrip("/"),
+ headers={
+ "X-API-Key": api_key,
+ "User-Agent": user_agent,
+ "Accept": "application/json",
+ },
+ timeout=httpx.Timeout(timeout) if isinstance(timeout, (int, float)) else timeout,
+ verify=verify,
+ **(httpx_args or {}),
+ )
+
+ # ----- retrying send -----
+
+ def _send(
+ self,
+ method: str,
+ path: str,
+ *,
+ params: dict[str, Any] | None,
+ headers: dict[str, str] | None,
+ content: bytes | None = None,
+ ) -> httpx.Response:
+ """Issue the request, retrying transient failures on idempotent
+ requests.
+
+ Retries `{429, 502, 503, 504}` and transient network errors up
+ to `retries` times, with jittered exponential backoff (honoring
+ `Retry-After` when present). Only idempotent requests are
+ replayed — `GET`/`PUT`/`DELETE`/`HEAD`/`OPTIONS`, plus `POST`
+ when the caller supplied an `Idempotency-Key`. Our own timeouts
+ (`httpx.TimeoutException`) are never retried: the caller set
+ that budget.
+ """
+ idempotent = method.upper() in _IDEMPOTENT_METHODS or (
+ method.upper() == "POST" and _has_idempotency_key(headers)
+ )
+ attempt = 0
+ while True:
+ try:
+ response = self._client.request(
+ method, path, params=params, headers=headers, content=content
+ )
+ except httpx.TimeoutException:
+ # Our own timeout budget — do not retry.
+ raise
+ except httpx.TransportError:
+ # Network-level failure (DNS, connection reset, TLS).
+ if idempotent and attempt < self._retries:
+ time.sleep(_backoff_ms(attempt) / 1000)
+ attempt += 1
+ continue
+ raise
+
+ if (
+ idempotent
+ and attempt < self._retries
+ and response.status_code in _RETRYABLE_STATUSES
+ ):
+ wait_ms = _retry_after_ms(response) or _backoff_ms(attempt)
+ response.close()
+ time.sleep(wait_ms / 1000)
+ attempt += 1
+ continue
+
+ return response
+
+ # ----- lifecycle -----
+
+ def close(self) -> None:
+ self._client.close()
+
+ def __enter__(self) -> "_Transport":
+ return self
+
+ def __exit__(self, *exc: object) -> None:
+ self.close()
+
+ @property
+ def base_url(self) -> str:
+ return str(self._client.base_url)
+
+ # ----- request helpers -----
+
+ def request_json(
+ self,
+ method: str,
+ path: str,
+ *,
+ body: BaseModel | None = None,
+ params: dict[str, Any] | None = None,
+ headers: dict[str, str] | None = None,
+ ) -> Any:
+ """Send a request, parse JSON, raise BatchAPIError on non-2xx.
+
+ Pydantic models in `body` are serialised via
+ `model_dump(mode="json", exclude_unset=True)` — the spec marks
+ every optional field with `omitempty`, so we send exactly what
+ the caller passed and let server defaults fill the rest.
+ """
+ content: bytes | None = None
+ if body:
+ payload = body.model_dump(
+ mode="json", exclude_unset=True, exclude_none=True, by_alias=True
+ )
+ import json
+
+ content = json.dumps(payload).encode("utf-8")
+ headers = {**(headers or {}), "Content-Type": "application/json"}
+
+ start = time.monotonic()
+ response = self._send(
+ method,
+ path,
+ params=_drop_none(params),
+ headers=headers,
+ content=content,
+ )
+ elapsed_ms = (time.monotonic() - start) * 1000
+
+ if response.status_code >= 400:
+ err = BatchAPIError.from_response(response)
+ log_error(
+ _log,
+ method=method,
+ path=path,
+ status=response.status_code,
+ code=err.code,
+ detail=err.problem.detail if err.problem else None,
+ )
+ raise err
+
+ log_request(
+ _log, method=method, path=path, status=response.status_code, elapsed_ms=elapsed_ms
+ )
+ if response.status_code == 204 or not response.content:
+ return None
+ return response.json()
+
+
+def _drop_none(d: dict[str, Any] | None) -> dict[str, Any] | None:
+ """Strip None-valued query params; httpx sends them as empty
+ strings otherwise, which the server then rejects as malformed."""
+ if not d:
+ return None
+ return {k: v for k, v in d.items() if v is not None}
diff --git a/src/zenrows/batch/_typed_dicts.py b/src/zenrows/batch/_typed_dicts.py
new file mode 100644
index 0000000..98b0fbb
--- /dev/null
+++ b/src/zenrows/batch/_typed_dicts.py
@@ -0,0 +1,138 @@
+"""TypedDict mirrors of the request models — for dict-literal callers.
+
+Most `client.submit_job(...)` / `add_tasks(...)` call sites pass a
+plain dict (it's nicer than constructing a pydantic model with the
+right kwargs). Without TypedDicts, those dicts get no autocomplete
+and no type-check; users discover misspellings at runtime.
+
+These TypedDicts mirror the pydantic models field-for-field. The
+client methods accept `Model | DictForm`; type-checkers route the
+dict literal against the TypedDict and surface IDE hints.
+
+Keep in sync with `models.py`. When a field is added there, mirror
+it here.
+"""
+
+import sys
+from datetime import datetime
+from typing import Any, Literal, TypedDict
+
+if sys.version_info >= (3, 11):
+ from typing import NotRequired
+else:
+ # `typing.NotRequired` only exists on 3.11+; the SDK floors at 3.10.
+ from typing_extensions import NotRequired
+
+# ----- task input -----
+
+
+class TaskInputDict(TypedDict, total=False):
+ """One task in a submit/AddTasks payload."""
+
+ url: str
+ external_id: NotRequired[str]
+ metadata: NotRequired[dict[str, str]]
+ # HTTP method against `url`: "GET" (default) | "POST". POST is for
+ # safe/idempotent requests only — tasks are retried, so the target
+ # may see the same POST more than once.
+ method: NotRequired[str]
+ # Request body, POST only. Any JSON value: object/array/number/bool
+ # → application/json; a str is sent verbatim as form-urlencoded.
+ body: NotRequired[Any]
+ zenrows_params: NotRequired[dict[str, Any]]
+
+
+# ----- submit -----
+
+
+class JobScheduleDict(TypedDict, total=False):
+ """Structured schedule block for `type: scheduled` submits.
+ Exactly one of `at`, `rate`, `calendar` must be set.
+
+ `at` accepts either a tz-naive ISO string (`"2026-09-01T09:00:00"`)
+ or a naive `datetime.datetime` (no `tzinfo`). The SDK normalises
+ to the string form before sending; aware datetimes are rejected
+ client-side with a ValueError."""
+
+ at: NotRequired[str | datetime]
+ rate: NotRequired[dict[str, Any]] # {"every": int, "unit": "minute"|"hour"|"day"}
+ calendar: NotRequired[dict[str, Any]] # times_of_day + cadence
+ timezone: NotRequired[str] # IANA name; mandatory for `at` and `calendar`
+
+
+class WebhookDict(TypedDict):
+ """Completion-callback config for a job. Set it at `POST /jobs`, or
+ manage it later via `PUT`/`DELETE /jobs/{id}/webhook`. `url` must be
+ HTTPS. Set `signature: true` to have each delivery signed with your
+ org's active HMAC key (delivered as an `X-Signature` header so your
+ receiver can verify it); it defaults to `false` (unsigned). Manage
+ signing keys via the `/hmac/keys` endpoints."""
+
+ url: str
+ signature: NotRequired[bool]
+
+
+class SubmitJobDict(TypedDict, total=False):
+ """Body of `POST /jobs`. Mirrors `SubmitJobRequest`."""
+
+ type: NotRequired[Literal["regular", "scheduled"]]
+ status: NotRequired[Literal["open", "closed"]]
+ zenrows_params: NotRequired[dict[str, Any]]
+ schedule: NotRequired[JobScheduleDict]
+ tasks: NotRequired[list[TaskInputDict]]
+ file_input_id: NotRequired[str]
+ external_id: NotRequired[str]
+ name: NotRequired[str]
+ metadata: NotRequired[dict[str, str]]
+ webhook: NotRequired[WebhookDict]
+
+
+# ----- add tasks -----
+
+
+class AddTasksDict(TypedDict):
+ """Body of `POST /jobs/{id}/tasks`."""
+
+ tasks: list[TaskInputDict]
+ last_batch: NotRequired[bool]
+
+
+# ----- file inputs -----
+
+
+class CSVFieldsDict(TypedDict, total=False):
+ """The `csv.fields` map in a CreateJobInput body."""
+
+ # Each value is `int` (0-based index) or `str` (column name,
+ # requires `header=True`). TypedDicts can't express ColumnRef
+ # exactly, but `int | str` covers both shapes.
+ url: int | str
+ external_id: NotRequired[int | str]
+
+
+class CSVSpecDict(TypedDict, total=False):
+ """Body of `csv` block on a CreateJobInput."""
+
+ fields: CSVFieldsDict
+ header: NotRequired[bool]
+ delimiter: NotRequired[str]
+ quote: NotRequired[str]
+
+
+class CreateJobInputDict(TypedDict, total=False):
+ """Body of `POST /job_inputs`."""
+
+ type: Literal["csv"]
+ csv: CSVSpecDict
+
+
+__all__ = [
+ "AddTasksDict",
+ "CSVFieldsDict",
+ "CSVSpecDict",
+ "CreateJobInputDict",
+ "JobScheduleDict",
+ "SubmitJobDict",
+ "TaskInputDict",
+ "WebhookDict",
+]
diff --git a/src/zenrows/batch/_waiters.py b/src/zenrows/batch/_waiters.py
new file mode 100644
index 0000000..e75ba27
--- /dev/null
+++ b/src/zenrows/batch/_waiters.py
@@ -0,0 +1,69 @@
+"""Polling helpers used by `ZenRowsBatchClient.wait_for_*` methods.
+
+Convention: a *waiter* polls a resource until it reaches a target
+state or a timeout/error fires. We default to exponential-ish
+backoff (capped) so short jobs don't pay a multi-second poll
+cadence and long ones don't hammer the API every two seconds.
+
+Errors are `WaiterTimeout` (timed out) and `WaiterError` (predicate
+raised, or the resource transitioned into an unexpected state the
+caller flagged as failure). Both subclass the stdlib `TimeoutError`
+/ `RuntimeError` so callers can catch with the broad stdlib types
+too.
+"""
+
+import random
+import time
+from collections.abc import Callable
+from typing import TypeVar
+
+T = TypeVar("T")
+
+
+class WaiterTimeout(TimeoutError):
+ """Raised when a waiter's `timeout` elapsed before the target state."""
+
+
+class WaiterError(RuntimeError):
+ """Raised when the resource entered a `failure_states` value."""
+
+
+def poll_until(
+ fetch: Callable[[], T],
+ *,
+ is_done: Callable[[T], bool],
+ is_failure: Callable[[T], bool] | None = None,
+ timeout: float = 300.0,
+ initial_interval: float = 1.0,
+ max_interval: float = 15.0,
+ backoff: float = 1.5,
+ jitter: float = 0.2,
+) -> T:
+ """Generic poll loop.
+
+ Calls `fetch()` repeatedly until `is_done(value)` is true (returns
+ the value) or `is_failure(value)` is true (raises `WaiterError`),
+ or `timeout` seconds elapse (raises `WaiterTimeout`).
+
+ The wait between calls starts at `initial_interval`, multiplies by
+ `backoff` each iteration, caps at `max_interval`, and is jittered
+ by ±`jitter` fraction so concurrent waiters don't synchronise into
+ thundering-herd patterns against the API.
+ """
+ deadline = time.monotonic() + timeout
+ interval = initial_interval
+ while True:
+ value = fetch()
+ if is_done(value):
+ return value
+ if is_failure and is_failure(value):
+ raise WaiterError(f"waiter: resource entered failure state ({value!r})")
+ now = time.monotonic()
+ if now >= deadline:
+ raise WaiterTimeout(f"waiter: timed out after {timeout:.1f}s waiting for target state")
+ # Jittered sleep, but never overshoot the deadline.
+ sleep = min(interval, deadline - now)
+ sleep = sleep * (1.0 + random.uniform(-jitter, jitter))
+ if sleep > 0:
+ time.sleep(sleep)
+ interval = min(interval * backoff, max_interval)
diff --git a/src/zenrows/batch/client.py b/src/zenrows/batch/client.py
new file mode 100644
index 0000000..dc5ea3a
--- /dev/null
+++ b/src/zenrows/batch/client.py
@@ -0,0 +1,1292 @@
+"""ZenRowsBatchClient — the friendly, typed facade.
+
+The **main pattern** is resource-style, in two tiers (see
+`_resources.py`): a `JobRef` / `RunRef` — id-only operations, no data —
+or a loaded `JobHandle` / `RunHandle` whose `.data` snapshot is
+synchronous and guaranteed. `client.job(id)` and the `submit_*` methods
+give you a ref; `get_job` / `iter_jobs` and `ref.load()` give you a
+loaded handle:
+
+ ref = client.submit_regular([...]) # JobRef — no GET yet
+ run = ref.wait() # loaded RunHandle
+ ref.download_to_dir("./out", # bulk download…
+ concurrency=8, # …in parallel…
+ progress=True) # …with a progress bar
+
+Handles are immutable snapshots: mutating ops (`close`, `stop`, …)
+return a *new* loaded handle carrying the server's fresh state rather
+than updating in place. The raw, pydantic-typed wire responses are
+still accessible — every loaded handle exposes a `.data` (full `Job` /
+`Run`), and refs returned by a `submit_*` call carry a
+`.submit_response` with the original wire-shape.
+
+Listing endpoints return raw pages (`ListJobsResponse`, etc.); the
+scanners (`iter_jobs`, `iter_runs`, `iter_results`) yield handles
+(or `TaskResult` for results, since those are terminal data — they
+don't have further-actions to chain).
+"""
+
+import os
+from collections.abc import Callable, Iterator, Mapping
+from datetime import datetime
+from enum import Enum
+from pathlib import Path
+from typing import IO, Any, Literal, TypeVar
+
+import httpx
+from pydantic import BaseModel
+
+from zenrows.__version__ import __version__
+from zenrows.batch._download import (
+ DEFAULT_MAX_BYTES_PER_FILE,
+ DEFAULT_MAX_COUNT_IN_MEMORY,
+ DEFAULT_MAX_FILES,
+ DEFAULT_MAX_TOTAL_BYTES_IN_MEMORY,
+ DownloadedResult,
+ download_to_dir,
+ download_to_memory,
+)
+from zenrows.batch._estimate import CostEstimate, ParamMap, _estimate_cost
+from zenrows.batch._resources import (
+ ExportHandle,
+ ExportRef,
+ JobHandle,
+ JobRef,
+ RunHandle,
+ RunRef,
+)
+from zenrows.batch._schedule import At, Calendar, Rate, Schedule
+from zenrows.batch._transport import _Transport
+from zenrows.batch._typed_dicts import (
+ AddTasksDict,
+ CreateJobInputDict,
+ JobScheduleDict,
+ SubmitJobDict,
+ TaskInputDict,
+ WebhookDict,
+)
+from zenrows.batch._waiters import poll_until
+from zenrows.batch.errors import BatchAPIError
+from zenrows.batch.models import (
+ AddTasksRequest,
+ AddTasksResponse,
+ CreateJobInputRequest,
+ CreateJobInputResponse,
+ Csv,
+ Export,
+ ExportStatus,
+ Fields,
+ FileInputColumnRef1,
+ FileInputColumnRef2,
+ HMACKeyCreated,
+ HMACKeyFinalized,
+ HMACKeyList,
+ IngestStatus,
+ Job,
+ JobSchedule,
+ JobStatus,
+ JobType,
+ ListJobRunsResponse,
+ ListJobsResponse,
+ ListResultsResponse,
+ RerunJobResponse,
+ Run,
+ RunStatus,
+ StartExportResponse,
+ SubmitJobRequest,
+ SubmitJobResponse,
+ TaskHistoryResponse,
+ TaskResult,
+ TestWebhookRequest,
+ TestWebhookResponse,
+ UpdateScheduleStateRequest,
+ WebhookConfig,
+)
+
+# Run states that mean "no further work will happen on this run".
+# Used as the default target for waiters; a run that's `completed`,
+# `stopped`, or `deleted` never transitions again.
+TERMINAL_RUN_STATUSES: frozenset[str] = frozenset(
+ {RunStatus.COMPLETED.value, RunStatus.STOPPED.value, RunStatus.DELETED.value}
+)
+
+# Export states that don't transition again — `completed` (zip ready)
+# or `failed` (size cap exceeded, fetch error, etc.). Used as the
+# waiter default.
+TERMINAL_EXPORT_STATUSES: frozenset[str] = frozenset(
+ {ExportStatus.COMPLETED.value, ExportStatus.FAILED.value}
+)
+
+# Production endpoint. Hardcoded so the common path needs no
+# configuration. The `base_url=` kwarg + `ZENROWS_BATCH_BASE_URL`
+# env var are present for advanced use only.
+DEFAULT_BASE_URL = "https://async.api.zenrows.com/v1"
+DEFAULT_USER_AGENT = f"zenrows-batch-python/{__version__}"
+
+M = TypeVar("M", bound=BaseModel)
+
+
+class ZenRowsBatchClient:
+ """Synchronous, typed client for the ZenRows Batch API.
+
+ `api_key` is required. Most users read it from a secrets store or
+ environment variable at the call site:
+
+ client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"])
+
+ Every non-2xx raises `BatchAPIError`; `BatchAPIError.code` carries
+ the stable `code` from the Problem body.
+ """
+
+ def __init__(
+ self,
+ api_key: str,
+ *,
+ base_url: str | None = None,
+ timeout: float | httpx.Timeout = 30.0,
+ retries: int = 3,
+ verify_ssl: bool | str = True,
+ user_agent: str = DEFAULT_USER_AGENT,
+ httpx_args: dict[str, Any] | None = None,
+ ):
+ """`retries` bounds automatic retries of transient failures
+ (HTTP 429/502/503/504 and network errors) on idempotent
+ requests — GET/PUT/DELETE and POSTs carrying an
+ `Idempotency-Key`. Retries use jittered exponential backoff and
+ honor `Retry-After`; set `retries=0` to disable."""
+ if not api_key:
+ raise ValueError("ZenRowsBatchClient: api_key is required.")
+ base_url = base_url or os.environ.get("ZENROWS_BATCH_BASE_URL") or DEFAULT_BASE_URL
+ self._t = _Transport(
+ base_url=base_url,
+ api_key=api_key,
+ user_agent=user_agent,
+ timeout=timeout,
+ retries=retries,
+ verify=verify_ssl,
+ httpx_args=httpx_args,
+ )
+
+ # ----- lifecycle -----
+
+ def __enter__(self) -> "ZenRowsBatchClient":
+ return self
+
+ def __exit__(self, *exc: object) -> None:
+ self.close()
+
+ def close(self) -> None:
+ self._t.close()
+
+ @property
+ def base_url(self) -> str:
+ return self._t.base_url
+
+ # ===== jobs (resource-returning) =====
+
+ def submit_job(
+ self,
+ body: SubmitJobRequest | SubmitJobDict,
+ *,
+ idempotency_key: str | None = None,
+ wait_for_ingest: bool = False,
+ ) -> JobRef:
+ """`POST /jobs` — submit a new scraping job. The general,
+ low-level path; most callers prefer the type-specific
+ `submit_regular` / `submit_scheduled` which hide the `type=`
+ boilerplate and give per-type validation in the signature.
+
+ Large submissions come back `202 Accepted` with task rows
+ still streaming into storage; `wait_for_ingest=True` blocks
+ until that ingestion finishes, so results pages are complete
+ and `add_tasks` won't 409 on return. It costs no extra calls
+ on the ordinary 201 path. For custom poll knobs, leave it
+ False and call `JobRef.wait_for_ingest()` yourself.
+
+ Returns a `JobRef` whose `.submit_response` carries the
+ immediate wire response (job_id, accepted_tasks, etc.). Call
+ `.load()` for a `JobHandle` with the full `.data` — or, when
+ `wait_for_ingest=True` actually polled, a loaded `JobHandle`
+ (with `.data`) is returned directly since the GET was paid
+ for anyway."""
+ body = _as_model(body, SubmitJobRequest)
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
+ resp = _parse(
+ self._t.request_json("POST", "/jobs", body=body, headers=headers),
+ SubmitJobResponse,
+ )
+ ref = JobRef(self, resp.job_id, submit_response=resp)
+ if (
+ wait_for_ingest
+ and resp.latest_run is not None
+ and resp.latest_run.ingest_status is IngestStatus.PENDING
+ ):
+ # We paid for the poll GET anyway — hand back a loaded
+ # JobHandle (still carrying the submit response) rather than
+ # a bare ref, so the caller has `.data` for free.
+ data = ref.wait_for_ingest().data
+ return JobHandle(self, resp.job_id, data, submit_response=resp)
+ return ref
+
+ # ----- type-specific submits (preferred over `submit_job` for
+ # most callers) -----
+
+ def submit_regular(
+ self,
+ urls: list[str | TaskInputDict] | None = None,
+ *,
+ file_input_id: str | None = None,
+ zenrows_params: ParamMap | None = None,
+ external_id: str | None = None,
+ name: str | None = None,
+ metadata: dict[str, str] | None = None,
+ webhook: WebhookDict | None = None,
+ idempotency_key: str | None = None,
+ wait_for_ingest: bool = False,
+ ) -> JobRef:
+ """Submit a one-shot scraping job (closed, all tasks upfront).
+
+ Pass `urls` as either bare strings or inline dicts carrying
+ per-task `external_id` / `metadata` / `params`:
+
+ client.submit_regular(["https://a", "https://b"])
+ client.submit_regular([
+ {"url": "https://a", "external_id": "ord-1"},
+ {"url": "https://b", "external_id": "ord-2"},
+ ])
+
+ `urls` and `file_input_id` are mutually exclusive — exactly
+ one must be set. The job is created with `status=closed`,
+ so no further `add_tasks` calls are accepted. For the
+ open-and-extend pattern see `submit_open`.
+ """
+ body = _build_submit_body(
+ job_type="regular",
+ urls=urls,
+ file_input_id=file_input_id,
+ status="closed",
+ zenrows_params=zenrows_params,
+ external_id=external_id,
+ name=name,
+ metadata=metadata,
+ webhook=webhook,
+ )
+ return self.submit_job(
+ body, idempotency_key=idempotency_key, wait_for_ingest=wait_for_ingest
+ )
+
+ def submit_open(
+ self,
+ urls: list[str | TaskInputDict] | None = None,
+ *,
+ zenrows_params: ParamMap | None = None,
+ external_id: str | None = None,
+ name: str | None = None,
+ metadata: dict[str, str] | None = None,
+ webhook: WebhookDict | None = None,
+ idempotency_key: str | None = None,
+ wait_for_ingest: bool = False,
+ ) -> JobRef:
+ """Submit a streaming-style job that stays open for more tasks.
+
+ Created with `status=open` — `urls` can be empty (or omitted)
+ and tasks are added later via `JobRef.add_tasks`. Close
+ the job with `job.close()` once you're done; until then the
+ run keeps running and accepting new work.
+
+ File-input upload is not supported here (the server only
+ accepts CSV inputs for `closed` regular jobs and scheduled
+ jobs).
+ """
+ body = _build_submit_body(
+ job_type="regular",
+ urls=urls,
+ file_input_id=None,
+ status="open",
+ zenrows_params=zenrows_params,
+ external_id=external_id,
+ name=name,
+ metadata=metadata,
+ webhook=webhook,
+ )
+ return self.submit_job(
+ body, idempotency_key=idempotency_key, wait_for_ingest=wait_for_ingest
+ )
+
+ def submit_scheduled(
+ self,
+ schedule: Schedule | JobScheduleDict,
+ urls: list[str | TaskInputDict] | None = None,
+ *,
+ file_input_id: str | None = None,
+ zenrows_params: ParamMap | None = None,
+ external_id: str | None = None,
+ name: str | None = None,
+ metadata: dict[str, str] | None = None,
+ webhook: WebhookDict | None = None,
+ idempotency_key: str | None = None,
+ ) -> JobRef:
+ """Submit a scheduled job.
+
+ `schedule` is one of the typed builders (`At`, `Rate`,
+ `Calendar`) or a raw `JobScheduleDict` for power users. The
+ typed builders validate their inputs in `__post_init__`;
+ the dict form is a passthrough — the server validates.
+
+ Examples:
+
+ ```python
+ from datetime import datetime
+ from zenrows.batch import At, Calendar, Rate, Weekly
+
+ # One-shot at 09:00 Berlin local time
+ client.submit_scheduled(
+ At(datetime(2026, 9, 1, 9, 0), timezone="Europe/Berlin"),
+ ["https://example.com/once"],
+ )
+
+ # Every 15 minutes (no timezone needed)
+ client.submit_scheduled(
+ Rate(every=15, unit="minute"),
+ ["https://example.com/poll"],
+ )
+
+ # 09:00 + 18:00 Berlin time, Mon/Wed/Fri
+ client.submit_scheduled(
+ Calendar(
+ times_of_day=["09:00", "18:00"],
+ cadence=Weekly(days=["mon", "wed", "fri"]),
+ timezone="Europe/Berlin",
+ ),
+ ["https://example.com/recurring"],
+ )
+ ```
+
+ Like `submit_regular`, `urls` is bare-strings-or-inline-dicts
+ and is mutually exclusive with `file_input_id`.
+ """
+ if isinstance(schedule, (At, Rate, Calendar)):
+ schedule = schedule.to_dict()
+ else:
+ schedule = _normalize_schedule(schedule)
+ body = _build_submit_body(
+ job_type="scheduled",
+ urls=urls,
+ file_input_id=file_input_id,
+ status="closed",
+ zenrows_params=zenrows_params,
+ schedule=schedule,
+ external_id=external_id,
+ name=name,
+ metadata=metadata,
+ webhook=webhook,
+ )
+ return self.submit_job(body, idempotency_key=idempotency_key)
+
+ def get_job(self, job_id: str) -> JobHandle:
+ """`GET /jobs/{job_id}` — returns a loaded `JobHandle` with
+ `.data` already populated.
+
+ **Want the full `Job` in one line?** This is it —
+ `client.get_job(job_id).data` gives you the complete pydantic
+ `Job`. It's the eager shortcut for `client.job(job_id).load()`
+ (same single GET, same handle); reach for the bare
+ `client.job(job_id)` ref only when you want to *act* on the id
+ without fetching it."""
+ data = self._get_job_data(job_id)
+ return JobHandle(self, job_id, data)
+
+ def job(self, job_id: str) -> JobRef:
+ """A `JobRef` for an existing job with **no network call**.
+ Lifecycle operations act on the id directly (`delete`, `close`,
+ `rerun`, `add_tasks`); current-run and schedule ops live on the
+ `.run` / `.schedule` facets; call `.load()` for a `JobHandle`
+ with `.data`. Prefer this over `get_job` when you just want to
+ act on a known id without fetching it first::
+
+ client.job(job_id).delete() # no round-trip
+ client.job(job_id).run.stop() # POST /jobs/{id}/stop
+ client.job(job_id).schedule.pause() # skip future fires
+ client.job(job_id).load().data.status # explicit GET
+ """
+ return JobRef(self, job_id)
+
+ # ----- cost estimation (local, no API call) -----
+
+ def estimate_cost(self, body: SubmitJobRequest | SubmitJobDict) -> CostEstimate:
+ """Estimate the credit cost of a job before submitting it,
+ assuming every task succeeds once. **Takes the same body you'd
+ hand `submit_job`** — a raw dict or a typed `SubmitJobRequest` —
+ so you estimate the exact job you're about to submit.
+
+ Returns a `CostEstimate` with `min`/`max` credits and a per-tier
+ `breakdown`; `min == max` (`.exact`) when no task uses
+ `mode=auto`. Per-task `zenrows_params` override the job-level
+ params on collision (task wins), matching the worker's merge.
+
+ `file_input` bodies estimate as zero tasks — the CSV row count
+ isn't known client-side; count the rows first.
+
+ This is the single entry point for estimation. It's currently
+ computed client-side from the SDK's rate card (no network call);
+ it may move server-side in a future release, so it lives on the
+ client to keep call sites stable.
+ """
+ model = _as_model(body, SubmitJobRequest)
+ return _estimate_cost(model.tasks or [], zenrows_params=model.zenrows_params)
+
+ def list_jobs(
+ self,
+ *,
+ limit: int | None = None,
+ cursor: str | None = None,
+ job_type: JobType | str | None = None,
+ status: JobStatus | str | None = None,
+ ) -> ListJobsResponse:
+ """`GET /jobs` — raw page (with `next_cursor`). For most uses
+ prefer the auto-paginating `iter_jobs`."""
+ return _parse(
+ self._t.request_json(
+ "GET",
+ "/jobs",
+ params={
+ "limit": limit,
+ "cursor": cursor,
+ # The wire-level query param is `type`; the
+ # Python kwarg is `job_type` so it doesn't
+ # shadow the builtin.
+ "type": _enum_value(job_type),
+ "status": _enum_value(status),
+ },
+ ),
+ ListJobsResponse,
+ )
+
+ def iter_jobs(
+ self,
+ *,
+ job_type: JobType | str | None = None,
+ status: JobStatus | str | None = None,
+ page_size: int | None = None,
+ ) -> Iterator[JobHandle]:
+ """Auto-paginate `list_jobs`, yielding loaded `JobHandle`s with
+ their `.data` pre-populated from the page."""
+ for job in _scan(
+ lambda cursor: self.list_jobs(
+ limit=page_size, cursor=cursor, job_type=job_type, status=status
+ ),
+ items_attr="jobs",
+ ):
+ yield JobHandle(self, job.job_id, job)
+
+ # ===== runs (resource-returning) =====
+
+ def get_run(self, job_id: str, *, run_id: str) -> RunHandle:
+ """`GET /jobs/{job_id}/runs/{run_id}` — returns a loaded
+ `RunHandle` with `.data` populated."""
+ data = self._get_run_data(job_id, run_id)
+ return RunHandle(self, job_id, run_id, data)
+
+ def run(self, job_id: str, run_id: str) -> RunRef:
+ """A `RunRef` for an existing run with **no network call** — the
+ run counterpart of `job`. Acts on `(job_id, run_id)` directly
+ (`delete`, `results`, `wait`); call `.load()` for a `RunHandle`
+ with `.data`::
+
+ client.run(job_id, run_id).delete() # scrub one run, no GET
+ """
+ return RunRef(self, job_id, run_id)
+
+ def list_runs(
+ self,
+ job_id: str,
+ *,
+ limit: int | None = None,
+ cursor: str | None = None,
+ ) -> ListJobRunsResponse:
+ """`GET /jobs/{job_id}/runs` — raw page. For most uses prefer
+ the auto-paginating `JobRef.runs()`."""
+ return _parse(
+ self._t.request_json(
+ "GET",
+ f"/jobs/{job_id}/runs",
+ params={"limit": limit, "cursor": cursor},
+ ),
+ ListJobRunsResponse,
+ )
+
+ def iter_runs(
+ self,
+ job_id: str,
+ *,
+ page_size: int | None = None,
+ ) -> Iterator[RunHandle]:
+ """Auto-paginate runs of a job, yielding loaded `RunHandle`s."""
+ for run in self._iter_runs_raw(job_id, page_size=page_size):
+ yield RunHandle(self, job_id, run.run_id, run)
+
+ # ===== results / content (terminal data) =====
+
+ def list_results(
+ self,
+ job_id: str,
+ *,
+ run_id: str | None = None,
+ status: str | None = None,
+ cursor: str | None = None,
+ ) -> ListResultsResponse:
+ """Raw results page. Prefer `iter_results` or
+ `JobRef.results()` for auto-pagination."""
+ path = f"/jobs/{job_id}/runs/{run_id}/results" if run_id else f"/jobs/{job_id}/results"
+ return _parse(
+ self._t.request_json("GET", path, params={"status": status, "cursor": cursor}),
+ ListResultsResponse,
+ )
+
+ def iter_results(
+ self,
+ job_id: str,
+ *,
+ run_id: str | None = None,
+ status: str | None = None,
+ ) -> Iterator[TaskResult]:
+ """Auto-paginate results, yielding `TaskResult` per row."""
+ return self._iter_results_raw(job_id, run_id=run_id, status=status)
+
+ # ===== waiter =====
+
+ def wait_for_run(
+ self,
+ job_id: str,
+ *,
+ run_id: str | None = None,
+ target_statuses: set[str] | frozenset[str] = TERMINAL_RUN_STATUSES,
+ failure_statuses: set[str] | frozenset[str] | None = None,
+ timeout: float = 300.0,
+ poll_interval: float = 2.0,
+ max_poll_interval: float = 15.0,
+ progress: bool = False,
+ ) -> Run:
+ """Block until a run reaches one of `target_statuses`, polling
+ with jittered exponential backoff.
+
+ `progress=True` shows a tqdm bar with totals as they advance.
+ `None` inherits the client-level `progress` setting (which
+ itself defaults to off unless `ZENROWS_BATCH_PROGRESS=true`).
+ """
+ return self._wait_for_run_raw(
+ job_id,
+ run_id=run_id,
+ target_statuses=target_statuses,
+ failure_statuses=failure_statuses,
+ timeout=timeout,
+ poll_interval=poll_interval,
+ max_poll_interval=max_poll_interval,
+ progress=progress,
+ )
+
+ # ===== file inputs (CSV uploads) =====
+
+ def create_job_input(
+ self,
+ body: CreateJobInputRequest | CreateJobInputDict,
+ ) -> CreateJobInputResponse:
+ """`POST /job_inputs` — allocate a CSV upload slot. Most
+ callers want the higher-level `upload_csv` instead."""
+ body = _as_model(body, CreateJobInputRequest)
+ return _parse(
+ self._t.request_json("POST", "/job_inputs", body=body),
+ CreateJobInputResponse,
+ )
+
+ def upload_csv(
+ self,
+ source: str | Path | IO[bytes],
+ *,
+ fields: dict[str, int | str],
+ header: bool = False,
+ delimiter: str = ",",
+ quote: str = '"',
+ ) -> str:
+ """Allocate a CSV slot + PUT the body. Returns the
+ `file_input_id` you then pass to `submit_job(...)`."""
+ ref_fields: dict[str, Any] = {}
+ for key in ("url", "external_id"):
+ if key not in fields:
+ continue
+ v = fields[key]
+ ref_fields[key] = (
+ FileInputColumnRef1(root=v) if isinstance(v, str) else FileInputColumnRef2(root=v)
+ )
+ request = CreateJobInputRequest(
+ type="csv",
+ csv=Csv(
+ delimiter=delimiter,
+ quote=quote,
+ header=header,
+ fields=Fields.model_validate(ref_fields),
+ ),
+ )
+ created = self.create_job_input(request)
+
+ headers = dict(created.upload.headers or {})
+ headers.setdefault("Content-Type", "text/csv")
+
+ data = _read_csv_body(source)
+
+ # The presigned URL lives on a different host (S3 / dev
+ # _local route). Use a bare httpx so our auth header doesn't
+ # leak there.
+ with httpx.Client(timeout=httpx.Timeout(60.0)) as bare:
+ r = bare.request(
+ created.upload.method, str(created.upload.url), content=data, headers=headers
+ )
+ if r.status_code >= 400:
+ raise BatchAPIError(r.status_code, problem=None, raw=r.content)
+
+ return created.file_input_id
+
+ # ===== Webhooks =====
+
+ def get_job_webhook(self, job_id: str) -> WebhookConfig:
+ """`GET /jobs/{job_id}/webhook` — the job's current webhook
+ config. Raises `BatchAPIError` (404) when none is set."""
+ return _parse(self._t.request_json("GET", f"/jobs/{job_id}/webhook"), WebhookConfig)
+
+ def put_job_webhook(self, job_id: str, config: WebhookConfig | WebhookDict) -> WebhookConfig:
+ """`PUT /jobs/{job_id}/webhook` — replace the webhook config
+ wholesale. Both `url` and `signature` are required (no
+ defaulting at the mutate boundary, so a partial update can't
+ silently toggle signing). Returns the persisted config."""
+ model = _as_model(config, WebhookConfig)
+ return _parse(
+ self._t.request_json("PUT", f"/jobs/{job_id}/webhook", body=model), WebhookConfig
+ )
+
+ def delete_job_webhook(self, job_id: str) -> None:
+ """`DELETE /jobs/{job_id}/webhook` — clear the webhook config.
+ Idempotent: 204 whether or not one was set."""
+ self._t.request_json("DELETE", f"/jobs/{job_id}/webhook")
+
+ def test_webhook(self, config: TestWebhookRequest | WebhookDict) -> TestWebhookResponse:
+ """`POST /webhook/test` — dispatch a synthetic `webhook.test`
+ event to a receiver URL and report the outcome, without touching
+ any job. Handy to verify a receiver before you wire it to a job.
+
+ `signature` defaults to `false`; set it `true` to exercise the
+ HMAC path (requires an active signing key, else 400
+ `webhook_signing_requires_active_key`)."""
+ model = _as_model(config, TestWebhookRequest)
+ return _parse(
+ self._t.request_json("POST", "/webhook/test", body=model), TestWebhookResponse
+ )
+
+ # ===== HMAC key lifecycle =====
+
+ def list_hmac_keys(self) -> HMACKeyList:
+ return _parse(self._t.request_json("GET", "/hmac/keys"), HMACKeyList)
+
+ def rotate_hmac_key(self) -> HMACKeyCreated:
+ """Capture the returned `secret` HERE — it is not revealed again."""
+ return _parse(self._t.request_json("POST", "/hmac/keys/rotate"), HMACKeyCreated)
+
+ def finalize_hmac_key(self) -> HMACKeyFinalized:
+ return _parse(
+ self._t.request_json("POST", "/hmac/keys/rotate/finalize"),
+ HMACKeyFinalized,
+ )
+
+ def cancel_hmac_rotation(self) -> None:
+ self._t.request_json("DELETE", "/hmac/keys/rotate")
+
+ # ===== Results exports =====
+
+ def start_results_export(self, job_id: str, run_id: str) -> ExportRef:
+ """`POST /jobs/{job_id}/runs/{run_id}/exports` — start an async
+ zip of every task body in the run. Returns an `ExportRef`
+ carrying the just-issued `export_id`; call `.load()` or block on
+ `.wait()` for the download URL.
+
+ Failure modes: 404 if the job/run is missing or not yours.
+ The worker fails the export (status=failed,
+ error="results are larger then 1 gb") if the pre-zip total
+ exceeds the server's 1 GiB cap.
+ """
+ resp = self._post_export_start(job_id, run_id)
+ return ExportRef(self, job_id, run_id, resp.export_id, start_response=resp)
+
+ def get_results_export(self, job_id: str, run_id: str, export_id: str) -> ExportHandle:
+ """`GET /jobs/{job_id}/runs/{run_id}/exports/{export_id}` —
+ snapshot one export. Returns a loaded `ExportHandle` with `.data`
+ pre-populated. 404 covers both "no such id" and "TTL-swept"."""
+ data = self._get_export(job_id, run_id, export_id)
+ return ExportHandle(self, job_id, run_id, export_id, data)
+
+ def wait_for_export(
+ self,
+ job_id: str,
+ run_id: str,
+ export_id: str,
+ *,
+ target_statuses: set[str] | frozenset[str] = TERMINAL_EXPORT_STATUSES,
+ timeout: float = 600.0,
+ poll_interval: float = 2.0,
+ max_poll_interval: float = 15.0,
+ ) -> Export:
+ """Block until an export reaches a terminal state. Defaults to
+ `{completed, failed}`. Returns the `Export` snapshot — callers
+ check `.status` and (on `completed`) `.download_url`.
+
+ `failed` is NOT raised; the caller decides whether
+ `error="results are larger then 1 gb"` is fatal or expected.
+ """
+ return self._wait_for_export_raw(
+ job_id,
+ run_id,
+ export_id,
+ target_statuses=target_statuses,
+ timeout=timeout,
+ poll_interval=poll_interval,
+ max_poll_interval=max_poll_interval,
+ )
+
+ # ==========================================================
+ # ===== "raw" methods (no resource wrapping; used by =====
+ # ===== handles internally and as escape hatches). =====
+ # ==========================================================
+
+ def _get_job_data(self, job_id: str) -> Job:
+ return _parse(self._t.request_json("GET", f"/jobs/{job_id}"), Job)
+
+ def _get_run_data(self, job_id: str, run_id: str) -> Run:
+ return _parse(self._t.request_json("GET", f"/jobs/{job_id}/runs/{run_id}"), Run)
+
+ def _post_close(self, job_id: str) -> Job:
+ return _parse(self._t.request_json("POST", f"/jobs/{job_id}/close"), Job)
+
+ def _post_stop(self, job_id: str) -> Run:
+ # `/stop`, `/pause`, `/resume` all act on the current run and
+ # echo the refreshed Run object (not the Job).
+ return _parse(self._t.request_json("POST", f"/jobs/{job_id}/stop"), Run)
+
+ def _post_pause(self, job_id: str) -> Run:
+ return _parse(self._t.request_json("POST", f"/jobs/{job_id}/pause"), Run)
+
+ def _post_resume(self, job_id: str) -> Run:
+ return _parse(self._t.request_json("POST", f"/jobs/{job_id}/resume"), Run)
+
+ def _delete(self, job_id: str) -> None:
+ self._t.request_json("DELETE", f"/jobs/{job_id}")
+
+ def _delete_run(self, job_id: str, run_id: str) -> None:
+ self._t.request_json("DELETE", f"/jobs/{job_id}/runs/{run_id}")
+
+ def _post_rerun(
+ self,
+ job_id: str,
+ *,
+ status: str | list[str] | None = None,
+ idempotency_key: str | None = None,
+ ) -> RerunJobResponse:
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
+ params: dict[str, Any] | None = None
+ if status is not None:
+ if isinstance(status, list):
+ params = {"status": ",".join(status)}
+ else:
+ params = {"status": status}
+ return _parse(
+ self._t.request_json("POST", f"/jobs/{job_id}/rerun", params=params, headers=headers),
+ RerunJobResponse,
+ )
+
+ def _resolve_schedule(self, schedule: "Schedule | JobScheduleDict") -> JobSchedule:
+ """Normalise a schedule (typed builder, or dict with a possible
+ `datetime` in `at`) into a validated `JobSchedule` model — the
+ same resolution `submit_scheduled` does."""
+ if isinstance(schedule, (At, Rate, Calendar)):
+ schedule = schedule.to_dict()
+ else:
+ schedule = _normalize_schedule(schedule)
+ return _as_model(schedule, JobSchedule)
+
+ def _put_schedule(self, job_id: str, schedule: JobSchedule) -> Job:
+ return _parse(
+ self._t.request_json("PUT", f"/jobs/{job_id}/schedule", body=schedule),
+ Job,
+ )
+
+ def _post_schedule_state(self, job_id: str, state: str) -> Job:
+ body = UpdateScheduleStateRequest(schedule_state=state) # type: ignore
+ return _parse(
+ self._t.request_json("POST", f"/jobs/{job_id}/schedule/state", body=body),
+ Job,
+ )
+
+ def _post_tasks(self, job_id: str, body: AddTasksRequest | AddTasksDict) -> AddTasksResponse:
+ body = _as_model(body, AddTasksRequest)
+ return _parse(
+ self._t.request_json("POST", f"/jobs/{job_id}/tasks", body=body),
+ AddTasksResponse,
+ )
+
+ def _iter_runs_raw(self, job_id: str, *, page_size: int | None = None) -> Iterator[Run]:
+ yield from _scan(
+ lambda cursor: self.list_runs(job_id, limit=page_size, cursor=cursor),
+ items_attr="runs",
+ )
+
+ def _iter_results_raw(
+ self,
+ job_id: str,
+ *,
+ run_id: str | None = None,
+ status: str | None = None,
+ ) -> Iterator[TaskResult]:
+ yield from _scan(
+ lambda cursor: self.list_results(job_id, run_id=run_id, status=status, cursor=cursor),
+ items_attr="results",
+ )
+
+ def _get_task_history_raw(
+ self, job_id: str, task_id: str, *, run_id: str | None = None
+ ) -> TaskHistoryResponse:
+ path = (
+ f"/jobs/{job_id}/runs/{run_id}/tasks/{task_id}/history"
+ if run_id
+ else f"/jobs/{job_id}/tasks/{task_id}/history"
+ )
+ return _parse(self._t.request_json("GET", path), TaskHistoryResponse)
+
+ def _post_export_start(self, job_id: str, run_id: str) -> StartExportResponse:
+ return _parse(
+ self._t.request_json("POST", f"/jobs/{job_id}/runs/{run_id}/exports"),
+ StartExportResponse,
+ )
+
+ def _get_export(self, job_id: str, run_id: str, export_id: str) -> Export:
+ return _parse(
+ self._t.request_json("GET", f"/jobs/{job_id}/runs/{run_id}/exports/{export_id}"),
+ Export,
+ )
+
+ def _wait_for_export_raw(
+ self,
+ job_id: str,
+ run_id: str,
+ export_id: str,
+ *,
+ target_statuses: set[str] | frozenset[str],
+ timeout: float,
+ poll_interval: float,
+ max_poll_interval: float = 15.0,
+ ) -> Export:
+ target = target_statuses or TERMINAL_EXPORT_STATUSES
+
+ def fetch() -> Export:
+ return self._get_export(job_id, run_id, export_id)
+
+ def is_done(e: Export) -> bool:
+ return e.status.value in target
+
+ return poll_until(
+ fetch=fetch,
+ is_done=is_done,
+ timeout=timeout,
+ initial_interval=poll_interval,
+ max_interval=max_poll_interval,
+ )
+
+ def download_all_results(
+ self,
+ job_id: str,
+ run_id: str,
+ target_path: str | Path,
+ *,
+ wait_timeout: float = 600.0,
+ poll_interval: float = 2.0,
+ chunk_size: int = 1 << 20,
+ ) -> Path:
+ """End-to-end helper: start an export, wait for it, and save
+ the zip to `target_path`.
+
+ Steps:
+ 1. `POST .../exports` (start) → `export_id`.
+ 2. Poll `GET .../exports/{id}` until `completed` or `failed`.
+ 3. On `completed`, stream the presigned URL to `target_path`.
+
+ Raises `WaiterTimeout` if the export doesn't reach a terminal
+ state within `wait_timeout`. Raises `BatchAPIError` with the
+ server's error message on `status=failed` (e.g.
+ `"results are larger then 1 gb"`). Returns the `Path` written.
+
+ The server-side export is capped at 1 GiB per run. For larger
+ runs (or one file per task), use `download_to_dir` instead: it
+ fetches each body client-side with no size limit, at the cost
+ of being slower (one body at a time, tunable via `concurrency=`).
+ """
+ export = self.start_results_export(job_id, run_id)
+ final = self.wait_for_export(
+ job_id,
+ run_id,
+ export.export_id,
+ timeout=wait_timeout,
+ poll_interval=poll_interval,
+ )
+ if final.status != ExportStatus.COMPLETED:
+ raise BatchAPIError(
+ status_code=0,
+ problem=None,
+ raw=(final.error or "export failed").encode(),
+ )
+ if not final.download_url:
+ raise BatchAPIError(
+ status_code=0,
+ problem=None,
+ raw=b"export completed but server returned no download_url",
+ )
+
+ target = Path(target_path)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ # Presigned URLs live on a different host (S3 / dev `_local`).
+ # Use a bare httpx so our auth header doesn't leak there.
+ with (
+ httpx.Client(timeout=httpx.Timeout(60.0)) as bare,
+ bare.stream("GET", str(final.download_url)) as r,
+ ):
+ if r.status_code >= 400:
+ raise BatchAPIError(r.status_code, problem=None, raw=r.read())
+ with target.open("wb") as f:
+ for chunk in r.iter_bytes(chunk_size):
+ f.write(chunk)
+ return target
+
+ def _wait_for_run_raw(
+ self,
+ job_id: str,
+ *,
+ run_id: str | None,
+ target_statuses: set[str] | frozenset[str] | None,
+ failure_statuses: set[str] | frozenset[str] | None,
+ timeout: float,
+ poll_interval: float,
+ max_poll_interval: float = 15.0,
+ progress: bool = False,
+ ) -> Run:
+ target = target_statuses or TERMINAL_RUN_STATUSES
+
+ def fetch() -> Run:
+ if not run_id:
+ job = self._get_job_data(job_id)
+ if not job.latest_run:
+ return _PendingRun() # type: ignore
+ return job.latest_run
+ return self._get_run_data(job_id, run_id)
+
+ def is_done(run: Run) -> bool:
+ if isinstance(run, _PendingRun):
+ return False
+ return run.status.value in target
+
+ def is_failure(run: Run) -> bool:
+ if not failure_statuses or isinstance(run, _PendingRun):
+ return False
+ return run.status.value in failure_statuses
+
+ with _maybe_wait_progress(progress, job_id=job_id) as on_poll:
+
+ def fetch_with_progress() -> Run:
+ run = fetch()
+ on_poll(run)
+ return run
+
+ return poll_until(
+ fetch=fetch_with_progress,
+ is_done=is_done,
+ is_failure=is_failure,
+ timeout=timeout,
+ initial_interval=poll_interval,
+ max_interval=max_poll_interval,
+ )
+
+ def _wait_for_ingest_raw(
+ self,
+ job_id: str,
+ *,
+ timeout: float,
+ poll_interval: float,
+ max_poll_interval: float = 15.0,
+ ) -> Job:
+ """Poll `GET /jobs/{id}` until the latest run's async-carrier
+ ingestion is no longer `pending`. Done means: `ingest_status`
+ is `done` or absent (the run never ingested asynchronously),
+ the run is terminal (a mid-ingest stop flips the field to
+ `done` — SPEC §3.1), or there is no run yet (nothing is
+ ingesting). Returns the final `Job` snapshot."""
+
+ def fetch() -> Job:
+ return self._get_job_data(job_id)
+
+ def is_done(job: Job) -> bool:
+ run = job.latest_run
+ if run is None or run.status.value in TERMINAL_RUN_STATUSES:
+ return True
+ return run.ingest_status is not IngestStatus.PENDING
+
+ return poll_until(
+ fetch=fetch,
+ is_done=is_done,
+ timeout=timeout,
+ initial_interval=poll_interval,
+ max_interval=max_poll_interval,
+ )
+
+ def _download_dir(
+ self,
+ job_id: str,
+ run_id: str | None,
+ target_dir: str | Path,
+ *,
+ status: str | None,
+ name_fn: Callable[[TaskResult], str] | None,
+ use_external_id: bool,
+ concurrency: int,
+ progress: bool,
+ max_files: int | None,
+ max_bytes_per_file: int | None,
+ ) -> int:
+ return download_to_dir(
+ self,
+ job_id,
+ Path(target_dir),
+ run_id=run_id,
+ status=status,
+ name_fn=name_fn,
+ use_external_id=use_external_id,
+ concurrency=concurrency,
+ progress=progress,
+ max_files=max_files if max_files is not None else DEFAULT_MAX_FILES,
+ max_bytes_per_file=(
+ max_bytes_per_file if max_bytes_per_file is not None else DEFAULT_MAX_BYTES_PER_FILE
+ ),
+ )
+
+ def _download_memory(
+ self,
+ job_id: str,
+ run_id: str | None,
+ *,
+ status: str | None,
+ concurrency: int,
+ progress: bool,
+ max_count: int | None,
+ max_total_bytes: int | None,
+ max_bytes_per_file: int | None,
+ ) -> list[DownloadedResult]:
+ return download_to_memory(
+ self,
+ job_id,
+ run_id=run_id,
+ status=status,
+ concurrency=concurrency,
+ progress=progress,
+ max_count=max_count if max_count is not None else DEFAULT_MAX_COUNT_IN_MEMORY,
+ max_total_bytes=(
+ max_total_bytes
+ if max_total_bytes is not None
+ else DEFAULT_MAX_TOTAL_BYTES_IN_MEMORY
+ ),
+ max_bytes_per_file=(
+ max_bytes_per_file if max_bytes_per_file is not None else DEFAULT_MAX_BYTES_PER_FILE
+ ),
+ )
+
+
+# ----- helpers -----
+
+
+def _scan(
+ fetch_page: Any,
+ *,
+ items_attr: str,
+ cursor_attr: str = "next_cursor",
+) -> Iterator[Any]:
+ """Generic cursor-pagination scanner."""
+ cursor: str | None = None
+ while True:
+ page = fetch_page(cursor)
+ yield from getattr(page, items_attr)
+ cursor = getattr(page, cursor_attr, None)
+ if not cursor:
+ return
+
+
+class _PendingRun:
+ """Sentinel returned by the waiter when a scheduled job has not
+ fired its first run yet."""
+
+ status = None
+
+
+def _normalize_schedule(s: JobScheduleDict) -> JobScheduleDict:
+ """Convert a `datetime` in `s["at"]` to a tz-naive ISO string.
+ Reject tz-aware datetimes with a clear ValueError — keeping the
+ tz field as the single source of truth is what makes DST
+ transitions deterministic. Returns a shallow copy with the
+ normalised `at`; other fields pass through unchanged."""
+ if "at" not in s:
+ return s
+ raw_at = s["at"]
+ if isinstance(raw_at, datetime):
+ if raw_at.tzinfo is not None and raw_at.utcoffset() is not None:
+ raise ValueError(
+ "schedule.at must be a naive datetime (no tzinfo); "
+ "supply schedule.timezone separately so DST transitions "
+ "stay deterministic."
+ )
+ out: JobScheduleDict = {**s}
+ out["at"] = raw_at.strftime("%Y-%m-%dT%H:%M:%S") # type: ignore[typeddict-item]
+ return out
+ return s
+
+
+def _build_submit_body(
+ *,
+ job_type: Literal["regular", "scheduled"],
+ urls: list[str | TaskInputDict] | None,
+ file_input_id: str | None,
+ status: Literal["open", "closed"],
+ zenrows_params: ParamMap | None,
+ schedule: JobScheduleDict | None = None,
+ external_id: str | None = None,
+ name: str | None = None,
+ metadata: dict[str, str] | None = None,
+ webhook: WebhookDict | None = None,
+) -> SubmitJobDict:
+ """Shared bodies for the type-specific submit methods.
+
+ Validates the urls/file_input_id mutual exclusion locally so
+ the API call doesn't pay for an obvious client-side mistake.
+ Returns a dict (not a SubmitJobRequest) so the values that
+ weren't supplied stay out of the wire payload — leaning on
+ the transport's `exclude_unset=True` semantics."""
+ if urls is not None and file_input_id is not None:
+ raise ValueError("submit: pass `urls` OR `file_input_id`, not both.")
+ if urls is None and file_input_id is None and status == "closed":
+ raise ValueError(
+ "submit: closed jobs require `urls` or `file_input_id` "
+ "(use submit_open() for the open/extend pattern)."
+ )
+
+ body: SubmitJobDict = {"type": job_type, "status": status}
+ if zenrows_params:
+ body["zenrows_params"] = zenrows_params
+ if schedule:
+ body["schedule"] = schedule
+ if file_input_id:
+ body["file_input_id"] = file_input_id
+ if urls:
+ body["tasks"] = [_coerce_url(u) for u in urls]
+ if external_id:
+ body["external_id"] = external_id
+ if name:
+ body["name"] = name
+ if metadata:
+ body["metadata"] = metadata
+ if webhook:
+ # `signature` is optional for the caller but required on the wire;
+ # default it to false (unsigned), matching the server default.
+ body["webhook"] = {"signature": False, **webhook}
+ return body
+
+
+def _coerce_url(item: str | TaskInputDict) -> TaskInputDict:
+ """Accept a bare URL string OR a TaskInputDict. Strings become
+ `{"url": "..."}`; dicts pass through untouched."""
+ if isinstance(item, str):
+ return {"url": item}
+ return item
+
+
+def _as_model(value: BaseModel | Mapping[str, Any], cls: type[M]) -> M:
+ if isinstance(value, cls):
+ return value
+ return cls.model_validate(value)
+
+
+def _parse(payload: Any, cls: type[M]) -> M:
+ return cls.model_validate(payload)
+
+
+def _enum_value(v: JobType | JobStatus | str | None) -> str | None:
+ """Flatten Enum-or-str-or-None to the wire string."""
+ if not v:
+ return None
+ if isinstance(v, Enum):
+ return v.value
+ return v
+
+
+def _read_csv_body(source: str | Path | IO[bytes]) -> bytes:
+ if isinstance(source, (str, Path)):
+ return Path(source).read_bytes()
+ data = source.read()
+ if isinstance(data, str):
+ return data.encode("utf-8")
+ return data
+
+
+# ----- waiter progress shim -----
+
+
+from contextlib import contextmanager # noqa: E402
+
+
+@contextmanager
+def _maybe_wait_progress(enabled: bool, *, job_id: str):
+ """Yields an `on_poll(run)` callback that updates a tqdm bar each
+ iteration with `(successful+failed)/total`. No-op when
+ `enabled=False` or `tqdm` is missing.
+
+ The bar's `total` starts at None (indeterminate) and is set on
+ the first poll that returns a non-pending run — for scheduled
+ jobs we don't know the totals until the first fire materialises."""
+ if not enabled:
+ yield lambda _run: None
+ return
+ try:
+ from tqdm.auto import tqdm
+ except ImportError:
+ yield lambda _run: None
+ return
+
+ bar = tqdm(total=None, desc=f"{job_id}: pending", unit="task")
+ try:
+ last_done = [0]
+
+ def on_poll(run: Run | _PendingRun) -> None:
+ if isinstance(run, _PendingRun):
+ # Scheduled job's first run hasn't materialised yet.
+ bar.set_description_str(f"{job_id}: pending")
+ return
+ if bar.total is None:
+ bar.total = run.stats.total
+ bar.refresh()
+ done = run.stats.successful + run.stats.failed
+ if done > last_done[0]:
+ bar.update(done - last_done[0])
+ last_done[0] = done
+ bar.set_description_str(f"{job_id}: {run.status.value}")
+
+ yield on_poll
+ finally:
+ bar.close()
+
+
+__all__ = [
+ "DEFAULT_BASE_URL",
+ "DEFAULT_USER_AGENT",
+ "TERMINAL_EXPORT_STATUSES",
+ "TERMINAL_RUN_STATUSES",
+ "ZenRowsBatchClient",
+]
diff --git a/src/zenrows/batch/errors.py b/src/zenrows/batch/errors.py
new file mode 100644
index 0000000..a78dc51
--- /dev/null
+++ b/src/zenrows/batch/errors.py
@@ -0,0 +1,83 @@
+"""RFC 7807 Problem JSON → friendly Python exceptions.
+
+The Batch API returns errors as `application/problem+json`.
+We surface them as `BatchAPIError` with a structured `problem` payload
+plus a flat `code` shortcut so callers can branch without indexing
+into a dict.
+"""
+
+import json
+from dataclasses import dataclass
+from typing import Any
+
+import httpx
+
+
+@dataclass(slots=True)
+class ProblemDetail:
+ """Decoded RFC 7807 Problem body.
+
+ `extras` keeps any non-standard members (e.g. `invalid_tasks`)
+ so handlers can dig in without us tracking every shape.
+ """
+
+ type: str
+ title: str
+ status: int
+ code: str
+ detail: str | None = None
+ instance: str | None = None
+ extras: dict[str, Any] | None = None
+
+ @classmethod
+ def from_response(cls, response: httpx.Response) -> "ProblemDetail | None":
+ """Parse a Problem body. Returns None if the body isn't JSON.
+
+ Tolerant — production servers occasionally return non-JSON
+ errors (e.g. ALB blocked at the edge); we degrade gracefully.
+ """
+ try:
+ body = json.loads(response.content or b"{}")
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ return None
+ if not isinstance(body, dict):
+ return None
+ standard = {"type", "title", "status", "code", "detail", "instance"}
+ extras = {k: v for k, v in body.items() if k not in standard}
+ return cls(
+ type=body.get("type", "about:blank"),
+ title=body.get("title", "Error"),
+ status=int(body.get("status", response.status_code)),
+ code=body.get("code", "internal"),
+ detail=body.get("detail"),
+ instance=body.get("instance"),
+ extras=extras or None,
+ )
+
+
+class BatchAPIError(Exception):
+ """A non-2xx response from the Batch API.
+
+ `code` is the RFC 7807 `code` member (e.g. `file_input_not_found`,
+ `idempotency_key_conflict`). Stable; safe to branch on.
+ """
+
+ def __init__(self, status_code: int, problem: ProblemDetail | None, raw: bytes):
+ self.status_code = status_code
+ self.problem = problem
+ self.raw = raw
+ self.code: str = problem.code if problem else "internal"
+ msg = (
+ f"{status_code} {problem.title}: {problem.detail or problem.code}"
+ if problem
+ else f"{status_code} (no problem body)"
+ )
+ super().__init__(msg)
+
+ @classmethod
+ def from_response(cls, response: httpx.Response) -> "BatchAPIError":
+ return cls(
+ status_code=response.status_code,
+ problem=ProblemDetail.from_response(response),
+ raw=response.content,
+ )
diff --git a/src/zenrows/batch/models.py b/src/zenrows/batch/models.py
new file mode 100644
index 0000000..185e42a
--- /dev/null
+++ b/src/zenrows/batch/models.py
@@ -0,0 +1,1153 @@
+# generated by datamodel-codegen:
+# filename: openapi.yaml
+# timestamp: 2026-07-27T07:37:28+00:00
+
+from __future__ import annotations
+
+from enum import Enum
+from typing import Annotated, Any, Literal
+
+from pydantic import AnyUrl, AwareDatetime, BaseModel, ConfigDict, Field, RootModel
+
+
+class JobType(Enum):
+ """
+ - `regular` — a run is created at submit time.
+ - `scheduled` — fires on a recurring or one-shot schedule.
+ Submit stores the task list as a template on the job row;
+ each scheduled fire produces a fresh Run. `schedule`
+ field required. Read-only template: `addTasks` and
+ `close` return 409. `rerun` (full or filtered) and `stop`
+ are allowed.
+
+ """
+
+ REGULAR = "regular"
+ SCHEDULED = "scheduled"
+
+
+class JobStatus(Enum):
+ """
+ - `open` — initial run still accepting `addTasks`. Only
+ meaningful while `latest_run.run_sequence == 1`.
+ - `closed` — no more tasks accepted (created closed, or
+ closed via `/close` / `addTasks{last_batch}` / `/rerun`).
+ - `deleted` — async deletion in progress; the job disappears
+ once it finishes.
+
+ """
+
+ OPEN = "open"
+ CLOSED = "closed"
+ DELETED = "deleted"
+
+
+class ScheduleState(Enum):
+ """
+ Run/pause flag on a scheduled job. While `paused`, scheduled
+ fires are skipped. Default at submit: `active`. Flip via
+ `POST /v1/jobs/{id}/schedule/state`.
+
+ """
+
+ ACTIVE = "active"
+ PAUSED = "paused"
+
+
+class UpdateScheduleStateRequest(BaseModel):
+ schedule_state: ScheduleState
+
+
+class RunTrigger(Enum):
+ """
+ What set this run in motion. Always set.
+ - `manual` — caller-initiated: `POST /jobs`, `POST /jobs/{id}/rerun`
+ (any job type, full or `?status=`-filtered, manual fire of
+ a scheduled job).
+ - `scheduled` — automatic, fired by the configured
+ schedule (recurring or one-shot).
+
+ """
+
+ MANUAL = "manual"
+ SCHEDULED = "scheduled"
+
+
+class RunStatus(Enum):
+ """
+ In-flight:
+ - `running` — work queued / in flight.
+ - `pending` — initial run of an open job, idle between batches.
+
+ Terminal:
+ - `completed` — natural finish.
+ - `stopped` — caller called `POST /jobs/{id}/stop`. No new
+ tasks are picked up; in-flight tasks may still finish.
+ Result bodies are kept. `stats.completed < stats.total`
+ signals "stopped early".
+ - `failed` — the run was auto-failed on an account-level error
+ (insufficient credits / inactive subscription). No new tasks
+ are picked up; `failure_reason` carries the cause. Re-runnable
+ once the account is resolved. Result bodies already produced
+ are kept.
+ - `deleted` — caller called
+ `DELETE /v1/jobs/{id}/runs/{run_id}`. The run's result
+ bodies and data are being deleted; once complete the run
+ disappears.
+
+ """
+
+ RUNNING = "running"
+ PENDING = "pending"
+ COMPLETED = "completed"
+ STOPPED = "stopped"
+ FAILED = "failed"
+ DELETED = "deleted"
+
+
+class TaskStatus(Enum):
+ PENDING = "pending"
+ PROCESSING = "processing"
+ SUCCESSFUL = "successful"
+ FAILED = "failed"
+
+
+class ResultType(Enum):
+ """
+ Body format of a successful task result; matches the job's `format` 1:1.
+ """
+
+ HTML = "html"
+ JSON = "json"
+ MARKDOWN = "markdown"
+ PLAINTEXT = "plaintext"
+ PDF = "pdf"
+
+
+class Format(Enum):
+ """
+ Derived server-side from `zenrows_params` at submit time.
+ Precedence:
+ - `response_type: markdown|plaintext|pdf` → matching format.
+ - `autoparse: true`, `json_response: true`, or non-empty
+ `css_extractor` → `json`.
+ - otherwise → `html`.
+ Stamped on every successful task result and used to set the
+ right `Content-Type` when you fetch the content.
+
+ """
+
+ HTML = "html"
+ JSON = "json"
+ MARKDOWN = "markdown"
+ PLAINTEXT = "plaintext"
+ PDF = "pdf"
+
+
+class Method(Enum):
+ """
+ HTTP method used against `url`. Case-insensitive. POST is
+ for **safe/idempotent** requests only (GraphQL queries,
+ search endpoints): tasks are retried on transient failures
+ and reruns, so the target may see the same POST more than
+ once. Callers that cannot tolerate a duplicate should
+ disable reruns. POST rides the standard (non-headless)
+ scraping path — combining it with `js_render`,
+ `js_instructions`, or `json_response` is rejected with
+ 400 `method_param_conflict`.
+
+ """
+
+ GET = "GET"
+ POST = "POST"
+
+
+class TaskInput(BaseModel):
+ external_id: Annotated[
+ str | None, Field(max_length=128, pattern="^[A-Za-z0-9._-]+$")
+ ] = None
+ """
+ Optional caller-supplied correlation id — typically an
+ identifier from the caller's own system. Surfaced verbatim
+ in result/content responses so callers can match results
+ back to their records. **Not required to be unique** —
+ callers may reuse the same value across tasks (e.g. when
+ multiple scrapes correlate to the same upstream record).
+ Independent of the server-
+ assigned `task_id`.
+
+ """
+ url: AnyUrl
+ """
+ Must be http(s). Other schemes rejected at submit.
+ """
+ metadata: Annotated[dict[str, str] | None, Field(max_length=20)] = None
+ method: Method | None = "GET"
+ """
+ HTTP method used against `url`. Case-insensitive. POST is
+ for **safe/idempotent** requests only (GraphQL queries,
+ search endpoints): tasks are retried on transient failures
+ and reruns, so the target may see the same POST more than
+ once. Callers that cannot tolerate a duplicate should
+ disable reruns. POST rides the standard (non-headless)
+ scraping path — combining it with `js_render`,
+ `js_instructions`, or `json_response` is rejected with
+ 400 `method_param_conflict`.
+
+ """
+ body: Any | None = None
+ """
+ Request body, only with `method: POST`. Any JSON value,
+ 16 KiB max. An object/array/number/boolean is sent as its
+ JSON encoding with `Content-Type: application/json`; a
+ string is sent verbatim with
+ `Content-Type: application/x-www-form-urlencoded`. Set a
+ different target Content-Type via the `custom_headers`
+ zenrows param. Never echoed in results listings.
+
+ """
+ zenrows_params: dict[str, str | bool | int | dict[str, str]] | None = None
+ """
+ Per-task scraper params. Override the job-level
+ `zenrows_params` on key collision (task wins).
+
+ """
+
+
+class Status(Enum):
+ """
+ Initial state. `open` is only allowed for `regular` jobs;
+ after the initial run, `open` has no meaning so the job
+ is auto-closed.
+
+ """
+
+ OPEN = "open"
+ CLOSED = "closed"
+
+
+class AddTasksRequest(BaseModel):
+ tasks: Annotated[list[TaskInput], Field(max_length=1000, min_length=1)]
+ last_batch: bool | None = False
+ """
+ Set true on the final batch. Closes the job (status →
+ `closed`) and marks the run's `last_batch_received`.
+
+ """
+
+
+class Spend(BaseModel):
+ """
+ Indicative `{credits, cost}` pair — what was charged for
+ the scoped work. **Not billing-grade**; your account
+ statement is authoritative. Use as a "how much did this
+ cost?" indicator, not for reconciliation.
+
+ """
+
+ credits: Annotated[int, Field(ge=0)]
+ cost: Annotated[float, Field(ge=0.0)]
+
+
+class TaskSpend(BaseModel):
+ """
+ Per-task indicative spend with two roll-ups: `total`
+ accumulates across every attempt (including retries),
+ `last_attempt` carries just the most recent gateway call.
+ On a task that succeeded on its first try the two are
+ equal; on a retried task they diverge.
+
+ """
+
+ total: Spend
+ last_attempt: Spend
+
+
+class RunStats(BaseModel):
+ total: int
+ """
+ Number of tasks in this run.
+ """
+ completed: int
+ """
+ successful + failed.
+ """
+ successful: int
+ failed: int
+ failure_reasons: Annotated[
+ dict[str, int] | None, Field(examples=[{"blocked": 9, "gateway_error": 4}])
+ ] = None
+ """
+ Coarse rollup of terminal failures keyed by a small public
+ taxonomy. Lets you answer "what kinds of failures did I
+ get?" without paging every error blob. Best-effort,
+ indicative — omitted on runs with no failures yet and on
+ runs that predate the feature.
+
+ Vocabulary (the only keys that appear):
+ - `auth_failed` — credentials or billing
+ - `blocked` — anti-bot / policy denials
+ - `bad_target` — target URL is the problem (bad host, 404, 410, too large)
+ - `rate_limited` — target throttled the request
+ - `timeout` — the scrape didn't complete in time
+ - `gateway_error` — ZenRows-side transport / 5xx
+ - `other` — anything else
+
+ """
+ spend: Spend | None = None
+ """
+ Indicative spend summed across every task attempt in
+ this run. Absent on runs whose tasks predate this
+ field (treat as zero).
+
+ """
+
+
+class PauseState(Enum):
+ """
+ Reversible-suspend flag, orthogonal to `status`.
+ Omitted from responses when `active` / absent (legacy
+ rows). Flip via `POST /jobs/{id}/pause` and
+ `/resume`.
+
+ """
+
+ ACTIVE = "active"
+ PAUSED = "paused"
+
+
+class IngestStatus(Enum):
+ """
+ Present only on runs created by a large (202) submission
+ or a large (202) rerun. `pending` — task rows are still
+ streaming into storage; reads may return partial pages
+ and `addTasks` returns `409`. `done` — every accepted
+ task row is visible. Omitted on runs whose tasks were
+ written on the request path (201 submissions and
+ reruns, `addTasks` batches).
+
+ """
+
+ PENDING = "pending"
+ DONE = "done"
+
+
+class FailureReason(Enum):
+ """
+ Present only when `status == failed`: the account-level
+ cause of the auto-fail. `insufficient_credits` (out of
+ credits) or `subscription_inactive` (subscription not
+ active). Omitted otherwise. Distinct from
+ `stats.failure_reasons` (the per-task rollup).
+
+ """
+
+ INSUFFICIENT_CREDITS = "insufficient_credits"
+ SUBSCRIPTION_INACTIVE = "subscription_inactive"
+
+
+class Run(BaseModel):
+ run_id: str
+ job_id: str
+ run_sequence: Annotated[int, Field(ge=1)]
+ status: RunStatus
+ stats: RunStats
+ last_batch_received: bool | None = None
+ """
+ Meaningful only for the initial run of an open job.
+ Once true, `addTasks` is rejected and the run drains
+ into `completed`.
+
+ """
+ pause_state: PauseState | None = None
+ """
+ Reversible-suspend flag, orthogonal to `status`.
+ Omitted from responses when `active` / absent (legacy
+ rows). Flip via `POST /jobs/{id}/pause` and
+ `/resume`.
+
+ """
+ ingest_status: IngestStatus | None = None
+ """
+ Present only on runs created by a large (202) submission
+ or a large (202) rerun. `pending` — task rows are still
+ streaming into storage; reads may return partial pages
+ and `addTasks` returns `409`. `done` — every accepted
+ task row is visible. Omitted on runs whose tasks were
+ written on the request path (201 submissions and
+ reruns, `addTasks` batches).
+
+ """
+ created_at: AwareDatetime
+ updated_at: AwareDatetime
+ failure_reason: FailureReason | None = None
+ """
+ Present only when `status == failed`: the account-level
+ cause of the auto-fail. `insufficient_credits` (out of
+ credits) or `subscription_inactive` (subscription not
+ active). Omitted otherwise. Distinct from
+ `stats.failure_reasons` (the per-task rollup).
+
+ """
+
+
+class Unit(Enum):
+ MINUTE = "minute"
+ HOUR = "hour"
+ DAY = "day"
+
+
+class ScheduleRate(BaseModel):
+ """
+ Interval-based fire policy — every N units.
+ """
+
+ every: Annotated[int, Field(examples=[15], ge=1)]
+ unit: Unit
+
+
+class TimesOfDayItem(RootModel[str]):
+ root: Annotated[str, Field(pattern="^([01][0-9]|2[0-3]):00$")]
+
+
+class Day(Enum):
+ MON = "mon"
+ TUE = "tue"
+ WED = "wed"
+ THU = "thu"
+ FRI = "fri"
+ SAT = "sat"
+ SUN = "sun"
+
+
+class Weekly(BaseModel):
+ days: Annotated[list[Day], Field(examples=[["mon", "wed", "fri"]], min_length=1)]
+
+
+class Day1(RootModel[int]):
+ root: Annotated[int, Field(ge=1, le=31)]
+
+
+class Monthly(BaseModel):
+ days: Annotated[list[Day1], Field(examples=[[1, 15]], min_length=1)]
+
+
+class ScheduleCadence(BaseModel):
+ """
+ Picks which days the schedule fires on. Exactly one of
+ `daily`, `weekly`, `monthly` must be set.
+
+ """
+
+ daily: dict[str, Any] | None = None
+ """
+ Fire every day. No knobs.
+ """
+ weekly: Weekly | None = None
+ monthly: Monthly | None = None
+
+
+class ListJobRunsResponse(BaseModel):
+ runs: list[Run]
+ next_cursor: str | None = None
+
+
+class Method1(Enum):
+ """
+ The task's HTTP method. Omitted for GET (the default).
+ The request `body` is intentionally not part of listing
+ responses.
+
+ """
+
+ GET = "GET"
+ POST = "POST"
+
+
+class WebhookConfig(BaseModel):
+ """
+ Webhook delivery config for `run.completed` / `run.failed`. Returned on
+ `GET /v1/jobs/{id}` (inline under `webhook`) and on
+ `GET /v1/jobs/{id}/webhook`. `PUT` requires both fields —
+ no defaulting at the mutate boundary (otherwise toggling
+ `url` would silently disable signing). Submit body accepts
+ the same shape with `signature` optional (defaults `false`).
+
+ """
+
+ url: AnyUrl
+ """
+ HTTPS only. Host must resolve via DNS within 1 second
+ (1+ A/AAAA record).
+
+ """
+ signature: bool
+ """
+ Opt-in HMAC signing. When `true`, each delivery is signed
+ with the org's active HMAC key
+ (`POST /v1/hmac/keys/rotate`) and carries
+ `X-Signature: t=,v1=,kid=`. The signed
+ input is `t + "." + raw_body`, HMAC-SHA256. When `false`,
+ deliveries carry **no** `X-Signature` header — header
+ absence is the signal.
+
+ """
+
+
+class TestWebhookRequest(BaseModel):
+ """
+ Body for `POST /v1/webhook/test`. Same shape as the submit-time
+ `webhook` field; `signature` is optional and defaults `false`.
+
+ """
+
+ url: AnyUrl
+ """
+ HTTPS only. Host must resolve via DNS within 1 second.
+ Identical validation to the submit/PUT webhook URL.
+
+ """
+ signature: bool | None = False
+ """
+ When `true`, the test event is signed with the org's
+ active HMAC key — same `X-Signature: t,v1,kid` header
+ real deliveries use. Returns `400
+ webhook_signing_requires_active_key` when no active key
+ exists.
+
+ """
+
+
+class TestWebhookResponse(BaseModel):
+ """
+ Outcome of the synthetic test dispatch. HTTP status is always
+ `200` when the request was validated — the receiver outcome
+ is in the body. The synthetic envelope uses `event_type:
+ "webhook.test"` and stable sentinel IDs (`job_id`/`run_id` =
+ `"test"`) so receivers can recognise and discard test traffic.
+
+ """
+
+ delivered: bool
+ """
+ `true` iff the receiver responded with a 2xx status. `false`
+ on non-2xx, timeout, or transport error.
+
+ """
+ event_id: str
+ """
+ ULID of the synthetic event. Different on every call so
+ receivers' dedup tables don't suppress repeated tests.
+
+ """
+ status_code: int | None = None
+ """
+ HTTP status returned by the receiver. Absent on timeout or
+ transport error (no response was received).
+
+ """
+ error: str | None = None
+ """
+ Human-readable reason when `delivered: false`. Same
+ vocabulary as real webhook deliveries
+ (`http_4xx:`, `http_5xx:`, `timeout`,
+ `transport_error:`). Absent on success.
+
+ """
+ elapsed_ms: int
+ """
+ Wall-clock duration of the receiver POST in milliseconds.
+ """
+
+
+class FileInputColumnRef1(RootModel[str]):
+ root: Annotated[str, Field(min_length=1)]
+ """
+ Either a column name (string) — requires `csv.header: true` —
+ or a 0-based column index (integer). Other shapes are
+ rejected at create-time.
+
+ """
+
+
+class FileInputColumnRef2(RootModel[int]):
+ root: Annotated[int, Field(ge=0)]
+ """
+ Either a column name (string) — requires `csv.header: true` —
+ or a 0-based column index (integer). Other shapes are
+ rejected at create-time.
+
+ """
+
+
+class Fields(BaseModel):
+ """
+ Map from canonical task field → CSV column. Only
+ `url` (required) and `external_id` (optional) are
+ accepted. Each value is a column index (int) or a
+ column name (string, requires `header: true`).
+
+ """
+
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ url: FileInputColumnRef1 | FileInputColumnRef2
+ """
+ Either a column name (string) — requires `csv.header: true` —
+ or a 0-based column index (integer). Other shapes are
+ rejected at create-time.
+
+ """
+ external_id: FileInputColumnRef1 | FileInputColumnRef2 | None = None
+ """
+ Either a column name (string) — requires `csv.header: true` —
+ or a 0-based column index (integer). Other shapes are
+ rejected at create-time.
+
+ """
+
+
+class Csv(BaseModel):
+ delimiter: Annotated[str | None, Field(max_length=1, min_length=1)] = ","
+ """
+ Single-character field delimiter.
+ """
+ quote: Annotated[str | None, Field(max_length=1, min_length=1)] = '"'
+ """
+ Single-character quoting character.
+ """
+ header: bool | None = False
+ """
+ When true, the first CSV row is consumed as a header
+ row and `csv.fields.*` values may be column names.
+
+ """
+ fields: Fields
+ """
+ Map from canonical task field → CSV column. Only
+ `url` (required) and `external_id` (optional) are
+ accepted. Each value is a column index (int) or a
+ column name (string, requires `header: true`).
+
+ """
+
+
+class CreateJobInputRequest(BaseModel):
+ type: Literal["csv"]
+ """
+ Only `csv` is supported in v1.
+ """
+ csv: Csv | None = None
+
+
+class FileInputUploadTarget(BaseModel):
+ method: Literal["PUT"]
+ url: AnyUrl
+ """
+ Presigned PUT URL. Caller MUST send the body with the
+ exact `Content-Type` shown in `headers` — the signature
+ binds the content-type.
+
+ """
+ headers: Annotated[
+ dict[str, str] | None, Field(examples=[{"Content-Type": "text/csv"}])
+ ] = None
+ expires_at: AwareDatetime
+ """
+ PUT URL TTL (~30 min).
+ """
+
+
+class CreateJobInputResponse(BaseModel):
+ file_input_id: str
+ upload: FileInputUploadTarget
+ expires_at: AwareDatetime
+ """
+ 24 h slot lifetime — beyond this the slot and its uploaded
+ body are removed and the `file_input_id` returns 404.
+
+ """
+
+
+class HMACKeyMeta(BaseModel):
+ """
+ Public view of one HMAC key — id + creation time. Never
+ includes secret material; that's only returned at /rotate.
+
+ """
+
+ kid: Annotated[str, Field(pattern="^[0-9A-HJKMNP-TV-Z]{26}$")]
+ """
+ ULID identifying this key. Stable for the life of the slot; a new candidate gets a new kid.
+ """
+ created_at: AwareDatetime
+
+
+class HMACKeyList(BaseModel):
+ """
+ Slots populated at the time of the call.
+ """
+
+ active: HMACKeyMeta | None = None
+ candidate: HMACKeyMeta | None = None
+
+
+class HMACKeyCreated(BaseModel):
+ """
+ Response to `/rotate`. `secret` is base64-encoded raw key
+ material. **This is the ONLY response that ever contains
+ the secret value** — capture it now or generate a new one
+ via another /rotate call.
+
+ """
+
+ kid: Annotated[str, Field(pattern="^[0-9A-HJKMNP-TV-Z]{26}$")]
+ secret: str
+ """
+ Base64-encoded 32-byte HMAC key.
+ """
+ created_at: AwareDatetime
+
+
+class HMACKeyFinalized(BaseModel):
+ """
+ Response to `/rotate/finalize`. No secret.
+ """
+
+ active_kid: Annotated[str, Field(pattern="^[0-9A-HJKMNP-TV-Z]{26}$")]
+ created_at: AwareDatetime
+
+
+class Reason(Enum):
+ MALFORMED_URL = "malformed_url"
+ UNSUPPORTED_SCHEME = "unsupported_scheme"
+ MISSING_HOST = "missing_host"
+ URL_TOO_LONG = "url_too_long"
+ METADATA_TOO_LARGE = "metadata_too_large"
+ METADATA_KEY_INVALID = "metadata_key_invalid"
+ INVALID_EXTERNAL_ID = "invalid_external_id"
+ UNKNOWN_PARAM = "unknown_param"
+ INVALID_PARAM_VALUE = "invalid_param_value"
+
+
+class InvalidTask(BaseModel):
+ index: int
+ reason: Reason
+ value: str | None = None
+ """
+ Offending input. For URL / metadata reasons this is
+ a redacted/truncated form of the bad value. For
+ `unknown_param` / `invalid_param_value` this is the
+ param key.
+
+ """
+
+
+class Problem(BaseModel):
+ """
+ RFC 7807 Problem Details.
+
+ """
+
+ model_config = ConfigDict(
+ extra="allow",
+ )
+ type: AnyUrl
+ title: str
+ status: int
+ detail: str | None = None
+ code: str
+ instance: str | None = None
+ invalid_tasks: list[InvalidTask] | None = None
+ """
+ Present on validation errors.
+ """
+
+
+class ExportStatus(Enum):
+ """
+ Lifecycle state of a results export.
+ * `pending` — export accepted, not started yet.
+ * `running` — the zip is being produced.
+ * `completed` — `download_url` will be present.
+ * `failed` — `error` carries the reason.
+
+ """
+
+ PENDING = "pending"
+ RUNNING = "running"
+ COMPLETED = "completed"
+ FAILED = "failed"
+
+
+class StartExportResponse(BaseModel):
+ """
+ Returned by `startResultsExport`.
+ """
+
+ export_id: Annotated[str, Field(pattern="^[0-9A-HJKMNP-TV-Z]{26}$")]
+ """
+ ULID identifying this export. Use it for `getResultsExport`.
+ """
+ status: ExportStatus
+ created_at: AwareDatetime
+ expires_at: AwareDatetime
+ """
+ 12 h after `created_at`. Past this point the export and
+ its download are removed and the export id 404s.
+
+ """
+
+
+class Export(BaseModel):
+ """
+ Polled view of a results export. `download_url` is presigned
+ fresh on every successful poll — stash the metadata, but
+ re-fetch the URL right before you download.
+
+ """
+
+ export_id: Annotated[str, Field(pattern="^[0-9A-HJKMNP-TV-Z]{26}$")]
+ status: ExportStatus
+ error: str | None = None
+ """
+ Non-empty only when `status = failed`. Stable strings —
+ e.g. `"results are larger then 1 gb"` when the combined
+ results exceed the 1 GiB cap.
+
+ """
+ download_url: AnyUrl | None = None
+ """
+ Presigned download URL for the zipped run results.
+ Present only when `status = completed`. Short-lived — the
+ server mints a new one on every poll.
+
+ """
+ created_at: AwareDatetime
+ expires_at: AwareDatetime
+ """
+ 12 h after `created_at`. The download is unavailable
+ after this point.
+
+ """
+
+
+class SubmitJobResponse(BaseModel):
+ job_id: str
+ status: JobStatus
+ latest_run: Run | None = None
+ """
+ Absent for `scheduled` jobs that haven't fired yet.
+ """
+ accepted_tasks: int
+ webhook: WebhookConfig | None = None
+ """
+ Echo of the webhook config persisted on the job (when
+ one was supplied). Omitted when no webhook was set.
+
+ """
+
+
+class AddTasksResponse(BaseModel):
+ accepted_tasks: int
+ job_status: JobStatus
+ latest_run: Run
+
+
+class RerunJobResponse(BaseModel):
+ job_id: str
+ status: JobStatus
+ latest_run: Run
+ rerun_of: str | None = None
+ """
+ `run_id` of the previous run that was replayed. Empty on
+ the first manual fire of a scheduled job (no prior run).
+
+ """
+ retried_tasks: int
+ """
+ Number of rows reset to `pending` and re-enqueued. Equals
+ `latest_run.stats.total` for a full rerun; equals the
+ filter-matched count for a `?status=` partial retry.
+
+ """
+ inherited_tasks: int
+ """
+ Number of rows copied verbatim from the previous run with
+ `source_run_id` stamped. Zero for a full rerun; non-zero
+ only when `?status=` is set.
+
+ """
+
+
+class ScheduleCalendar(BaseModel):
+ """
+ Calendar-style fire policy. Fires at every `times_of_day`
+ entry on every day matching the cadence.
+
+ """
+
+ times_of_day: Annotated[
+ list[TimesOfDayItem], Field(examples=[["09:00", "18:00"]], min_length=1)
+ ]
+ """
+ Wall-clock times on a 24-hour clock, full hours only
+ (`"09:00"`, `"18:00"`). Minute granularity is rejected
+ with 400.
+
+ """
+ cadence: ScheduleCadence
+
+
+class TaskResult(BaseModel):
+ task_id: str
+ external_id: str | None = None
+ """
+ Caller-supplied correlation id from submit/AddTasks.
+ Omitted when the caller did not supply one.
+
+ """
+ run_id: str
+ url: AnyUrl
+ metadata: Annotated[dict[str, str] | None, Field(max_length=20)] = None
+ method: Method1 | None = None
+ """
+ The task's HTTP method. Omitted for GET (the default).
+ The request `body` is intentionally not part of listing
+ responses.
+
+ """
+ status: TaskStatus
+ type: ResultType | None = None
+ result_url: str | None = None
+ """
+ 24-hour presigned download URL for the result body, or a
+ `/v1/jobs//runs//tasks//content` URL you can
+ fetch directly. Empty for non-successful tasks.
+
+ """
+ error: Problem | None = None
+ """
+ Present on failed tasks. The scraping engine's error
+ response as Problem JSON, or a synthesised envelope with
+ `code: "gateway_unreachable"` when it couldn't be reached.
+
+ """
+ source_run_id: str | None = None
+ """
+ Set when this row was copied from another run by
+ `/rerun?status=`. The row is terminal at creation, is
+ never re-executed, and its `result_url` resolves to the
+ source run's stored result. On chained retries,
+ `source_run_id` chases back to the run that actually
+ owns the result. Empty for normally-executed rows.
+
+ """
+ spend: TaskSpend | None = None
+
+
+class ListResultsResponse(BaseModel):
+ results: list[TaskResult]
+ next_cursor: str | None = None
+
+
+class TaskHistoryEvent(BaseModel):
+ started_at: AwareDatetime
+ ended_at: AwareDatetime
+ attempt: Annotated[int, Field(ge=1)]
+ """
+ 1-indexed attempt within the run.
+ """
+ error: Problem | None = None
+ spend: Spend | None = None
+ """
+ Indicative spend charged for this single attempt. Zero
+ on attempts that didn't reach the scraping engine or
+ were not charged.
+
+ """
+
+
+class TaskHistoryResponse(BaseModel):
+ events: list[TaskHistoryEvent]
+
+
+class JobSchedule(BaseModel):
+ """
+ Structured scheduling block attached to `type: scheduled`
+ jobs. Exactly one of `at`, `rate`, or `calendar` must be
+ set.
+
+ """
+
+ at: Annotated[str | None, Field(examples=["2026-09-01T09:00:00"])] = None
+ """
+ One-shot fire at a specific wall-clock timestamp.
+ Mutually exclusive with `rate` and `schedule`.
+
+ **Must be tz-naive** — no trailing `Z`, no offset. The
+ sibling `timezone` field (mandatory) is the single
+ authoritative interpreter. This keeps DST transitions
+ deterministic.
+
+ """
+ rate: ScheduleRate | None = None
+ calendar: ScheduleCalendar | None = None
+ timezone: Annotated[str | None, Field(examples=["Europe/Berlin"])] = None
+ """
+ IANA timezone name (e.g. `Europe/Berlin`, `UTC`).
+ **Required** when `at` or `calendar` is set;
+ ignored by `rate` (interval-based, no wall-clock
+ meaning). Anchoring wall-clock times to a named zone
+ (rather than a UTC offset baked into the string) keeps
+ DST transitions deterministic.
+
+ """
+
+
+class Job(BaseModel):
+ job_id: str
+ type: JobType
+ status: JobStatus
+ format: Format | None = None
+ zenrows_params: dict[str, str] | None = None
+ """
+ Stored canonical form — values are always strings even
+ though submit accepts any JSON scalar (see ScraperParams).
+
+ """
+ external_id: str | None = None
+ """
+ Caller-supplied correlation id passed at submit (omitted
+ when the caller did not supply one). Not server-enforced
+ unique.
+
+ """
+ name: str | None = None
+ """
+ Optional human label passed at submit (omitted when the
+ caller did not supply one). Free-form, up to 100 chars.
+
+ """
+ metadata: Annotated[dict[str, str] | None, Field(max_length=20)] = None
+ schedule: JobSchedule | None = None
+ """
+ Schedule block — present only for `type: scheduled`
+ jobs.
+
+ """
+ next_scheduled_run: AwareDatetime | None = None
+ """
+ Server-computed timestamp of the next expected fire,
+ stamped at submit and re-stamped on every fire. `null`
+ for non-scheduled jobs and for one-shot `at(...)`
+ schedules that have already fired. Stays computed when
+ `schedule_state == paused` — "what would fire next if
+ you resumed."
+
+ """
+ schedule_state: ScheduleState | None = None
+ """
+ Set only for `type: scheduled`. Default `active` at
+ submit; flip via `POST /v1/jobs/{id}/schedule/state`.
+
+ """
+ webhook: WebhookConfig | None = None
+ """
+ Webhook delivery config. Present iff a
+ webhook is configured. Mutable via `PUT/DELETE
+ /v1/jobs/{id}/webhook`; `signature` never appears
+ alone — the whole `webhook` key is omitted when no
+ URL is set.
+
+ """
+ latest_run: Run | None = None
+ """
+ Snapshot projection of the latest run. Absent for
+ `scheduled` jobs that haven't fired yet.
+
+ """
+ created_at: AwareDatetime
+ updated_at: AwareDatetime
+
+
+class ListJobsResponse(BaseModel):
+ jobs: list[Job]
+ next_cursor: str | None = None
+
+
+class SubmitJobRequest(BaseModel):
+ type: JobType | None = "regular"
+ status: Status | None = "closed"
+ """
+ Initial state. `open` is only allowed for `regular` jobs;
+ after the initial run, `open` has no meaning so the job
+ is auto-closed.
+
+ """
+ zenrows_params: dict[str, str | bool | int | dict[str, str]] | None = None
+ """
+ Job-level scraper params, applied to every task of every
+ run of the job. Each task can override individual keys
+ via its own `zenrows_params` (task wins on collision).
+
+ """
+ schedule: JobSchedule | None = None
+ """
+ Schedule block for `type: scheduled`. Required there,
+ ignored otherwise.
+
+ """
+ tasks: Annotated[list[TaskInput] | None, Field(max_length=1000, min_length=0)] = (
+ None
+ )
+ """
+ Required for closed jobs (1–1000) unless `file_input_id`
+ is provided. Optional for open jobs — may be empty if the
+ caller will follow up with `addTasks`. Mutually exclusive
+ with `file_input_id`.
+
+ """
+ file_input_id: str | None = None
+ """
+ Reference to a previously-uploaded CSV input (see
+ `POST /v1/job_inputs`). Mutually exclusive with `tasks`.
+ Eligible only for regular-closed and scheduled job types.
+ The uploaded CSV is parsed under the saved spec; its rows
+ become tasks (regular-closed) or template_tasks
+ (scheduled).
+
+ """
+ external_id: Annotated[
+ str | None, Field(max_length=128, pattern="^[A-Za-z0-9._-]+$")
+ ] = None
+ """
+ Optional caller-supplied correlation id for the job —
+ same semantics as `task.external_id`. Shape-checked,
+ **not** required to be unique. Surfaced verbatim in
+ `getJob` / `listJobs` responses.
+
+ """
+ name: Annotated[str | None, Field(max_length=100)] = None
+ """
+ Optional human-readable label for the job. Free-form —
+ no shape rules. Up to 100 characters. Surfaced verbatim
+ in `getJob` / `listJobs` responses. No uniqueness, no
+ indexing; cannot be changed after submit.
+
+ """
+ metadata: Annotated[dict[str, str] | None, Field(max_length=20)] = None
+ webhook: WebhookConfig | None = None
+ """
+ Optional `run.completed` / `run.failed` delivery config.
+ A terminal run fires `run.completed` on a natural finish, or
+ `run.failed` (with `failure_reason` + partial stats) when the
+ run auto-fails on an account-level error. `signature`
+ defaults to `false` here so first-time integrations
+ don't need an HMAC key. Config is mutable post-submit
+ via `PUT /v1/jobs/{id}/webhook` and `DELETE
+ /v1/jobs/{id}/webhook`; the current config is surfaced
+ on `GET /v1/jobs/{id}`.
+
+ """
diff --git a/src/zenrows/client.py b/src/zenrows/client.py
new file mode 100644
index 0000000..e9db1b8
--- /dev/null
+++ b/src/zenrows/client.py
@@ -0,0 +1,319 @@
+"""Synchronous Zenrows scraper client (the original SDK surface).
+
+Backward-compatible with the pre-1.4 API: the constructor still takes
+`(apikey, retries, concurrency)` positionally, and `get`/`post`/`put`
+return a `requests.Response`. What's new in this freshen-up:
+
+ - Modern type hints (3.10+ unions, no Optional/Dict noise)
+ - Context-manager support that closes the underlying requests
+ session + the thread-pool executor
+
+For the async (job-based) Batch API, see `zenrows.ZenRowsBatchClient`.
+"""
+
+import asyncio
+import os
+from concurrent.futures import ThreadPoolExecutor
+from functools import partial
+from typing import Any
+
+import requests
+import urllib3
+from requests.adapters import HTTPAdapter
+from urllib3.util.retry import Retry
+
+from zenrows.__version__ import __version__
+
+DEFAULT_SCRAPER_URL = "https://api.zenrows.com/v1/"
+DEFAULT_USER_AGENT = f"zenrows/{__version__} python"
+
+# Status codes the retry layer treats as transient. The set was inherited
+# from the original SDK and matches the gateway's documented retry guidance.
+_RETRY_STATUSES = [422, 429, 500, 502, 503, 504]
+
+
+def _is_auth010(response: requests.Response) -> bool:
+ """True when a response's JSON error envelope carries the Extract
+ domain-not-enabled code (AUTH010)."""
+ try:
+ body = response.json()
+ except ValueError:
+ return False
+ code = body.get("code") if isinstance(body, dict) else None
+ return isinstance(code, str) and code.upper() == "AUTH010"
+
+
+class ZenRowsClient:
+ """Synchronous client for the Zenrows scraping API.
+
+ Example:
+
+ client = ZenRowsClient("zr_...")
+ resp = client.get("https://example.com", params={"js_render": "true"})
+ print(resp.text)
+
+ `base_url` defaults to the public production endpoint; override via
+ constructor arg or `ZENROWS_SCRAPER_BASE_URL` env var.
+ """
+
+ # Kept as a class attribute for compatibility — pre-1.4 callers
+ # could read `ZenRowsClient.api_url` directly.
+ api_url = DEFAULT_SCRAPER_URL
+
+ def __init__(
+ self,
+ apikey: str,
+ retries: int = 0,
+ concurrency: int = 5,
+ *,
+ base_url: str | None = None,
+ ):
+ if not apikey:
+ raise ValueError("ZenRowsClient: apikey is required.")
+ self.apikey = apikey
+ self.api_url = base_url or os.environ.get("ZENROWS_SCRAPER_BASE_URL") or DEFAULT_SCRAPER_URL
+
+ self.executor = ThreadPoolExecutor(max_workers=concurrency)
+ self.requests_session = requests.Session()
+ if retries > 0:
+ max_retries = Retry(
+ total=retries,
+ backoff_factor=0.5,
+ status_forcelist=_RETRY_STATUSES,
+ raise_on_status=False,
+ )
+ adapter = HTTPAdapter(max_retries=max_retries)
+ self.requests_session.mount("https://", adapter)
+ self.requests_session.mount("http://", adapter)
+
+ # ---- sync HTTP verbs ----
+
+ def fetch(
+ self,
+ url: str,
+ params: dict | None = None,
+ headers: dict | None = None,
+ **kwargs: Any,
+ ) -> requests.Response:
+ """Fetch a URL through Zenrows — the main page-scraping product. This is
+ the primary entry point; `get()` remains as a deprecated alias.
+ """
+ return self._worker("GET", url, params, headers, **kwargs)
+
+ def get(
+ self,
+ url: str,
+ params: dict | None = None,
+ headers: dict | None = None,
+ **kwargs: Any,
+ ) -> requests.Response:
+ """Deprecated: use `fetch()` instead. Kept for backward compatibility."""
+ return self.fetch(url, params, headers, **kwargs)
+
+ def extract(
+ self,
+ url: str,
+ params: dict | None = None,
+ headers: dict | None = None,
+ mode: str = "auto",
+ fallback_to_autoparse: bool = True,
+ adaptive_stealth: bool = True,
+ **kwargs: Any,
+ ) -> requests.Response:
+ """Fetch a URL and run it through Extract — Zenrows' AI-powered structured
+ extraction (beta). `mode` is one of "auto" (default), "native",
+ or "standard". Thin wrapper over `fetch()` with the `extract` param set —
+ no separate endpoint or auth.
+
+ `mode="auto"` is a domain-gated open beta: when the target domain isn't
+ enabled yet, the API returns a 402 with `code: "AUTH010"`. By default
+ this retries once with `autoparse=True` instead of returning the error
+ response — pass `fallback_to_autoparse=False` to disable that and get
+ the raw AUTH010 response back.
+
+ `adaptive_stealth=True` (default) also sends Adaptive Stealth Mode
+ (`mode: "auto"` at the wire-param level - unrelated to this method's own
+ `mode` argument) on both the extract attempt and the Autoparse fallback,
+ so a target needing `js_render`/`premium_proxy` gets escalated
+ automatically instead of failing with REQS002. Pass
+ `adaptive_stealth=False` to disable that and set `js_render`/
+ `premium_proxy` yourself.
+ """
+ final_params = dict(params) if params else {}
+ final_params["extract"] = mode
+ if adaptive_stealth:
+ final_params["mode"] = "auto"
+ response = self.fetch(url, final_params, headers, **kwargs)
+
+ if (
+ response.status_code == 402
+ and mode == "auto"
+ and fallback_to_autoparse
+ and _is_auth010(response)
+ ):
+ autoparse_params = dict(params) if params else {}
+ autoparse_params["autoparse"] = True
+ if adaptive_stealth:
+ autoparse_params["mode"] = "auto"
+ return self.fetch(url, autoparse_params, headers, **kwargs)
+
+ return response
+
+ def post(
+ self,
+ url: str,
+ params: dict | None = None,
+ headers: dict | None = None,
+ data: Any = None,
+ **kwargs: Any,
+ ) -> requests.Response:
+ return self._worker("POST", url, params, headers, data=data, **kwargs)
+
+ def put(
+ self,
+ url: str,
+ params: dict | None = None,
+ headers: dict | None = None,
+ data: Any = None,
+ **kwargs: Any,
+ ) -> requests.Response:
+ return self._worker("PUT", url, params, headers, data=data, **kwargs)
+
+ # ---- async-flavoured wrappers (thread-pool offload) ----
+
+ async def fetch_async(
+ self,
+ url: str,
+ params: dict | None = None,
+ headers: dict | None = None,
+ **kwargs: Any,
+ ) -> requests.Response:
+ return await self._offload("GET", url, params, headers, **kwargs)
+
+ async def get_async(
+ self,
+ url: str,
+ params: dict | None = None,
+ headers: dict | None = None,
+ **kwargs: Any,
+ ) -> requests.Response:
+ """Deprecated: use `fetch_async()` instead. Kept for backward compatibility."""
+ return await self.fetch_async(url, params, headers, **kwargs)
+
+ async def extract_async(
+ self,
+ url: str,
+ params: dict | None = None,
+ headers: dict | None = None,
+ mode: str = "auto",
+ fallback_to_autoparse: bool = True,
+ adaptive_stealth: bool = True,
+ **kwargs: Any,
+ ) -> requests.Response:
+ """Async counterpart of `extract()` - see its docstring for the
+ AUTH010 -> Autoparse fallback behavior, `fallback_to_autoparse`, and
+ `adaptive_stealth`."""
+ final_params = dict(params) if params else {}
+ final_params["extract"] = mode
+ if adaptive_stealth:
+ final_params["mode"] = "auto"
+ response = await self.fetch_async(url, final_params, headers, **kwargs)
+
+ if (
+ response.status_code == 402
+ and mode == "auto"
+ and fallback_to_autoparse
+ and _is_auth010(response)
+ ):
+ autoparse_params = dict(params) if params else {}
+ autoparse_params["autoparse"] = True
+ if adaptive_stealth:
+ autoparse_params["mode"] = "auto"
+ return await self.fetch_async(url, autoparse_params, headers, **kwargs)
+
+ return response
+
+ async def post_async(
+ self,
+ url: str,
+ params: dict | None = None,
+ headers: dict | None = None,
+ data: Any = None,
+ **kwargs: Any,
+ ) -> requests.Response:
+ return await self._offload("POST", url, params, headers, data=data, **kwargs)
+
+ async def put_async(
+ self,
+ url: str,
+ params: dict | None = None,
+ headers: dict | None = None,
+ data: Any = None,
+ **kwargs: Any,
+ ) -> requests.Response:
+ return await self._offload("PUT", url, params, headers, data=data, **kwargs)
+
+ # ---- lifecycle ----
+
+ def close(self) -> None:
+ """Release the requests session + drain the thread-pool."""
+ self.requests_session.close()
+ self.executor.shutdown(wait=True)
+
+ def __enter__(self) -> "ZenRowsClient":
+ return self
+
+ def __exit__(self, *exc: object) -> None:
+ self.close()
+
+ # ---- internal ----
+
+ async def _offload(
+ self,
+ method: str,
+ url: str,
+ params: dict | None,
+ headers: dict | None,
+ **kwargs: Any,
+ ) -> requests.Response:
+ loop = asyncio.get_event_loop()
+ return await loop.run_in_executor(
+ self.executor,
+ partial(self._worker, method, url, params, headers, **kwargs),
+ )
+
+ def _worker(
+ self,
+ method: str,
+ url: str,
+ params: dict | None = None,
+ headers: dict | None = None,
+ data: Any = None,
+ **kwargs: Any,
+ ) -> requests.Response:
+ final_params: dict = {}
+ if params:
+ final_params.update(params)
+ final_params["url"] = url
+ final_params["apikey"] = self.apikey
+
+ final_headers: dict = {"User-Agent": DEFAULT_USER_AGENT}
+
+ if headers:
+ # Caller wants their own headers forwarded to the target —
+ # opt into the gateway's `custom_headers` mode and clear
+ # the requests defaults that would otherwise stomp them.
+ final_params["custom_headers"] = True
+ final_headers["Accept"] = None
+ final_headers["Accept-Encoding"] = urllib3.util.SKIP_HEADER
+ final_headers["Connection"] = None
+ final_headers.update(headers)
+
+ return self.requests_session.request(
+ method,
+ self.api_url,
+ params=final_params,
+ headers=final_headers,
+ data=data,
+ **kwargs,
+ )
diff --git a/tests/test_batch_client.py b/tests/test_batch_client.py
new file mode 100644
index 0000000..04c3fb3
--- /dev/null
+++ b/tests/test_batch_client.py
@@ -0,0 +1,1093 @@
+"""Smoke tests for ZenRowsBatchClient.
+
+Covers the wire-level contract: auth header, base-URL override, RFC
+7807 error mapping, and a round-trip through `submit_job` + the
+auto-paginating `iter_results`. We use respx to mock httpx so the
+tests stay offline-deterministic.
+"""
+
+import io
+import json
+
+import httpx
+import pytest
+import respx
+from httpx import Response
+
+from zenrows import ZenRowsBatchClient
+from zenrows.batch import BatchAPIError, IngestStatus, JobStatus, JobType, TaskResult, WaiterTimeout
+
+BASE_URL = "http://localhost:9000/v1"
+API_KEY = "test-key"
+
+
+@pytest.fixture
+def client() -> ZenRowsBatchClient:
+ """A client pointed at a fake local dev URL. Demonstrates the
+ base_url override path — same code can hit prod by dropping it."""
+ return ZenRowsBatchClient(api_key=API_KEY, base_url=BASE_URL)
+
+
+@respx.mock
+def test_submit_job_sends_api_key_and_returns_typed_response(client: ZenRowsBatchClient):
+ """Auth header is set, body is encoded, response parses into a typed model."""
+ route = respx.post(f"{BASE_URL}/jobs").mock(
+ return_value=Response(
+ 201,
+ json={
+ "job_id": "01J0000000000000000000000",
+ "status": "closed",
+ "accepted_tasks": 1,
+ },
+ )
+ )
+
+ resp = client.submit_job(
+ {
+ "type": "regular",
+ "status": "closed",
+ "tasks": [{"url": "https://example.com/a"}],
+ }
+ )
+
+ assert resp.job_id == "01J0000000000000000000000"
+ assert resp.status == JobStatus.CLOSED
+ assert resp.accepted_tasks == 1
+
+ sent = route.calls.last.request
+ assert sent.headers["X-API-Key"] == API_KEY
+ assert sent.headers["Content-Type"] == "application/json"
+ assert json.loads(sent.content) == {
+ "type": "regular",
+ "status": "closed",
+ "tasks": [{"url": "https://example.com/a"}],
+ }
+
+
+@respx.mock
+def test_problem_response_raises_batch_api_error(client: ZenRowsBatchClient):
+ """RFC 7807 problem bodies surface as BatchAPIError with a stable code."""
+ respx.get(f"{BASE_URL}/jobs/missing").mock(
+ return_value=Response(
+ 404,
+ headers={"Content-Type": "application/problem+json"},
+ json={
+ "type": "about:blank",
+ "title": "Not found",
+ "status": 404,
+ "code": "not_found",
+ "detail": "Job not found.",
+ },
+ )
+ )
+
+ with pytest.raises(BatchAPIError) as exc_info:
+ client.get_job("missing")
+ assert exc_info.value.code == "not_found"
+ assert exc_info.value.status_code == 404
+ assert exc_info.value.problem is not None
+ assert exc_info.value.problem.title == "Not found"
+
+
+@respx.mock
+def test_iter_results_auto_paginates(client: ZenRowsBatchClient):
+ """Auto-pagination uses next_cursor; yields TaskResult instances."""
+ page1 = {
+ "results": [
+ {
+ "task_id": "01T000000000000000000A",
+ "run_id": "01R000000000000000000A",
+ "url": "https://example.com/a",
+ "status": "successful",
+ }
+ ],
+ "next_cursor": "abc",
+ }
+ page2 = {
+ "results": [
+ {
+ "task_id": "01T000000000000000000B",
+ "run_id": "01R000000000000000000A",
+ "url": "https://example.com/b",
+ "status": "successful",
+ }
+ ],
+ "next_cursor": None,
+ }
+
+ def _handler(request):
+ cursor = request.url.params.get("cursor")
+ return Response(200, json=page2 if cursor == "abc" else page1)
+
+ respx.get(f"{BASE_URL}/jobs/J/results").mock(side_effect=_handler)
+
+ rows = list(client.iter_results("J"))
+ assert [r.task_id for r in rows] == [
+ "01T000000000000000000A",
+ "01T000000000000000000B",
+ ]
+
+
+def test_missing_api_key_raises():
+ """Constructor refuses to start without auth — no surprise 401s later."""
+ with pytest.raises(ValueError, match="api_key is required"):
+ ZenRowsBatchClient(api_key=None)
+
+
+def test_base_url_override_takes_precedence_over_env(monkeypatch):
+ """Explicit base_url wins over the env var which wins over the default."""
+ monkeypatch.setenv("ZENROWS_BATCH_BASE_URL", "http://env-set/v1")
+ c = ZenRowsBatchClient(api_key=API_KEY, base_url="http://kwarg/v1")
+ assert c.base_url.rstrip("/") == "http://kwarg/v1"
+
+ c2 = ZenRowsBatchClient(api_key=API_KEY)
+ assert c2.base_url.rstrip("/") == "http://env-set/v1"
+
+
+@respx.mock
+def test_enum_filter_serialises_to_string(client: ZenRowsBatchClient):
+ """`type=JobType.REGULAR` flattens to a `type=regular` query param."""
+ route = respx.get(f"{BASE_URL}/jobs").mock(
+ return_value=Response(200, json={"jobs": [], "next_cursor": None})
+ )
+
+ client.list_jobs(job_type=JobType.REGULAR, status=JobStatus.OPEN)
+
+ params = route.calls.last.request.url.params
+ assert params.get("type") == "regular"
+ assert params.get("status") == "open"
+
+
+# ----- type-specific submit shortcuts -----
+
+
+@respx.mock
+def test_submit_regular_accepts_bare_url_strings(client: ZenRowsBatchClient):
+ """`submit_regular(["url1", "url2"])` builds the same wire body as
+ the dict form — without the caller having to spell out type/status."""
+ route = respx.post(f"{BASE_URL}/jobs").mock(
+ return_value=Response(
+ 201,
+ json={"job_id": "J", "status": "closed", "accepted_tasks": 2},
+ )
+ )
+
+ client.submit_regular(
+ ["https://example.com/a", "https://example.com/b"],
+ zenrows_params={"js_render": "true"},
+ )
+
+ body = json.loads(route.calls.last.request.content)
+ assert body == {
+ "type": "regular",
+ "status": "closed",
+ "zenrows_params": {"js_render": "true"},
+ "tasks": [
+ {"url": "https://example.com/a"},
+ {"url": "https://example.com/b"},
+ ],
+ }
+
+
+@respx.mock
+def test_submit_regular_accepts_inline_dicts_with_external_id(client: ZenRowsBatchClient):
+ """Inline dict form lets each URL carry its own external_id."""
+ route = respx.post(f"{BASE_URL}/jobs").mock(
+ return_value=Response(
+ 201,
+ json={"job_id": "J", "status": "closed", "accepted_tasks": 1},
+ )
+ )
+
+ client.submit_regular(
+ [{"url": "https://example.com/a", "external_id": "order-1"}],
+ )
+
+ body = json.loads(route.calls.last.request.content)
+ assert body["tasks"] == [{"url": "https://example.com/a", "external_id": "order-1"}]
+
+
+def test_submit_regular_rejects_urls_and_file_input_together(client: ZenRowsBatchClient):
+ """Local validation fires before the round-trip."""
+ with pytest.raises(ValueError, match=r"urls.*file_input_id"):
+ client.submit_regular(["https://a"], file_input_id="FI")
+
+
+@respx.mock
+def test_submit_open_omits_tasks_for_streaming_jobs(client: ZenRowsBatchClient):
+ """`submit_open()` with no args creates an empty open job."""
+ route = respx.post(f"{BASE_URL}/jobs").mock(
+ return_value=Response(
+ 201,
+ json={"job_id": "J", "status": "open", "accepted_tasks": 0},
+ )
+ )
+
+ client.submit_open()
+
+ body = json.loads(route.calls.last.request.content)
+ # No `tasks` key at all when no urls supplied.
+ assert body == {"type": "regular", "status": "open"}
+
+
+@respx.mock
+def test_submit_scheduled_rate(client: ZenRowsBatchClient):
+ """rate-shape schedule round-trips on the wire."""
+ from zenrows.batch import Rate
+
+ route = respx.post(f"{BASE_URL}/jobs").mock(
+ return_value=Response(
+ 201,
+ json={"job_id": "J", "status": "closed", "accepted_tasks": 1},
+ )
+ )
+
+ client.submit_scheduled(
+ Rate(every=15, unit="minute"),
+ ["https://example.com/poll"],
+ )
+
+ body = json.loads(route.calls.last.request.content)
+ assert body["type"] == "scheduled"
+ assert body["schedule"] == {"rate": {"every": 15, "unit": "minute"}}
+
+
+# ----- filename clash handling -----
+
+
+def test_name_allocator_appends_suffix_on_clash():
+ """First claimer gets the bare name; subsequent claimers get
+ `_01`, `_02`, … suffixed before the extension."""
+ from zenrows.batch._download import _NameAllocator
+
+ a = _NameAllocator()
+ assert a.claim("order-1.html") == "order-1.html"
+ assert a.claim("order-1.html") == "order-1_01.html"
+ assert a.claim("order-1.html") == "order-1_02.html"
+ # Different basename — no collision.
+ assert a.claim("order-2.html") == "order-2.html"
+ # No extension — suffix still goes on the end.
+ assert a.claim("README") == "README"
+ assert a.claim("README") == "README_01"
+
+
+@respx.mock
+def test_submit_regular_passes_job_external_id_and_metadata(client: ZenRowsBatchClient):
+ """Job-level external_id + metadata round-trip on the wire."""
+ route = respx.post(f"{BASE_URL}/jobs").mock(
+ return_value=Response(
+ 201,
+ json={"job_id": "J", "status": "closed", "accepted_tasks": 1},
+ )
+ )
+
+ client.submit_regular(
+ ["https://example.com/a"],
+ external_id="quarterly-crawl-42",
+ metadata={"owner": "growth-team", "ticket": "GROW-1234"},
+ )
+
+ body = json.loads(route.calls.last.request.content)
+ assert body["external_id"] == "quarterly-crawl-42"
+ assert body["metadata"] == {"owner": "growth-team", "ticket": "GROW-1234"}
+
+
+@respx.mock
+def test_submit_scheduled_calendar_with_timezone(client: ZenRowsBatchClient):
+ """Calendar builder round-trips to the expected wire shape."""
+ from zenrows.batch import Calendar, Weekly
+
+ route = respx.post(f"{BASE_URL}/jobs").mock(
+ return_value=Response(
+ 201,
+ json={"job_id": "J", "status": "closed", "accepted_tasks": 1},
+ )
+ )
+
+ client.submit_scheduled(
+ Calendar(
+ times_of_day=["09:00", "18:00"],
+ cadence=Weekly(days=["mon", "wed", "fri"]),
+ timezone="Europe/Berlin",
+ ),
+ ["https://example.com/daily"],
+ )
+
+ body = json.loads(route.calls.last.request.content)
+ assert body["schedule"] == {
+ "calendar": {
+ "times_of_day": ["09:00", "18:00"],
+ "cadence": {"weekly": {"days": ["mon", "wed", "fri"]}},
+ },
+ "timezone": "Europe/Berlin",
+ }
+
+
+@respx.mock
+def test_submit_scheduled_at_accepts_naive_datetime(client: ZenRowsBatchClient):
+ """`At(datetime, ...)` serializes to the ISO string wire form."""
+ from datetime import datetime
+
+ from zenrows.batch import At
+
+ route = respx.post(f"{BASE_URL}/jobs").mock(
+ return_value=Response(
+ 201,
+ json={"job_id": "J", "status": "closed", "accepted_tasks": 1},
+ )
+ )
+
+ client.submit_scheduled(
+ At(datetime(2026, 9, 1, 9, 0), timezone="Europe/Berlin"),
+ ["https://example.com/once"],
+ )
+
+ body = json.loads(route.calls.last.request.content)
+ assert body["schedule"] == {
+ "at": "2026-09-01T09:00:00",
+ "timezone": "Europe/Berlin",
+ }
+
+
+def test_at_rejects_aware_datetime():
+ """`At(...)` validates in `__post_init__` — aware datetime fails fast."""
+ from datetime import datetime, timezone
+
+ from zenrows.batch import At
+
+ with pytest.raises(ValueError, match="naive datetime"):
+ At(datetime(2026, 9, 1, 9, 0, tzinfo=timezone.utc), timezone="Europe/Berlin")
+
+
+def test_at_rejects_offset_string():
+ """`At(str-with-offset, ...)` rejects in `__post_init__`."""
+ from zenrows.batch import At
+
+ with pytest.raises(ValueError, match="tz-naive"):
+ At("2026-09-01T09:00:00+02:00", timezone="Europe/Berlin")
+
+
+def test_at_rejects_z_suffix():
+ """`At('...Z', ...)` rejects in `__post_init__`."""
+ from zenrows.batch import At
+
+ with pytest.raises(ValueError, match="tz-naive"):
+ At("2026-09-01T09:00:00Z", timezone="UTC")
+
+
+def test_at_requires_timezone():
+ from zenrows.batch import At
+
+ with pytest.raises(ValueError, match="timezone is required"):
+ At("2026-09-01T09:00:00", timezone="")
+
+
+def test_at_rejects_bad_timezone():
+ from zenrows.batch import At
+
+ with pytest.raises(ValueError, match="not a valid IANA"):
+ At("2026-09-01T09:00:00", timezone="Mars/Olympus")
+
+
+def test_rate_validates():
+ from zenrows.batch import Rate
+
+ with pytest.raises(ValueError, match=">= 1"):
+ Rate(every=0, unit="minute")
+ with pytest.raises(ValueError, match="must be one of"):
+ Rate(every=5, unit="week") # type: ignore[arg-type]
+
+
+def test_calendar_validates():
+ from zenrows.batch import Calendar, Daily, Monthly, Weekly
+
+ # Half-hour rejected.
+ with pytest.raises(ValueError, match="on the hour"):
+ Calendar(
+ times_of_day=["09:30"],
+ cadence=Daily(),
+ timezone="UTC",
+ )
+
+ # Bad weekday name.
+ with pytest.raises(ValueError, match="valid day"):
+ Calendar(
+ times_of_day=["09:00"],
+ cadence=Weekly(days=["funday"]),
+ timezone="UTC",
+ )
+
+ # Monthly day out of range.
+ with pytest.raises(ValueError, match="out of range"):
+ Calendar(
+ times_of_day=["09:00"],
+ cadence=Monthly(days=[32]),
+ timezone="UTC",
+ )
+
+ # Timezone required.
+ with pytest.raises(ValueError, match="timezone is required"):
+ Calendar(
+ times_of_day=["09:00"],
+ cadence=Daily(),
+ timezone="",
+ )
+
+
+# ----- retry failed (partial rerun) -----
+
+
+def _rerun_response(run_id: str, *, total: int, successful: int) -> dict:
+ return {
+ "job_id": "J",
+ "status": "closed",
+ "latest_run": {
+ "run_id": run_id,
+ "job_id": "J",
+ "run_sequence": 2,
+ "status": "running",
+ "stats": {
+ "total": total,
+ "completed": successful,
+ "successful": successful,
+ "failed": 0,
+ },
+ "created_at": "2026-06-05T00:00:00Z",
+ "updated_at": "2026-06-05T00:00:00Z",
+ },
+ "rerun_of": "R1",
+ "retried_tasks": total - successful,
+ "inherited_tasks": successful,
+ }
+
+
+@respx.mock
+def test_retry_failed_sends_status_failed(client: ZenRowsBatchClient):
+ route = respx.post(f"{BASE_URL}/jobs/J/rerun").mock(
+ return_value=Response(201, json=_rerun_response("R2", total=100, successful=90))
+ )
+
+ # Act on a ref without a GET round-trip.
+ run = client.job("J").retry_failed()
+
+ assert run.run_id == "R2"
+ assert dict(route.calls.last.request.url.params) == {"status": "failed"}
+
+
+@respx.mock
+def test_retry_failed_include_pending_sends_both(client: ZenRowsBatchClient):
+ route = respx.post(f"{BASE_URL}/jobs/J/rerun").mock(
+ return_value=Response(201, json=_rerun_response("R2", total=100, successful=80))
+ )
+
+ client.job("J").retry_failed(include_pending=True, idempotency_key="k1")
+
+ req = route.calls.last.request
+ assert dict(req.url.params) == {"status": "failed,pending"}
+ assert req.headers["Idempotency-Key"] == "k1"
+
+
+# ----- download all results (export-based zip) -----
+
+
+@respx.mock
+def test_download_all_results_starts_polls_and_streams(client: ZenRowsBatchClient, tmp_path):
+ exports = f"{BASE_URL}/jobs/J/runs/R/exports"
+ download = "https://s3.example.test/exports/E.zip?sig=abc"
+
+ respx.post(exports).mock(
+ return_value=Response(
+ 202,
+ json={
+ "export_id": "01J000000000000000000000EX",
+ "status": "pending",
+ "created_at": "2026-06-05T00:00:00Z",
+ "expires_at": "2026-06-05T12:00:00Z",
+ },
+ )
+ )
+ respx.get(f"{exports}/01J000000000000000000000EX").mock(
+ return_value=Response(
+ 200,
+ json={
+ "export_id": "01J000000000000000000000EX",
+ "status": "completed",
+ "error": None,
+ "download_url": download,
+ "created_at": "2026-06-05T00:00:00Z",
+ "expires_at": "2026-06-05T12:00:00Z",
+ },
+ )
+ )
+ respx.get(download).mock(return_value=Response(200, content=b"PK\x03\x04 zip-bytes"))
+
+ out = client.download_all_results("J", "R", tmp_path / "results.zip")
+
+ assert out.read_bytes() == b"PK\x03\x04 zip-bytes"
+
+
+# ----- scheduled-job management -----
+
+
+def _scheduled_job(schedule_state: str) -> dict:
+ return {
+ "job_id": "J",
+ "type": "scheduled",
+ "status": "closed",
+ "schedule_state": schedule_state,
+ "created_at": "2026-06-05T00:00:00Z",
+ "updated_at": "2026-06-05T00:00:00Z",
+ }
+
+
+def _run_json(
+ *, run_id: str = "R", status: str = "running", pause_state: str | None = None
+) -> dict:
+ run = {
+ "run_id": run_id,
+ "job_id": "J",
+ "run_sequence": 1,
+ "status": status,
+ "stats": {"total": 10, "completed": 3, "successful": 3, "failed": 0},
+ "created_at": "2026-07-07T00:00:00Z",
+ "updated_at": "2026-07-07T00:00:00Z",
+ }
+ if pause_state:
+ run["pause_state"] = pause_state
+ return run
+
+
+@respx.mock
+def test_run_pause_and_resume_suspend_current_run(client: ZenRowsBatchClient):
+ """`job.run.pause()` / `.resume()` hit the run-level endpoints
+ (distinct from `job.schedule.*`) and return a fresh RunHandle."""
+ pause = respx.post(f"{BASE_URL}/jobs/J/pause").mock(
+ return_value=Response(200, json=_run_json(pause_state="paused"))
+ )
+ resume = respx.post(f"{BASE_URL}/jobs/J/resume").mock(
+ return_value=Response(200, json=_run_json(pause_state="active"))
+ )
+
+ job = client.job("J")
+ paused = job.run.pause()
+ assert pause.call_count == 1
+ assert paused.run_id == "R"
+ assert paused.data.pause_state.value == "paused"
+
+ resumed = job.run.resume()
+ assert resume.call_count == 1
+ assert resumed.data.pause_state.value == "active"
+
+
+@respx.mock
+def test_run_stop_posts_stop_and_returns_run_handle(client: ZenRowsBatchClient):
+ """`job.run.stop()` (and its `cancel()` alias) POST /stop and echo
+ the refreshed run."""
+ route = respx.post(f"{BASE_URL}/jobs/J/stop").mock(
+ return_value=Response(200, json=_run_json(status="stopped"))
+ )
+
+ stopped = client.job("J").run.stop()
+ assert route.call_count == 1
+ assert stopped.run_id == "R"
+ assert stopped.data.status.value == "stopped"
+
+
+@respx.mock
+def test_pause_and_resume_post_schedule_state(client: ZenRowsBatchClient):
+ route = respx.post(f"{BASE_URL}/jobs/J/schedule/state").mock(
+ side_effect=[
+ Response(200, json=_scheduled_job("paused")),
+ Response(200, json=_scheduled_job("active")),
+ ]
+ )
+
+ job = client.job("J")
+ paused = job.schedule.pause()
+ assert json.loads(route.calls[0].request.content) == {"schedule_state": "paused"}
+ assert paused.data.schedule_state.value == "paused"
+
+ resumed = job.schedule.resume()
+ assert json.loads(route.calls[1].request.content) == {"schedule_state": "active"}
+ assert resumed.data.schedule_state.value == "active"
+
+
+@respx.mock
+def test_update_schedule_puts_resolved_body(client: ZenRowsBatchClient):
+ from zenrows.batch import Rate
+
+ route = respx.put(f"{BASE_URL}/jobs/J/schedule").mock(
+ return_value=Response(200, json=_scheduled_job("active"))
+ )
+
+ client.job("J").schedule.update(Rate(every=15, unit="minute"))
+
+ assert json.loads(route.calls.last.request.content) == {"rate": {"every": 15, "unit": "minute"}}
+
+
+@respx.mock
+def test_submit_regular_sends_webhook_and_name(client: ZenRowsBatchClient):
+ route = respx.post(f"{BASE_URL}/jobs").mock(
+ return_value=Response(201, json={"job_id": "J", "status": "closed", "accepted_tasks": 1})
+ )
+
+ client.submit_regular(
+ ["https://example.com/a"],
+ name="nightly-prices",
+ webhook={"url": "https://hooks.example.com/zr", "signature": True},
+ )
+
+ body = json.loads(route.calls.last.request.content)
+ assert body["name"] == "nightly-prices"
+ assert body["webhook"] == {"url": "https://hooks.example.com/zr", "signature": True}
+
+
+# ----- wait-for-ingest (async 202 submits, COR-358) -----
+
+
+def _ingest_run_json(ingest_status: str | None, *, status: str = "running") -> dict:
+ run = {
+ "run_id": "R",
+ "job_id": "J",
+ "run_sequence": 1,
+ "status": status,
+ "stats": {"total": 10000, "completed": 0, "successful": 0, "failed": 0},
+ "created_at": "2026-07-07T00:00:00Z",
+ "updated_at": "2026-07-07T00:00:00Z",
+ }
+ if ingest_status:
+ run["ingest_status"] = ingest_status
+ return run
+
+
+def _ingest_job_json(ingest_status: str | None) -> dict:
+ return {
+ "job_id": "J",
+ "type": "regular",
+ "status": "closed",
+ "created_at": "2026-07-07T00:00:00Z",
+ "updated_at": "2026-07-07T00:00:00Z",
+ "latest_run": _ingest_run_json(ingest_status),
+ }
+
+
+@respx.mock
+def test_submit_wait_for_ingest_polls_202_until_done(client: ZenRowsBatchClient):
+ """A 202 submit body carries `ingest_status: pending`; the flag
+ polls GET /jobs/{id} until it leaves `pending`."""
+ respx.post(f"{BASE_URL}/jobs").mock(
+ return_value=Response(
+ 202,
+ json={
+ "job_id": "J",
+ "status": "closed",
+ "accepted_tasks": 10000,
+ "latest_run": _ingest_run_json("pending"),
+ },
+ )
+ )
+ get_route = respx.get(f"{BASE_URL}/jobs/J").mock(
+ return_value=Response(200, json=_ingest_job_json("done"))
+ )
+
+ job = client.submit_regular(["https://example.com/a"], wait_for_ingest=True)
+
+ assert get_route.call_count == 1
+ assert job.data.latest_run.ingest_status is IngestStatus.DONE
+
+
+@respx.mock
+def test_submit_wait_for_ingest_201_skips_polling(client: ZenRowsBatchClient):
+ """Sync (201) submits never carry `ingest_status` — the flag must
+ not cost a follow-up GET."""
+ respx.post(f"{BASE_URL}/jobs").mock(
+ return_value=Response(
+ 201,
+ json={
+ "job_id": "J",
+ "status": "closed",
+ "accepted_tasks": 1,
+ "latest_run": _ingest_run_json(None),
+ },
+ )
+ )
+ get_route = respx.get(f"{BASE_URL}/jobs/J").mock(
+ return_value=Response(200, json=_ingest_job_json(None))
+ )
+
+ client.submit_regular(["https://example.com/a"], wait_for_ingest=True)
+
+ assert get_route.call_count == 0
+
+
+@respx.mock
+def test_wait_for_ingest_standalone_polls_until_done(client: ZenRowsBatchClient):
+ """`JobRef.wait_for_ingest()` works on any ref — the path a caller
+ uses when acting on a known id. Returns a fresh loaded handle."""
+ get_route = respx.get(f"{BASE_URL}/jobs/J").mock(
+ side_effect=[
+ Response(200, json=_ingest_job_json("pending")),
+ Response(200, json=_ingest_job_json("pending")),
+ Response(200, json=_ingest_job_json("done")),
+ ]
+ )
+
+ out = client.job("J").wait_for_ingest(timeout=5.0, poll_interval=0.01)
+
+ assert get_route.call_count == 3
+ assert out.data.latest_run.ingest_status is IngestStatus.DONE
+
+
+@respx.mock
+def test_wait_for_ingest_timeout_raises(client: ZenRowsBatchClient):
+ respx.get(f"{BASE_URL}/jobs/J").mock(
+ return_value=Response(200, json=_ingest_job_json("pending"))
+ )
+
+ with pytest.raises(WaiterTimeout):
+ client.job("J").wait_for_ingest(timeout=0.05, poll_interval=0.01)
+
+
+@respx.mock
+def test_job_handle_is_get_free(client: ZenRowsBatchClient):
+ """`client.job(id)` mints a handle with no network call; acting on it
+ (delete) hits only the operation endpoint — no wasted GET."""
+ get_route = respx.get(f"{BASE_URL}/jobs/J").mock(return_value=Response(200, json={}))
+ del_route = respx.delete(f"{BASE_URL}/jobs/J").mock(return_value=Response(202, json={}))
+
+ client.job("J").delete()
+
+ assert del_route.call_count == 1
+ assert get_route.call_count == 0 # no GET just to delete
+
+
+@respx.mock
+def test_run_handle_is_get_free(client: ZenRowsBatchClient):
+ """`client.run(job, run)` — same GET-free contract, scoped to one run."""
+ get_route = respx.get(f"{BASE_URL}/jobs/J/runs/R").mock(return_value=Response(200, json={}))
+ del_route = respx.delete(f"{BASE_URL}/jobs/J/runs/R").mock(return_value=Response(202, json={}))
+
+ client.run("J", "R").delete()
+
+ assert del_route.call_count == 1
+ assert get_route.call_count == 0
+
+
+@respx.mock
+def test_job_ref_load_fetches_once(client: ZenRowsBatchClient):
+ """A `client.job(id)` ref is GET-free and has no `.data`; `.load()`
+ fetches exactly once and returns a loaded `JobHandle`."""
+ get_route = respx.get(f"{BASE_URL}/jobs/J").mock(
+ return_value=Response(200, json=_ingest_job_json("done"))
+ )
+
+ ref = client.job("J")
+ assert get_route.call_count == 0 # minting the ref costs nothing
+ assert not hasattr(ref, "data") # a ref carries no snapshot
+
+ handle = ref.load() # explicit GET
+ assert get_route.call_count == 1
+ assert handle.data.status is not None
+
+
+# ===== transport retries =====
+
+
+@pytest.fixture
+def no_sleep(monkeypatch):
+ """Record + swallow retry backoff sleeps so tests run instantly.
+ Returns the list of sleep durations (seconds) in call order."""
+ slept: list[float] = []
+ monkeypatch.setattr("zenrows.batch._transport.time.sleep", slept.append)
+ return slept
+
+
+@respx.mock
+def test_retry_transient_status_then_succeeds(client: ZenRowsBatchClient, no_sleep):
+ """A 503 on an idempotent GET is retried; the eventual 200 wins."""
+ route = respx.get(f"{BASE_URL}/jobs/J").mock(
+ side_effect=[
+ Response(503, json={}),
+ Response(503, json={}),
+ Response(200, json=_ingest_job_json("done")),
+ ]
+ )
+
+ job = client.get_job("J")
+
+ assert route.call_count == 3 # two retries, then success
+ assert job.data.status is JobStatus.CLOSED
+ assert len(no_sleep) == 2 # slept once before each retry
+
+
+@respx.mock
+def test_retry_exhausts_and_raises(client: ZenRowsBatchClient, no_sleep):
+ """Default 3 retries → 4 attempts, then the last error surfaces."""
+ route = respx.get(f"{BASE_URL}/jobs/J").mock(return_value=Response(503, json={}))
+
+ with pytest.raises(BatchAPIError) as exc:
+ client.get_job("J")
+
+ assert exc.value.status_code == 503
+ assert route.call_count == 4 # 1 + 3 retries
+
+
+@respx.mock
+def test_no_retry_on_non_retryable_status(client: ZenRowsBatchClient, no_sleep):
+ """A 400 is a client error, not transient — no retry."""
+ route = respx.get(f"{BASE_URL}/jobs/J").mock(return_value=Response(400, json={}))
+
+ with pytest.raises(BatchAPIError):
+ client.get_job("J")
+
+ assert route.call_count == 1
+
+
+@respx.mock
+def test_no_retry_on_non_idempotent_post(client: ZenRowsBatchClient, no_sleep):
+ """A plain POST (no Idempotency-Key) is not replayed on 503."""
+ route = respx.post(f"{BASE_URL}/jobs").mock(return_value=Response(503, json={}))
+
+ with pytest.raises(BatchAPIError):
+ client.submit_regular(["https://a"])
+
+ assert route.call_count == 1
+
+
+@respx.mock
+def test_retry_on_post_with_idempotency_key(client: ZenRowsBatchClient, no_sleep):
+ """A POST carrying an Idempotency-Key IS safe to replay."""
+ route = respx.post(f"{BASE_URL}/jobs").mock(
+ side_effect=[
+ Response(503, json={}),
+ Response(201, json={"job_id": "J", "status": "closed", "accepted_tasks": 1}),
+ ]
+ )
+
+ ref = client.submit_regular(["https://a"], idempotency_key="k1")
+
+ assert route.call_count == 2
+ assert ref.job_id == "J"
+
+
+@respx.mock
+def test_retry_honors_retry_after(client: ZenRowsBatchClient, no_sleep):
+ """`Retry-After: 2` overrides the computed backoff for that wait."""
+ respx.get(f"{BASE_URL}/jobs/J").mock(
+ side_effect=[
+ Response(429, headers={"Retry-After": "2"}, json={}),
+ Response(200, json=_ingest_job_json("done")),
+ ]
+ )
+
+ client.get_job("J")
+
+ assert no_sleep[0] == 2.0 # 2000ms / 1000
+
+
+@respx.mock
+def test_retry_on_network_error_then_succeeds(client: ZenRowsBatchClient, no_sleep):
+ """Transient network errors on idempotent requests are retried."""
+ route = respx.get(f"{BASE_URL}/jobs/J").mock(
+ side_effect=[httpx.ConnectError("reset"), Response(200, json=_ingest_job_json("done"))]
+ )
+
+ job = client.get_job("J")
+
+ assert route.call_count == 2
+ assert job.data.status is JobStatus.CLOSED
+
+
+@respx.mock
+def test_no_retry_on_timeout(client: ZenRowsBatchClient, no_sleep):
+ """Our own timeout budget is not retried — it propagates."""
+ route = respx.get(f"{BASE_URL}/jobs/J").mock(side_effect=httpx.ReadTimeout("slow"))
+
+ with pytest.raises(httpx.ReadTimeout):
+ client.get_job("J")
+
+ assert route.call_count == 1
+ assert no_sleep == []
+
+
+@respx.mock
+def test_retries_zero_disables(no_sleep):
+ """`retries=0` sends each request exactly once."""
+ client = ZenRowsBatchClient(api_key=API_KEY, base_url=BASE_URL, retries=0)
+ route = respx.get(f"{BASE_URL}/jobs/J").mock(return_value=Response(503, json={}))
+
+ with pytest.raises(BatchAPIError):
+ client.get_job("J")
+
+ assert route.call_count == 1
+
+
+# ===== webhooks =====
+
+_WEBHOOK_JSON = {"url": "https://hooks.example.com/zr", "signature": True}
+
+
+@respx.mock
+def test_get_job_webhook(client: ZenRowsBatchClient):
+ respx.get(f"{BASE_URL}/jobs/J/webhook").mock(return_value=Response(200, json=_WEBHOOK_JSON))
+
+ cfg = client.get_job_webhook("J")
+
+ assert str(cfg.url) == "https://hooks.example.com/zr"
+ assert cfg.signature is True
+
+
+@respx.mock
+def test_put_job_webhook_sends_both_fields(client: ZenRowsBatchClient):
+ route = respx.put(f"{BASE_URL}/jobs/J/webhook").mock(
+ return_value=Response(200, json=_WEBHOOK_JSON)
+ )
+
+ client.put_job_webhook("J", {"url": "https://hooks.example.com/zr", "signature": True})
+
+ assert json.loads(route.calls.last.request.content) == {
+ "url": "https://hooks.example.com/zr",
+ "signature": True,
+ }
+
+
+@respx.mock
+def test_delete_job_webhook(client: ZenRowsBatchClient):
+ route = respx.delete(f"{BASE_URL}/jobs/J/webhook").mock(return_value=Response(204))
+
+ assert client.delete_job_webhook("J") is None
+ assert route.call_count == 1
+
+
+@respx.mock
+def test_test_webhook(client: ZenRowsBatchClient):
+ route = respx.post(f"{BASE_URL}/webhook/test").mock(
+ return_value=Response(
+ 200,
+ json={"delivered": True, "event_id": "01T", "status_code": 200, "elapsed_ms": 42},
+ )
+ )
+
+ resp = client.test_webhook({"url": "https://hooks.example.com/zr"})
+
+ assert resp.delivered is True
+ assert resp.status_code == 200
+ assert json.loads(route.calls.last.request.content)["url"] == "https://hooks.example.com/zr"
+
+
+@respx.mock
+def test_job_ref_webhook_facet_delegates(client: ZenRowsBatchClient):
+ """`job.set_webhook()` / `.get_webhook()` hit the job webhook route."""
+ put = respx.put(f"{BASE_URL}/jobs/J/webhook").mock(
+ return_value=Response(200, json=_WEBHOOK_JSON)
+ )
+
+ cfg = client.job("J").set_webhook("https://hooks.example.com/zr", signature=True)
+
+ assert put.call_count == 1
+ assert cfg.signature is True
+
+
+# ===== single-task download =====
+
+
+def _task_result(
+ task_id: str,
+ *,
+ external_id: str | None = None,
+ result_type: str | None = None,
+ result_url: str | None = None,
+ status: str = "successful",
+) -> TaskResult:
+ data = {"task_id": task_id, "run_id": "R", "url": "https://example.com", "status": status}
+ if external_id:
+ data["external_id"] = external_id
+ if result_type:
+ data["type"] = result_type
+ if result_url:
+ data["result_url"] = result_url
+ return TaskResult.model_validate(data)
+
+
+@respx.mock
+def test_download_task_pulls_from_result_url(client: ZenRowsBatchClient, tmp_path):
+ """`run.download_task_to_*` GET the presigned `result_url` directly
+ (no /content endpoint); memory returns raw bytes, file writes to a
+ path or an open binary file object."""
+ result_url = "https://storage.example.test/bodies/T1.html?sig=abc"
+ route = respx.get(result_url).mock(return_value=Response(200, content=b"hi"))
+ run = client.run("J", "R")
+ task = _task_result("T1", result_url=result_url)
+
+ assert run.download_task_to_memory(task) == b"hi"
+
+ out = tmp_path / "nested" / "body.html" # parent dirs created
+ run.download_task_to_file(task, out)
+ assert out.read_bytes() == b"hi"
+
+ buf = io.BytesIO() # also accepts an open binary file object
+ run.download_task_to_file(task, buf)
+ assert buf.getvalue() == b"hi"
+
+ assert route.call_count == 3 # one GET per download, straight to storage
+
+
+@respx.mock
+def test_download_task_no_result_url_raises(client: ZenRowsBatchClient):
+ """A task with no `result_url` (e.g. a failed task) can't be downloaded."""
+ task = _task_result("T1", status="failed")
+ with pytest.raises(ValueError, match="no result_url"):
+ client.run("J", "R").download_task_to_memory(task)
+
+
+def test_external_id_filename_coerces_to_safe_name():
+ """`use_external_id=True` coerces the id into a safe filename —
+ unsafe chars → `_`, missing id → task_id fallback."""
+ from zenrows.batch._download import _external_id_filename
+
+ ok = _task_result("T1", external_id="order-1", result_type="html")
+ assert _external_id_filename(ok) == "order-1.html"
+
+ unsafe = _task_result("T1", external_id="a/b c", result_type="html")
+ assert _external_id_filename(unsafe) == "a_b_c.html" # '/' and ' ' → '_'
+
+ missing = _task_result("T1", result_type="html")
+ assert _external_id_filename(missing) == "T1.html" # falls back to task_id
+
+
+@respx.mock
+def test_submit_job_post_task_method_body_on_wire(client: ZenRowsBatchClient):
+ """A POST task's method/body ride the wire verbatim; tasks that
+ don't set them send neither key (exclude_unset — the server treats
+ absent method as GET)."""
+ route = respx.post(f"{BASE_URL}/jobs").mock(
+ return_value=Response(
+ 201,
+ json={
+ "job_id": "01J0000000000000000000000",
+ "status": "closed",
+ "accepted_tasks": 2,
+ },
+ )
+ )
+
+ client.submit_job(
+ {
+ "type": "regular",
+ "status": "closed",
+ "tasks": [
+ {
+ "url": "https://api.example.com/graphql",
+ "method": "POST",
+ "body": {"query": "{ products { id } }"},
+ },
+ {"url": "https://example.com/plain"},
+ ],
+ }
+ )
+
+ sent = json.loads(route.calls.last.request.content)
+ assert sent["tasks"][0]["method"] == "POST"
+ assert sent["tasks"][0]["body"] == {"query": "{ products { id } }"}
+ assert "method" not in sent["tasks"][1]
+ assert "body" not in sent["tasks"][1]
diff --git a/tests/test_client.py b/tests/test_client.py
index 96c7686..fff8bde 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -1,6 +1,7 @@
-from unittest import mock, TestCase
-from requests import Session
+from unittest import TestCase, mock
+
import urllib3
+from requests import Session
from zenrows import ZenRowsClient
from zenrows.__version__ import __version__
@@ -8,9 +9,7 @@
apikey = "APIKEY"
url = "http://example.com"
api_url_base = "https://api.zenrows.com/v1/"
-default_headers = {
- "User-Agent": f"zenrows/{__version__} python"
-}
+default_headers = {"User-Agent": f"zenrows/{__version__} python"}
class TestZenRowsClient(TestCase):
@@ -25,52 +24,71 @@ def test_get_url(self, mock_request):
mock_request.assert_called_once_with(
"GET",
api_url_base,
- params={
- "url": url,
- "apikey": apikey
- },
+ params={"url": url, "apikey": apikey},
headers=default_headers,
data=None,
)
@mock.patch.object(Session, "request")
def test_get_with_params(self, mock_request):
- self.zenrows_client.get(
- url, params={
- "premium_proxy": True,
- "proxy_country": "us"
- })
+ self.zenrows_client.get(url, params={"premium_proxy": True, "proxy_country": "us"})
mock_request.assert_called_once_with(
"GET",
api_url_base,
- params={
- "url": url,
- "apikey": apikey,
- "premium_proxy": True,
- "proxy_country": "us"
- },
+ params={"url": url, "apikey": apikey, "premium_proxy": True, "proxy_country": "us"},
+ headers=default_headers,
+ data=None,
+ )
+
+ @mock.patch.object(Session, "request")
+ def test_fetch_is_the_primary_method_get_is_a_deprecated_alias(self, mock_request):
+ self.zenrows_client.fetch(url)
+
+ mock_request.assert_called_once_with(
+ "GET",
+ api_url_base,
+ params={"url": url, "apikey": apikey},
+ headers=default_headers,
+ data=None,
+ )
+
+ @mock.patch.object(Session, "request")
+ def test_extract_sets_the_extract_param_defaulting_to_auto(self, mock_request):
+ self.zenrows_client.extract(url)
+
+ mock_request.assert_called_once_with(
+ "GET",
+ api_url_base,
+ params={"url": url, "apikey": apikey, "extract": "auto", "mode": "auto"},
+ headers=default_headers,
+ data=None,
+ )
+
+ @mock.patch.object(Session, "request")
+ def test_extract_accepts_an_explicit_mode(self, mock_request):
+ self.zenrows_client.extract(url, mode="native")
+
+ mock_request.assert_called_once_with(
+ "GET",
+ api_url_base,
+ params={"url": url, "apikey": apikey, "extract": "native", "mode": "auto"},
headers=default_headers,
data=None,
)
@mock.patch.object(Session, "request")
def test_get_with_headers(self, mock_request):
- self.zenrows_client.get(
- url, headers={"Referrer": "https://www.google.com"})
+ self.zenrows_client.get(url, headers={"Referrer": "https://www.google.com"})
mock_request.assert_called_once_with(
"GET",
api_url_base,
- params={
- "url": url,
- "apikey": apikey,
- "custom_headers": True
- },
+ params={"url": url, "apikey": apikey, "custom_headers": True},
headers={
"User-Agent": f"zenrows/{__version__} python",
"Referrer": "https://www.google.com",
- 'Accept-Encoding': urllib3.util.SKIP_HEADER,
+ "Accept-Encoding": urllib3.util.SKIP_HEADER,
"Connection": None,
"Accept": None,
},
@@ -80,19 +98,19 @@ def test_get_with_headers(self, mock_request):
@mock.patch.object(Session, "request")
def test_get_overwrite_ua(self, mock_request):
self.zenrows_client.get(
- url, headers={"User-Agent": "MyCustomUserAgent", })
+ url,
+ headers={
+ "User-Agent": "MyCustomUserAgent",
+ },
+ )
mock_request.assert_called_once_with(
"GET",
api_url_base,
- params={
- "url": url,
- "apikey": apikey,
- "custom_headers": True
- },
+ params={"url": url, "apikey": apikey, "custom_headers": True},
headers={
"User-Agent": "MyCustomUserAgent",
- 'Accept-Encoding': urllib3.util.SKIP_HEADER,
+ "Accept-Encoding": urllib3.util.SKIP_HEADER,
"Connection": None,
"Accept": None,
},
@@ -101,8 +119,7 @@ def test_get_overwrite_ua(self, mock_request):
@mock.patch.object(Session, "request")
def test_post_with_data(self, mock_request):
- self.zenrows_client.post(
- url, data={"key1": "value1", "key2": "value2"})
+ self.zenrows_client.post(url, data={"key1": "value1", "key2": "value2"})
mock_request.assert_called_once_with(
"POST",
@@ -120,8 +137,7 @@ def test_post_with_data(self, mock_request):
@mock.patch.object(Session, "request")
def test_put_with_data(self, mock_request):
- self.zenrows_client.put(
- url, data={"key1": "value1", "key2": "value2"})
+ self.zenrows_client.put(url, data={"key1": "value1", "key2": "value2"})
mock_request.assert_called_once_with(
"PUT",
diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py
index c9bbdde..4efe474 100644
--- a/tests/test_concurrency.py
+++ b/tests/test_concurrency.py
@@ -1,5 +1,6 @@
from unittest import IsolatedAsyncioTestCase
from unittest.mock import patch
+
from requests import Session
from zenrows import ZenRowsClient
diff --git a/tests/test_estimate.py b/tests/test_estimate.py
new file mode 100644
index 0000000..659b4da
--- /dev/null
+++ b/tests/test_estimate.py
@@ -0,0 +1,161 @@
+"""Cost estimation (SPEC §8.1) — pure, client-side, no network."""
+
+import pytest
+
+from zenrows.batch import (
+ CostEstimate,
+ Tier,
+ ZenRowsBatchClient,
+)
+
+# Internal pricing helpers — no public function surface; estimation is
+# reached via `client.estimate_cost` / `client.estimate_job`. These tests
+# reach the private primitives to cover the rate card directly.
+from zenrows.batch._estimate import _cost_for_params as cost_for_params
+from zenrows.batch._estimate import _estimate_cost as estimate_cost
+from zenrows.batch.models import TaskInput
+
+# ----- single-task pricing (the rate card) -----
+
+
+@pytest.mark.parametrize(
+ ("params", "tier", "lo", "hi"),
+ [
+ ({}, Tier.BASE, 1, 1),
+ ({"js_render": "true"}, Tier.JS, 5, 5),
+ ({"premium_proxy": "true"}, Tier.PREMIUM, 10, 10),
+ ({"js_render": "true", "premium_proxy": "true"}, Tier.JS_AND_PREMIUM, 25, 25),
+ ({"mode": "auto"}, Tier.AUTO, 1, 25),
+ ],
+)
+def test_cost_for_params_rate_card(params, tier, lo, hi):
+ tc = cost_for_params(params)
+ assert tc.tier is tier
+ assert (tc.min, tc.max) == (lo, hi)
+ assert tc.exact == (lo == hi)
+
+
+@pytest.mark.parametrize("value", [True, "true", "True", " TRUE ", 1, "1", "yes", "on"])
+def test_truthy_spellings_turn_on_a_flag(value):
+ assert cost_for_params({"js_render": value}).tier is Tier.JS
+
+
+@pytest.mark.parametrize("value", [False, "false", "0", 0, "", "no"])
+def test_falsy_spellings_keep_base(value):
+ assert cost_for_params({"js_render": value}).tier is Tier.BASE
+
+
+def test_auto_wins_over_explicit_flags():
+ # Server rejects this combo at submit (mutually exclusive), but if
+ # it slips through, auto is what the engine honors.
+ tc = cost_for_params({"mode": "auto", "js_render": "true", "premium_proxy": "true"})
+ assert tc.tier is Tier.AUTO
+ assert (tc.min, tc.max) == (1, 25)
+
+
+# ----- job aggregation -----
+
+
+def test_empty_job_is_zero():
+ est = estimate_cost([])
+ assert (est.task_count, est.min, est.max) == (0, 0, 0)
+ assert est.exact
+ assert est.breakdown == ()
+
+
+def test_all_base_is_exact():
+ est = estimate_cost(["https://a", "https://b", "https://c"])
+ assert (est.min, est.max) == (3, 3)
+ assert est.exact
+ assert est.auto_tasks == 0
+
+
+def test_job_level_params_apply_to_every_task():
+ est = estimate_cost(["https://a", "https://b"], zenrows_params={"premium_proxy": True})
+ assert (est.min, est.max) == (20, 20)
+ assert est.breakdown[0].tier is Tier.PREMIUM
+ assert est.breakdown[0].count == 2
+
+
+def test_task_params_override_job_params():
+ # Job says premium (10); one task overrides to plain base (1).
+ est = estimate_cost(
+ [
+ {"url": "https://a", "zenrows_params": {"premium_proxy": False}},
+ {"url": "https://b"},
+ ],
+ zenrows_params={"premium_proxy": True},
+ )
+ assert (est.min, est.max) == (1 + 10, 1 + 10)
+ tiers = {line.tier for line in est.breakdown}
+ assert tiers == {Tier.BASE, Tier.PREMIUM}
+
+
+def test_auto_drives_the_range():
+ est = estimate_cost([{"url": "https://a", "zenrows_params": {"mode": "auto"}}] * 50)
+ assert (est.min, est.max) == (50, 1250)
+ assert not est.exact
+ assert est.auto_tasks == 50
+ # width is exactly 24 x auto_tasks
+ assert est.max - est.min == 24 * est.auto_tasks
+
+
+def test_mixed_breakdown_sums_and_orders():
+ tasks = (
+ ["https://base1", "https://base2"] # 2 x base
+ + [{"url": "https://js", "zenrows_params": {"js_render": "true"}}] # 1 x js
+ + [{"url": f"https://auto{i}", "zenrows_params": {"mode": "auto"}} for i in range(3)]
+ )
+ est = estimate_cost(tasks)
+ assert est.task_count == 6
+ # min: 2*1 + 1*5 + 3*1 = 10 ; max: 2*1 + 1*5 + 3*25 = 82
+ assert (est.min, est.max) == (10, 82)
+ # breakdown renders in tier order: base, js, auto
+ assert [line.tier for line in est.breakdown] == [Tier.BASE, Tier.JS, Tier.AUTO]
+ base, js, auto = est.breakdown
+ assert (base.count, base.subtotal_min, base.subtotal_max) == (2, 2, 2)
+ assert (js.count, js.subtotal_min, js.subtotal_max) == (1, 5, 5)
+ assert (auto.count, auto.subtotal_min, auto.subtotal_max) == (3, 3, 75)
+
+
+def test_taskinput_model_input_supported():
+ tasks = [
+ TaskInput(url="https://a", zenrows_params={"js_render": True}),
+ TaskInput(url="https://b"),
+ ]
+ est = estimate_cost(tasks)
+ assert (est.min, est.max) == (6, 6)
+
+
+# ----- presentation -----
+
+
+def test_str_and_format():
+ est = estimate_cost(["https://a", {"url": "https://b", "zenrows_params": {"mode": "auto"}}])
+ assert str(est) == "2-26 credits (2 tasks)"
+ out = est.format()
+ assert "2 tasks → 2-26 credits" in out
+ assert "base" in out and "auto" in out
+
+
+# ----- client convenience (no network) -----
+
+
+def test_client_estimate_cost_is_offline():
+ client = ZenRowsBatchClient(api_key="test-key")
+ est = client.estimate_cost(
+ {
+ "type": "regular",
+ "status": "closed",
+ "zenrows_params": {"js_render": "true"},
+ "tasks": [{"url": "https://a"}, {"url": "https://b"}],
+ }
+ )
+ assert isinstance(est, CostEstimate)
+ assert (est.min, est.max) == (10, 10)
+
+
+def test_client_estimate_cost_file_input_is_zero():
+ client = ZenRowsBatchClient(api_key="test-key")
+ est = client.estimate_cost({"type": "regular", "status": "closed", "file_input_id": "01HKE..."})
+ assert (est.task_count, est.min, est.max) == (0, 0, 0)
diff --git a/tests/test_fetch_extract.py b/tests/test_fetch_extract.py
new file mode 100644
index 0000000..4ea18ac
--- /dev/null
+++ b/tests/test_fetch_extract.py
@@ -0,0 +1,339 @@
+"""Real behavior tests for fetch()/extract() and the client-level gaps they share.
+
+Existing tests in test_client.py only assert on *call arguments* passed to
+`Session.request` — none of them exercise what the client actually does with
+a response, an error status, an unvalidated mode string, or the constructor/
+context-manager surface. These tests close that gap: they assert on what
+`fetch()`/`extract()`/`get()` actually *return* and *raise* (or don't).
+"""
+
+from unittest import IsolatedAsyncioTestCase, TestCase, mock
+
+from requests import Response, Session
+
+from zenrows import ZenRowsClient
+
+apikey = "APIKEY"
+url = "http://example.com"
+api_url_base = "https://api.zenrows.com/v1/"
+
+
+def _fake_response(status_code: int, body: bytes = b"") -> Response:
+ response = Response()
+ response.status_code = status_code
+ response._content = body
+ return response
+
+
+class TestFetchExtractErrorHandling(TestCase):
+ """The client never raises on a non-2xx status — it hands the caller the
+ real Response so they can check `.status_code`/`.text` themselves. That's
+ a deliberate design choice (no `raise_for_status()` anywhere in `_worker`),
+ not an oversight — these tests pin that behavior down so a future change
+ can't silently start raising (or silently start swallowing errors) without
+ a test failing.
+ """
+
+ def setUp(self):
+ self.client = ZenRowsClient(apikey)
+
+ @mock.patch.object(Session, "request")
+ def test_fetch_returns_error_response_unchanged_no_raise(self, mock_request):
+ mock_request.return_value = _fake_response(403, b"blocked")
+
+ response = self.client.fetch(url)
+
+ self.assertEqual(response.status_code, 403)
+ self.assertEqual(response.content, b"blocked")
+
+ @mock.patch.object(Session, "request")
+ def test_extract_returns_error_response_unchanged_no_raise(self, mock_request):
+ mock_request.return_value = _fake_response(500, b"upstream failure")
+
+ response = self.client.extract(url)
+
+ self.assertEqual(response.status_code, 500)
+ self.assertEqual(response.content, b"upstream failure")
+
+ @mock.patch.object(Session, "request")
+ def test_get_and_fetch_produce_byte_identical_request_calls(self, mock_request):
+ """get() is documented as a deprecated alias for fetch() — assert they
+ are actually identical calls, not just "both work"."""
+ mock_request.return_value = _fake_response(200)
+
+ self.client.get(url, params={"js_render": True}, headers={"X-Test": "1"})
+ get_call = mock_request.call_args
+ mock_request.reset_mock()
+
+ self.client.fetch(url, params={"js_render": True}, headers={"X-Test": "1"})
+ fetch_call = mock_request.call_args
+
+ self.assertEqual(get_call, fetch_call)
+
+ @mock.patch.object(Session, "request")
+ def test_extract_accepts_standard_mode(self, mock_request):
+ mock_request.return_value = _fake_response(200)
+
+ self.client.extract(url, mode="standard")
+
+ _, kwargs = mock_request.call_args
+ self.assertEqual(kwargs["params"]["extract"], "standard")
+
+ @mock.patch.object(Session, "request")
+ def test_extract_does_not_validate_mode_value(self, mock_request):
+ """There is no validation on `mode` in extract() — any string is passed
+ straight through as the `extract` query param. This test documents that
+ as current, intentional-until-decided behavior (server-side validates
+ instead); if that ever changes, this test should be the one that fails.
+ """
+ mock_request.return_value = _fake_response(200)
+
+ self.client.extract(url, mode="not-a-real-mode")
+
+ _, kwargs = mock_request.call_args
+ self.assertEqual(kwargs["params"]["extract"], "not-a-real-mode")
+
+ @mock.patch.object(Session, "request")
+ def test_extract_merges_extract_param_with_other_params(self, mock_request):
+ mock_request.return_value = _fake_response(200)
+
+ self.client.extract(url, params={"js_render": True}, mode="native")
+
+ _, kwargs = mock_request.call_args
+ self.assertEqual(
+ kwargs["params"],
+ {
+ "url": url,
+ "apikey": apikey,
+ "js_render": True,
+ "extract": "native",
+ "mode": "auto",
+ },
+ )
+
+ @mock.patch.object(Session, "request")
+ def test_extract_does_not_mutate_caller_supplied_params_dict(self, mock_request):
+ """extract() copies `params` before adding `extract` — the caller's dict
+ must come back untouched, otherwise a caller reusing a params dict
+ across calls would leak `extract` into an unrelated fetch()."""
+ mock_request.return_value = _fake_response(200)
+ caller_params = {"js_render": True}
+
+ self.client.extract(url, params=caller_params, mode="native")
+
+ self.assertEqual(caller_params, {"js_render": True})
+
+
+class TestExtractAutoparseFallback(TestCase):
+ """`extract(mode="auto")` is a domain-gated open beta: AUTH010 means the
+ target domain isn't enabled yet. By default this retries once with
+ Autoparse instead of raising - same behavior as the CLI's extract
+ adapter."""
+
+ def setUp(self):
+ self.client = ZenRowsClient(apikey)
+
+ @mock.patch.object(Session, "request")
+ def test_falls_back_to_autoparse_on_auth010(self, mock_request):
+ mock_request.side_effect = [
+ _fake_response(402, b'{"code": "AUTH010", "title": "Domain not enabled"}'),
+ _fake_response(200, b'[{"found": "via autoparse"}]'),
+ ]
+
+ response = self.client.extract(url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(mock_request.call_count, 2)
+ _, fallback_kwargs = mock_request.call_args
+ self.assertTrue(fallback_kwargs["params"].get("autoparse"))
+ self.assertNotIn("extract", fallback_kwargs["params"])
+
+ @mock.patch.object(Session, "request")
+ def test_fallback_disabled_returns_error_response(self, mock_request):
+ mock_request.return_value = _fake_response(402, b'{"code": "AUTH010"}')
+
+ response = self.client.extract(url, fallback_to_autoparse=False)
+
+ self.assertEqual(response.status_code, 402)
+ self.assertEqual(mock_request.call_count, 1)
+
+ @mock.patch.object(Session, "request")
+ def test_402_without_auth010_does_not_fall_back(self, mock_request):
+ """A real credits-exhausted 402 (e.g. AUTH004) must not be mistaken
+ for the domain-gating error."""
+ mock_request.return_value = _fake_response(
+ 402, b'{"code": "AUTH004", "title": "No credit available"}'
+ )
+
+ response = self.client.extract(url)
+
+ self.assertEqual(response.status_code, 402)
+ self.assertEqual(mock_request.call_count, 1)
+
+ @mock.patch.object(Session, "request")
+ def test_no_fallback_for_non_auto_mode(self, mock_request):
+ """AUTH010 shouldn't trigger a fallback for native/standard modes -
+ only "auto" is the domain-gated beta path."""
+ mock_request.return_value = _fake_response(402, b'{"code": "AUTH010"}')
+
+ response = self.client.extract(url, mode="native")
+
+ self.assertEqual(response.status_code, 402)
+ self.assertEqual(mock_request.call_count, 1)
+
+ @mock.patch.object(Session, "request")
+ def test_fallback_does_not_mutate_caller_supplied_params_dict(self, mock_request):
+ mock_request.side_effect = [
+ _fake_response(402, b'{"code": "AUTH010"}'),
+ _fake_response(200, b"{}"),
+ ]
+ caller_params = {"js_render": True}
+
+ self.client.extract(url, params=caller_params)
+
+ self.assertEqual(caller_params, {"js_render": True})
+
+
+class TestExtractAdaptiveStealth(TestCase):
+ """extract() sends Adaptive Stealth Mode (mode="auto" at the wire level) by
+ default, so targets needing js_render/premium_proxy (e.g. Zoopla) escalate
+ automatically instead of failing with REQS002."""
+
+ def setUp(self):
+ self.client = ZenRowsClient(apikey)
+
+ @mock.patch.object(Session, "request")
+ def test_sends_adaptive_stealth_by_default(self, mock_request):
+ mock_request.return_value = _fake_response(200)
+
+ self.client.extract(url)
+
+ _, kwargs = mock_request.call_args
+ self.assertEqual(kwargs["params"]["mode"], "auto")
+
+ @mock.patch.object(Session, "request")
+ def test_omits_wire_mode_when_disabled(self, mock_request):
+ mock_request.return_value = _fake_response(200)
+
+ self.client.extract(url, adaptive_stealth=False)
+
+ _, kwargs = mock_request.call_args
+ self.assertNotIn("mode", kwargs["params"])
+
+ @mock.patch.object(Session, "request")
+ def test_fallback_request_also_carries_adaptive_stealth(self, mock_request):
+ mock_request.side_effect = [
+ _fake_response(402, b'{"code": "AUTH010"}'),
+ _fake_response(200, b"{}"),
+ ]
+
+ self.client.extract(url)
+
+ _, fallback_kwargs = mock_request.call_args
+ self.assertEqual(fallback_kwargs["params"]["mode"], "auto")
+
+
+class TestExtractAsyncAutoparseFallback(IsolatedAsyncioTestCase):
+ """Async counterpart - same AUTH010 -> Autoparse behavior."""
+
+ def setUp(self):
+ self.client = ZenRowsClient(apikey, concurrency=2)
+
+ @mock.patch.object(Session, "request")
+ async def test_falls_back_to_autoparse_on_auth010(self, mock_request):
+ mock_request.side_effect = [
+ _fake_response(402, b'{"code": "AUTH010"}'),
+ _fake_response(200, b'[{"found": "via autoparse"}]'),
+ ]
+
+ response = await self.client.extract_async(url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(mock_request.call_count, 2)
+
+
+class TestFetchExtractAsync(IsolatedAsyncioTestCase):
+ def setUp(self):
+ self.client = ZenRowsClient(apikey, concurrency=2)
+
+ @mock.patch.object(Session, "request")
+ async def test_fetch_async_returns_error_response_unchanged(self, mock_request):
+ mock_request.return_value = _fake_response(429, b"rate limited")
+
+ response = await self.client.fetch_async(url)
+
+ self.assertEqual(response.status_code, 429)
+ self.assertEqual(response.content, b"rate limited")
+
+ @mock.patch.object(Session, "request")
+ async def test_extract_async_sets_mode(self, mock_request):
+ mock_request.return_value = _fake_response(200)
+
+ await self.client.extract_async(url, mode="native")
+
+ _, kwargs = mock_request.call_args
+ self.assertEqual(kwargs["params"]["extract"], "native")
+
+ @mock.patch.object(Session, "request")
+ async def test_get_async_and_fetch_async_produce_identical_calls(self, mock_request):
+ mock_request.return_value = _fake_response(200)
+
+ await self.client.get_async(url, params={"premium_proxy": True})
+ get_call = mock_request.call_args
+ mock_request.reset_mock()
+
+ await self.client.fetch_async(url, params={"premium_proxy": True})
+ fetch_call = mock_request.call_args
+
+ self.assertEqual(get_call, fetch_call)
+
+
+class TestClientConstructionAndLifecycle(TestCase):
+ """Covers the constructor validation and close()/context-manager surface
+ that every fetch()/extract() call depends on but that nothing exercised."""
+
+ def test_empty_apikey_raises(self):
+ with self.assertRaises(ValueError):
+ ZenRowsClient("")
+
+ def test_none_apikey_raises(self):
+ with self.assertRaises(ValueError):
+ ZenRowsClient(None) # type: ignore[arg-type]
+
+ def test_base_url_override_via_constructor(self):
+ client = ZenRowsClient(apikey, base_url="https://custom.example/v1/")
+ self.assertEqual(client.api_url, "https://custom.example/v1/")
+
+ @mock.patch.dict("os.environ", {"ZENROWS_SCRAPER_BASE_URL": "https://env.example/v1/"})
+ def test_base_url_override_via_env_var(self):
+ client = ZenRowsClient(apikey)
+ self.assertEqual(client.api_url, "https://env.example/v1/")
+
+ def test_constructor_arg_wins_over_env_var(self):
+ with mock.patch.dict("os.environ", {"ZENROWS_SCRAPER_BASE_URL": "https://env.example/v1/"}):
+ client = ZenRowsClient(apikey, base_url="https://explicit.example/v1/")
+ self.assertEqual(client.api_url, "https://explicit.example/v1/")
+
+ def test_close_shuts_down_session_and_executor(self):
+ client = ZenRowsClient(apikey)
+
+ with (
+ mock.patch.object(client.requests_session, "close") as mock_close,
+ mock.patch.object(client.executor, "shutdown") as mock_shutdown,
+ ):
+ client.close()
+
+ mock_close.assert_called_once()
+ mock_shutdown.assert_called_once_with(wait=True)
+
+ def test_context_manager_calls_close_on_exit(self):
+ with mock.patch.object(ZenRowsClient, "close") as mock_close:
+ with ZenRowsClient(apikey) as client:
+ self.assertIsInstance(client, ZenRowsClient)
+ mock_close.assert_called_once()
+
+ def test_context_manager_calls_close_even_on_exception(self):
+ with mock.patch.object(ZenRowsClient, "close") as mock_close:
+ with self.assertRaises(RuntimeError), ZenRowsClient(apikey):
+ raise RuntimeError("boom")
+ mock_close.assert_called_once()
diff --git a/tests/test_retries.py b/tests/test_retries.py
index b06823f..3538238 100644
--- a/tests/test_retries.py
+++ b/tests/test_retries.py
@@ -1,9 +1,10 @@
from unittest import TestCase
from unittest.mock import patch
+
from requests import Session
-from urllib3.util.retry import Retry
from zenrows import ZenRowsClient
+from zenrows.client import _RETRY_STATUSES
apikey = "APIKEY"
url = "http://example.com"
@@ -11,7 +12,7 @@
class TestZenRowsClientRetries(TestCase):
- @patch.object(Retry, "new")
+ @patch("zenrows.client.Retry")
@patch.object(Session, "mount")
def test_custom_session_not_initiated(self, mock_mount, mock_retry):
ZenRowsClient(apikey, retries=0)
@@ -19,14 +20,14 @@ def test_custom_session_not_initiated(self, mock_mount, mock_retry):
mock_retry.assert_not_called()
self.assertEqual(mock_mount.call_count, 2) # called internally
- @patch.object(Retry, "new")
+ @patch("zenrows.client.Retry")
def test_retry_parameters(self, mock_retry):
ZenRowsClient(apikey, retries=2)
mock_retry.assert_called_once_with(
total=2,
backoff_factor=0.5,
- status_forcelist=[422, 429, 500, 502, 503, 504],
+ status_forcelist=list(_RETRY_STATUSES),
raise_on_status=False,
)
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..a0991bf
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,1012 @@
+version = 1
+revision = 2
+requires-python = ">=3.10"
+resolution-markers = [
+ "python_full_version >= '3.14'",
+ "python_full_version < '3.14'",
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
+]
+
+[[package]]
+name = "argcomplete"
+version = "3.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" },
+]
+
+[[package]]
+name = "backports-asyncio-runner"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
+]
+
+[[package]]
+name = "black"
+version = "26.5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "mypy-extensions" },
+ { name = "packaging" },
+ { name = "pathspec" },
+ { name = "platformdirs" },
+ { name = "pytokens" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" },
+ { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" },
+ { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" },
+ { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" },
+ { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" },
+ { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" },
+ { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" },
+ { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" },
+ { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" },
+ { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" },
+ { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" },
+ { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.5.20"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" },
+ { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" },
+ { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" },
+ { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" },
+ { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" },
+ { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" },
+ { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" },
+ { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" },
+ { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" },
+ { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" },
+ { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },
+ { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },
+ { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },
+ { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },
+ { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },
+ { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
+ { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
+ { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
+ { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
+ { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
+ { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
+ { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
+ { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
+ { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
+ { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
+ { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
+ { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
+ { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
+ { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
+ { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
+ { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
+ { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
+ { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
+ { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
+ { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.4.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "datamodel-code-generator"
+version = "0.58.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "argcomplete" },
+ { name = "black" },
+ { name = "genson" },
+ { name = "inflect" },
+ { name = "isort" },
+ { name = "jinja2" },
+ { name = "pydantic" },
+ { name = "pyyaml" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d4/7f/0e838be05fb64a088730bf5a3a510f4260e43ea9fbcea8b86d97aa31ca80/datamodel_code_generator-0.58.0.tar.gz", hash = "sha256:14b157b26ca85b8dfc2fdf2ada242f003937a130375892003067a4fdf046021c", size = 986860, upload-time = "2026-05-25T03:23:53.113Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/f6/992262b2f7845885899a6b57ff81f97f8506d443a20b7244ee8ea85247d8/datamodel_code_generator-0.58.0-py3-none-any.whl", hash = "sha256:73c4feb12bf773e280eb721b7968a5d5dbb953be2a700bf70995ba442abd0a57", size = 274532, upload-time = "2026-05-25T03:23:51.027Z" },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
+]
+
+[[package]]
+name = "genson"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c5/cf/2303c8ad276dcf5ee2ad6cf69c4338fd86ef0f471a5207b069adf7a393cf/genson-1.3.0.tar.gz", hash = "sha256:e02db9ac2e3fd29e65b5286f7135762e2cd8a986537c075b06fc5f1517308e37", size = 34919, upload-time = "2024-05-15T22:08:49.123Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/5c/e226de133afd8bb267ec27eead9ae3d784b95b39a287ed404caab39a5f50/genson-1.3.0-py3-none-any.whl", hash = "sha256:468feccd00274cc7e4c09e84b08704270ba8d95232aa280f65b986139cec67f7", size = 21470, upload-time = "2024-05-15T22:08:47.056Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.16"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" },
+]
+
+[[package]]
+name = "inflect"
+version = "7.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "more-itertools" },
+ { name = "typeguard" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197, upload-time = "2024-12-28T17:11:15.931Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "isort"
+version = "8.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
+ { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
+ { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
+ { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
+ { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
+ { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
+ { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
+ { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
+ { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
+ { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "more-itertools"
+version = "11.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pathspec"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" },
+ { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" },
+ { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" },
+ { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" },
+ { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" },
+ { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
+ { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
+ { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
+ { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
+ { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
+ { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
+ { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
+ { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
+ { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
+ { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
+ { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
+ { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
+ { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
+ { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
+ { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
+ { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
+ { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
+ { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
+ { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
+ { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
+ { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
+ { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
+ { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
+ { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
+ { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
+ { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
+ { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
+ { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
+ { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
+ { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
+ { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
+ { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
+ { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
+ { name = "pytest" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
+]
+
+[[package]]
+name = "pytest-httpx"
+version = "0.36.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4e/42/f53c58570e80d503ade9dd42ce57f2915d14bcbe25f6308138143950d1d6/pytest_httpx-0.36.2.tar.gz", hash = "sha256:05a56527484f7f4e8c856419ea379b8dc359c36801c4992fdb330f294c690356", size = 57683, upload-time = "2026-04-09T13:57:19.837Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/55/1fa65f8e4fceb19dd6daa867c162ad845d547f6058cd92b4b02384a44777/pytest_httpx-0.36.2-py3-none-any.whl", hash = "sha256:d42ebd5679442dc7bfb0c48e0767b6562e9bc4534d805127b0084171886a5e22", size = 20315, upload-time = "2026-04-09T13:57:18.587Z" },
+]
+
+[[package]]
+name = "pytokens"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" },
+ { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" },
+ { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" },
+ { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" },
+ { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" },
+ { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" },
+ { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" },
+ { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" },
+ { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" },
+ { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" },
+ { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" },
+ { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" },
+ { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" },
+ { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" },
+ { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
+ { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
+ { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+]
+
+[[package]]
+name = "respx"
+version = "0.23.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.15"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
+ { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
+ { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
+ { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
+ { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
+ { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
+ { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
+ { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
+ { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
+ { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
+ { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
+ { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
+ { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
+ { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
+ { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
+ { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
+ { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
+ { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
+ { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
+ { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
+ { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
+ { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
+ { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
+ { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
+ { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
+ { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
+ { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
+ { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
+ { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
+ { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.67.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
+]
+
+[[package]]
+name = "ty"
+version = "0.0.46"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/7d/d95b5a9dea83472006be3ce5e480028c44b34138d84d0172e910f287fb69/ty-0.0.46.tar.gz", hash = "sha256:c6c2d7105b5633b49950b4c3a90d1ed2613eb9d794ad582bbbf6c4ffcb93accf", size = 5832380, upload-time = "2026-06-09T03:28:05.056Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/24/f9f7533c391610521f4164e6b8e37ef72d0c1ee8651bc0d9ce9e658b953b/ty-0.0.46-py3-none-linux_armv6l.whl", hash = "sha256:5e716337994699cbc1a1a7b7a3e6622306f2574c710330f9d9691c2c3d8391b0", size = 11756264, upload-time = "2026-06-09T03:28:20.112Z" },
+ { url = "https://files.pythonhosted.org/packages/66/49/ff3d13655b9b5cc8176f4c3446bf7ec2df43c8ad9e5272d4adc5d952fa45/ty-0.0.46-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:51d618dec5403635690d0e3e298cd0ad3d84ebc6a576652939ef30ce96fce4b2", size = 11492723, upload-time = "2026-06-09T03:28:13.23Z" },
+ { url = "https://files.pythonhosted.org/packages/82/4a/e7e3209e353c5835c7756339bbcdfda10852407b80fbb9ed46c17241873a/ty-0.0.46-py3-none-macosx_11_0_arm64.whl", hash = "sha256:acbafd6a2351b07a6cf4c945b0b1d47f6d2826faac2526a351dfa74d3a3cc664", size = 10892822, upload-time = "2026-06-09T03:27:51.179Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/20/4390c90434a9ddefcecb65e8df00e4c2700e9739dc0baf58bed36d25f713/ty-0.0.46-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de5df602ffd760612ae36602bbad69b0123ff6cffd92e62aa92b7709317d69e3", size = 11408745, upload-time = "2026-06-09T03:27:58.049Z" },
+ { url = "https://files.pythonhosted.org/packages/75/0c/f13a1bf9c6798530c773667095a6cf8f73ec9721db359423e7249bff7fbc/ty-0.0.46-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7abf5a10b30d8641faad90f6a19989daec941bb90261159e05cfeb04d2012046", size = 11544432, upload-time = "2026-06-09T03:27:53.519Z" },
+ { url = "https://files.pythonhosted.org/packages/56/69/eb3710c13dff846a0362df04fadd8a39b64ccc244c0d02ce5285ede8eae5/ty-0.0.46-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8770404139c6ccee2ce2fc226478cfa4100915133c876c257e52197b8b92051d", size = 12031228, upload-time = "2026-06-09T03:28:29.816Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/68/5f5db9c84c1d44acdc67281089b372d9d818ee68123a60c59c66187095e2/ty-0.0.46-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f960d5a6e4860076924d2b86891d9872c4a3daa4663fb416e640b22cf3dbf68e", size = 12596073, upload-time = "2026-06-09T03:28:25.204Z" },
+ { url = "https://files.pythonhosted.org/packages/14/be/cfd0bb272e6a1491f6de30c60da1f39c2b3c3524ec64a5c92b71365c9185/ty-0.0.46-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d9000a4a3ed08fc37e8a2ff0b801cde06e1c2af3bc053677744bb5a1b751030", size = 12284885, upload-time = "2026-06-09T03:28:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/3a/2cd541f6320f5d6f70a45725c4e1016efedd5545348bb23b47ffb3e4c724/ty-0.0.46-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1160e6dc86536109ab755f7142f36f4dda5333c8330cf230d61819494d27125", size = 12079480, upload-time = "2026-06-09T03:27:55.847Z" },
+ { url = "https://files.pythonhosted.org/packages/de/91/8e0075bc6568fb477e7ef4d805c67fa6902b692cb4419e0bf5ce3c04c5bc/ty-0.0.46-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b619c0efe007731f8221fa787701bfa4402da7a83eb26c61ae25e77b6ace6384", size = 12316547, upload-time = "2026-06-09T03:28:08.28Z" },
+ { url = "https://files.pythonhosted.org/packages/00/28/b96cbfeda019a4044c6a8cd06ff84d08b631d4ba7d9a1e6dc0311df3563a/ty-0.0.46-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ad98fccb6a8a94c4121b993761a0deee602f5826c4162e0a91f4f8118ddadd42", size = 11392846, upload-time = "2026-06-09T03:28:00.418Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/d0/4d77f699a95ac7a13b94ca1a58682667cfe974f91557d9e2a9fc0b808a7f/ty-0.0.46-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:74536b13c3cc3f5944408669c202d4c57c3d19ff154732df8e6145718aef9191", size = 11559017, upload-time = "2026-06-09T03:28:17.619Z" },
+ { url = "https://files.pythonhosted.org/packages/88/62/1d6f6b51c2b132da8011c6a41ead0c1fd2a0b17ea72304bcf6ce084d581a/ty-0.0.46-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5e50b1e96ced41b609e24ed27d9e4f508584ed7f4d0bb717ca8c8d75d2fd1b7c", size = 11666509, upload-time = "2026-06-09T03:28:22.454Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/9a/6643894bc12cb30c281f4c8bf37f6d30c1fbd9484ef39a12b0ea6dae3c1c/ty-0.0.46-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0a7d9f58d26d938e5d2f607481b7a412d8c00d675a1ec72004fa9d6b3b9def99", size = 12180448, upload-time = "2026-06-09T03:28:32.329Z" },
+ { url = "https://files.pythonhosted.org/packages/86/68/0f3b7bb03a7da676ef51b1c0af0bde1e500d69d5f0c807ed63b6f30b66dd/ty-0.0.46-py3-none-win32.whl", hash = "sha256:26db0ce89c573e60132d14e9688c9329a1633b1a8c26fe457025c7c406f7d5e6", size = 10960002, upload-time = "2026-06-09T03:28:02.832Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/f4/91ff618b2dee39d0633d23e1adac0174aa1de80df17e270acac534034dbc/ty-0.0.46-py3-none-win_amd64.whl", hash = "sha256:90e8e6d446b9cb7cb4bede9fca7b3c99fd1e2355605ecf431c131a51db2a5e93", size = 12097413, upload-time = "2026-06-09T03:28:27.495Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/2e/300174fca375a27a7c28dd80e990d857d7b3e3b25980c65063f980aa2f17/ty-0.0.46-py3-none-win_arm64.whl", hash = "sha256:ebd320d82605079b901a095dc4711037a0c488b4ace79a602fef4df0d3f4cf74", size = 11439595, upload-time = "2026-06-09T03:28:15.355Z" },
+]
+
+[[package]]
+name = "typeguard"
+version = "4.5.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/67/1c/dfba5c4633cafc4c701f237d2ba63b416805047fd6d96aab4cfc40969f98/typeguard-4.5.2.tar.gz", hash = "sha256:5a16dcac23502039299c97c8941651bc33d7ea8cc4b2f7d6bbb1b528f6eea423", size = 80240, upload-time = "2026-05-14T12:59:40.857Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl", hash = "sha256:fcf9de18bd945cdb4c7b996e12b4c51ce83f92f191314a6d7cf1739586ec98cf", size = 36748, upload-time = "2026-05-14T12:59:39.473Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
+]
+
+[[package]]
+name = "zenrows"
+version = "1.4.0"
+source = { editable = "." }
+dependencies = [
+ { name = "httpx" },
+ { name = "pydantic" },
+ { name = "requests" },
+ { name = "tqdm" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "datamodel-code-generator" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "pytest-httpx" },
+ { name = "respx" },
+ { name = "ruff" },
+ { name = "ty" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "httpx", specifier = ">=0.27" },
+ { name = "pydantic", specifier = ">=2.9" },
+ { name = "requests", specifier = ">=2.31" },
+ { name = "tqdm", specifier = ">=4.66" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "datamodel-code-generator", specifier = ">=0.26" },
+ { name = "pytest", specifier = ">=8" },
+ { name = "pytest-asyncio", specifier = ">=0.24" },
+ { name = "pytest-httpx", specifier = ">=0.30" },
+ { name = "respx", specifier = ">=0.21" },
+ { name = "ruff", specifier = ">=0.7" },
+ { name = "ty" },
+]
diff --git a/zenrows/__init__.py b/zenrows/__init__.py
deleted file mode 100644
index a445fd5..0000000
--- a/zenrows/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from zenrows.client import ZenRowsClient
-
-__all__ = ['ZenRowsClient']
diff --git a/zenrows/client.py b/zenrows/client.py
deleted file mode 100644
index 1be3001..0000000
--- a/zenrows/client.py
+++ /dev/null
@@ -1,92 +0,0 @@
-import requests
-import asyncio
-from requests.adapters import HTTPAdapter
-from urllib3.util.retry import Retry
-from concurrent.futures import ThreadPoolExecutor
-import urllib3
-from functools import partial
-
-from .__version__ import __version__
-
-
-class ZenRowsClient:
- api_url = "https://api.zenrows.com/v1/"
-
- def __init__(self, apikey: str, retries: int = 0, concurrency: int = 5):
- self.apikey = apikey
-
- self.executor = ThreadPoolExecutor(max_workers=concurrency)
-
- self.requests_session = requests.Session()
- if (retries > 0):
- max_retries = Retry().new(
- total=retries,
- backoff_factor=0.5,
- status_forcelist=[422, 429, 500, 502, 503, 504],
- raise_on_status=False,
- )
- adapter = HTTPAdapter(max_retries=max_retries)
- self.requests_session.mount("https://", adapter)
- self.requests_session.mount("http://", adapter)
-
- def get(
- self, url: str, params: dict = None, headers: dict = None, **kwargs
- ) -> requests.Response:
- return self._worker("GET", url, params, headers, **kwargs)
-
- async def get_async(
- self, url: str, params: dict = None, headers: dict = None, **kwargs
- ) -> requests.Response:
- loop = asyncio.get_event_loop()
- return await loop.run_in_executor(self.executor, partial(self._worker, "GET", url, params, headers, **kwargs))
-
- def post(
- self, url: str, params: dict = None, headers: dict = None, data: dict = None, **kwargs
- ) -> requests.Response:
- return self._worker("POST", url, params, headers, data, **kwargs)
-
- async def post_async(
- self, url: str, params: dict = None, headers: dict = None, data: dict = None, **kwargs
- ) -> requests.Response:
- loop = asyncio.get_event_loop()
- return await loop.run_in_executor(
- self.executor, partial(self._worker, "POST", url, params, headers, data, **kwargs)
- )
-
- def put(
- self, url: str, params: dict = None, headers: dict = None, data: dict = None, **kwargs
- ) -> requests.Response:
- return self._worker("PUT", url, params, headers, data, **kwargs)
-
- async def put_async(
- self, url: str, params: dict = None, headers: dict = None, data: dict = None, **kwargs
- ) -> requests.Response:
- loop = asyncio.get_event_loop()
- return await loop.run_in_executor(
- self.executor, partial(self._worker, "PUT", url, params, headers, data, **kwargs)
- )
-
- def _worker(
- self, method, url: str, params: dict = None, headers: dict = None, data: dict = None, **kwargs
- ):
- final_params = {}
- if params:
- final_params.update(params)
- final_params.update({"url": url, "apikey": self.apikey})
-
- final_headers = {"User-Agent": f"zenrows/{__version__} python"}
-
- if headers:
- final_params["custom_headers"] = True
-
- final_headers["Accept"] = None
- final_headers["Accept-Encoding"] = urllib3.util.SKIP_HEADER
- final_headers["Connection"] = None
- else:
- headers = {}
-
- final_headers.update(headers)
-
- return self.requests_session.request(
- method, self.api_url, params=final_params, headers=final_headers, data=data, **kwargs
- )