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..831e38c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,24 @@ # 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. + +SDK to access [ZenRows](https://www.zenrows.com/) APIs directly from Python. +ZenRows handles proxies rotation, headless browsers, and CAPTCHAs for you. + +This package ships two clients: + + - **`ZenRowsClient`** — the original synchronous scraping client. + One URL in, one HTML/JSON response out. Best for ad-hoc scraping. + - **`ZenRowsBatchClient`** — the async-job / Batch API client. Submit + thousands of URLs as one job, poll for results, optionally upload a + CSV of URLs in one call. Best for offline / bulk pipelines. + _(Private beta — [contact support](mailto:support@zenrows.com) for access.)_ ## Installation -Install the SDK with pip. ```bash pip install zenrows ``` -## Usage +## Quickstart — synchronous scraping (`ZenRowsClient`) Start using the API by [creating your API Key](https://www.zenrows.com/register?p=free). The SDK uses [requests](https://docs.python-requests.org/) for HTTP requests. The client's response will be a requests `Response`. @@ -130,6 +140,252 @@ async def main(): asyncio.run(main()) ``` +## Quickstart — Batch API (`ZenRowsBatchClient`) + +> **⚠️ Private beta.** The Batch API is currently in private beta and not +> yet generally available. If you'd like access, please +> [contact support](mailto:support@zenrows.com) to have it enabled for +> your account. + +For workflows where you have many URLs to scrape and don't want to +manage retries, concurrency, and pagination yourself, the Batch API +submits a *job* (a list of tasks), runs it asynchronously on ZenRows' +infrastructure, and lets you poll results when they're ready. + +```python +from zenrows import ZenRowsBatchClient + +client = ZenRowsBatchClient(api_key="YOUR-API-KEY") + +# 1. Submit a job — URLs as bare strings or dicts with per-task fields. +# submit_* returns a JobRef (no GET yet). +job = client.submit_regular( + [ + {"url": "https://example.com/a", "external_id": "order-1"}, + {"url": "https://example.com/b", "external_id": "order-2"}, + ], + zenrows_params={"js_render": "true", "premium_proxy": "true"}, +) + +# 2. Block until the current run is terminal; returns a RunHandle. +run = job.run.wait() +print(f"{run.stats.successful}/{run.stats.total} succeeded") + +# 3. Inspect failures — each failed task carries an RFC 7807 error. +for task in run.results(status="failed"): + code = task.error.code if task.error else "unknown" + print(f" {task.external_id or task.task_id} failed: {code}") + +# 4. Download every successful body to ./out — one file per task. +count = run.download_to_dir("./out") +print(f"downloaded {count} results to ./out/") +``` + +> Need full control over the request body? `client.submit_job({...})` accepts a raw dict (the +> wire shape) or a typed `SubmitJobRequest`, and returns the same `JobRef`. + +### Upload URLs from a CSV + +```python +file_input_id = client.upload_csv( + "leads.csv", + fields={"url": "Page URL", "external_id": "Lead Ref"}, + header=True, +) +job = client.submit_regular(file_input_id=file_input_id) +``` + +`upload_csv` allocates the slot, PUTs your file to the presigned URL, +and returns the `file_input_id` — one call instead of three. + +### Estimate cost before submitting + +Pricing is per successful request (base `1`, `js_render` `5`, +`premium_proxy` `10`, both `25`; `mode=auto` is dynamic `1–25`). +`client.estimate_cost` answers "if every URL succeeds once, what's the +charge?" — pass the **same body you'd submit**, and get back a credit +interval (`min == max` unless the job uses `mode=auto`) with a per-tier +breakdown. + +```python +est = client.estimate_cost( + { + "type": "regular", + "tasks": [{"url": "https://a"}, {"url": "https://b"}], + "zenrows_params": {"js_render": "true"}, + } +) +print(est) # "10 credits (2 tasks)" +print(est.format()) # per-tier breakdown table +``` + +> Estimation is computed client-side from the SDK's rate card today, so +> it costs no API call; it may move server-side in a future release, so +> it lives on the client (`client.estimate_cost`) to keep your call +> sites stable. + +After a run finishes, read the **actual** credits it used from +`run.stats.spend` (`{credits, cost}`). Only successful requests are +charged, so realized spend is ≤ the estimate. (Per-task `task.spend` is +indicative and often absent on successful rows — rely on the run-level +rollup for actuals.) + +### Retry only the failed tasks + +After a run finishes with some failures, `retry_failed()` starts a new +run that re-executes *only* the failed tasks — successes are inherited +verbatim, so you don't pay to re-scrape them. (Thin shortcut for +`rerun(status="failed")`.) Pass `include_pending=True` to also pick up +tasks that never started. + +```python +# If you still hold the `job` ref from submit, act on it directly: +run = job.retry_failed() + +# Or, when you only kept the id (a CLI arg, your DB, a webhook), +# `client.job(id)` acts on it with no GET: +run = client.job(job_id).retry_failed() + +run.wait() +``` + +### Download results + +The Batch API doesn't ship page bodies inline — `results()` gives you +metadata plus a presigned `result_url` per task. The SDK downloads bodies +straight from that URL (no extra API round-trip). Four ways, on the +current run (`job.run.*`) or any specific run +(`client.get_run(job_id, run_id=...)`): + +| You want… | Use | Where it lands | +|---|---|---| +| Every body, one file per task | `run.download_to_dir(dir)` | files on disk | +| Every body, kept in the program | `run.download_to_memory()` | `list[DownloadedResult]` | +| The whole run as a single artifact | `run.download_all_results("out.zip")` | one `.zip` | +| One task's body (inside a loop) | `run.download_task_to_file(task, path)` / `run.download_task_to_memory(task)` | file / `bytes` | + +**Bulk → disk.** Iterates results, fetches each body, writes one file +per task. Tunable parallelism, a progress bar, and safety caps that +raise `DownloadLimitExceeded` before a runaway job fills the disk: + +```python +run.download_to_dir( + "./out", + status="successful", # default; pass status=None for everything + use_external_id=True, # name files . vs . + concurrency=8, # parallel body fetches + progress=True, # tqdm bar (soft dep) + max_files=20_000, # caps that raise DownloadLimitExceeded + max_bytes_per_file=10 * 1024 * 1024, +) +``` + +**Bulk → memory.** Same iteration, no disk — each item carries +`task_id`, `external_id`, `content_type`, and `body: bytes`: + +```python +for r in run.download_to_memory(status="successful"): + process(r.body, r.content_type) # e.g. parse in-process, push to a queue +``` + +**Whole run → one zip.** A server-side export: kick off the zip, poll +until ready, stream it to disk in one call. Best when you want a single +artifact: + +```python +run.download_all_results("results.zip") +``` + +> The server-side zip is capped at **1 GiB** per run. For larger runs — +> or one file per task — use `download_to_dir`, which fetches bodies +> client-side with **no size limit** (slower; tune with `concurrency=`). + +**One task at a time.** When you're iterating `results()` and want only +some bodies (a Python-side filter the `status=` argument can't express), +download them individually — straight from each task's `result_url`: + +```python +for task in run.results(status="successful"): + if task.external_id in wanted: + run.download_task_to_file(task, f"./out/{task.external_id}.html") + body = run.download_task_to_memory(task) # or just the raw bytes +``` + +`download_task_to_file` also accepts an open binary file object, not +just a path. Every result's `result_url` is a plain presigned URL, so +you can equally GET it with your own HTTP client (no auth header) — it's +TTL-limited, so fetch it promptly. See examples 02 and 08. + +### Act on an id without a GET + +Already have a job or run id (from a webhook, a queue, your own DB)? +`client.job(id)` / `client.run(id, run_id)` mint a `JobRef` / `RunRef` with +**no network call** — lifecycle ops act on the id directly; call `.load()` +for a loaded handle when you actually want the data: + +```python +client.job(job_id).delete() # DELETE — no preceding GET +client.job(job_id).run.stop() # stop the current run +client.run(job_id, run_id).delete() # scrub one specific run +client.job(job_id).load().data.status # explicit GET → JobHandle +``` + +`get_job` / `get_run` remain the eager variants (a `client.job(id).load()` +in one call), for when you want the data up front. See example 10. + +### Scheduled jobs & webhooks + +Submit a recurring job with a typed schedule builder, then pause / +resume / re-schedule it via the `job.schedule` facet. (This is distinct +from `job.run.pause()`, which reversibly suspends the *current run's* +dispatcher — two different endpoints the API keeps separate.) + +```python +from zenrows.batch import Rate + +job = client.submit_scheduled(Rate(every=6, unit="hour"), ["https://example.com"]) +job.schedule.pause() # stop firing (schedule keeps ticking) +job.schedule.resume() # fire again +job.schedule.update(Rate(every=1, unit="day")) # replace the schedule +``` + +Register a completion webhook at submit time, or manage it later via +`PUT` / `DELETE /jobs/{id}/webhook`. Set `signature=True` to have each +delivery HMAC-signed (manage signing keys via the `/hmac/keys` +endpoints); it defaults to unsigned. Deliveries are at-least-once, so +dedup on the `X-ZenRows-Event-Id` header. + +```python +client.submit_regular( + ["https://example.com/a"], + webhook={"url": "https://hooks.example.com/zr", "signature": True}, +) +``` + +> No webhook receiver? Just poll for completion with `job.run.wait()` / +> `run.wait()`. + +### Error handling + +Every non-2xx surfaces as `BatchAPIError`; the `code` attribute carries +the stable code from the RFC 7807 body (`file_input_not_found`, +`idempotency_key_conflict`, etc.). + +```python +from zenrows.batch import BatchAPIError + +try: + client.get_job("does-not-exist") +except BatchAPIError as exc: + if exc.code == "not_found": + ... + raise +``` + +The full Batch surface (jobs, runs, tasks, results, content, history, +file_inputs, HMAC keys) is reachable via methods on `ZenRowsBatchClient`. +See `src/zenrows/batch/client.py` or `help(ZenRowsBatchClient)`. + ## Contributing Pull requests are welcome. For significant changes, please open an issue first to discuss what you would like to change. diff --git a/docs/batch-client-reference.md b/docs/batch-client-reference.md new file mode 100644 index 0000000..a988a37 --- /dev/null +++ b/docs/batch-client-reference.md @@ -0,0 +1,3051 @@ +# ZenRows Batch — Python SDK Reference + +_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 ...`)._ + +# Client + +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). + + + +## ZenRowsBatchClient Objects + +```python +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. + + + +#### \_\_init\_\_ + +```python +def __init__(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. + + + +#### submit\_job + +```python +def submit_job(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. + + + +#### submit\_regular + +```python +def submit_regular(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`. + + + +#### submit\_open + +```python +def submit_open(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). + + + +#### submit\_scheduled + +```python +def submit_scheduled(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`. + + + +#### get\_job + +```python +def get_job(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. + + + +#### job + +```python +def job(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 + + + +#### estimate\_cost + +```python +def estimate_cost(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. + + + +#### list\_jobs + +```python +def list_jobs(*, + 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`. + + + +#### iter\_jobs + +```python +def iter_jobs(*, + 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. + + + +#### get\_run + +```python +def get_run(job_id: str, *, run_id: str) -> RunHandle +``` + +`GET /jobs/{job_id}/runs/{run_id}` — returns a loaded +`RunHandle` with `.data` populated. + + + +#### run + +```python +def run(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 + + + +#### list\_runs + +```python +def list_runs(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()`. + + + +#### iter\_runs + +```python +def iter_runs(job_id: str, + *, + page_size: int | None = None) -> Iterator[RunHandle] +``` + +Auto-paginate runs of a job, yielding loaded `RunHandle`s. + + + +#### list\_results + +```python +def list_results(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. + + + +#### iter\_results + +```python +def iter_results(job_id: str, + *, + run_id: str | None = None, + status: str | None = None) -> Iterator[TaskResult] +``` + +Auto-paginate results, yielding `TaskResult` per row. + + + +#### wait\_for\_run + +```python +def wait_for_run(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`). + + + +#### create\_job\_input + +```python +def create_job_input( + body: CreateJobInputRequest | CreateJobInputDict +) -> CreateJobInputResponse +``` + +`POST /job_inputs` — allocate a CSV upload slot. Most +callers want the higher-level `upload_csv` instead. + + + +#### upload\_csv + +```python +def upload_csv(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(...)`. + + + +#### get\_job\_webhook + +```python +def get_job_webhook(job_id: str) -> WebhookConfig +``` + +`GET /jobs/{job_id}/webhook` — the job's current webhook +config. Raises `BatchAPIError` (404) when none is set. + + + +#### put\_job\_webhook + +```python +def put_job_webhook(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. + + + +#### delete\_job\_webhook + +```python +def delete_job_webhook(job_id: str) -> None +``` + +`DELETE /jobs/{job_id}/webhook` — clear the webhook config. +Idempotent: 204 whether or not one was set. + + + +#### test\_webhook + +```python +def test_webhook( + 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`). + + + +#### rotate\_hmac\_key + +```python +def rotate_hmac_key() -> HMACKeyCreated +``` + +Capture the returned `secret` HERE — it is not revealed again. + + + +#### start\_results\_export + +```python +def start_results_export(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. + + + +#### get\_results\_export + +```python +def get_results_export(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". + + + +#### wait\_for\_export + +```python +def wait_for_export(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. + + + +#### download\_all\_results + +```python +def download_all_results(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=`). + +# Job, run & export handles + +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. + + + +## RunRef Objects + +```python +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. + + + +#### load + +```python +def load() -> "RunHandle" +``` + +``GET /jobs/{id}/runs/{run_id}`` — fetch the run and return a +loaded :class:`RunHandle`. + + + +#### delete + +```python +def delete() -> None +``` + +``DELETE /jobs/{id}/runs/{run_id}`` — scrub one run only. + + + +#### download\_task\_to\_file + +```python +def download_task_to_file(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\_memory + +```python +def download_task_to_memory(task: TaskResult) -> bytes +``` + +Download one ``task``'s body straight from its presigned +``result_url`` and return the raw bytes. + + + +#### wait + +```python +def wait(*, + 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. + + + +#### start\_export + +```python +def start_export() -> "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. + + + +#### export + +```python +def export(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. + + + +#### download\_all\_results + +```python +def download_all_results(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=``). + + + +## RunHandle Objects + +```python +class RunHandle(RunRef) +``` + +A :class:`RunRef` plus a guaranteed, synchronous ``data`` snapshot. + + + +#### status + +```python +@property +def status() -> RunStatus +``` + +This run's status. Shortcut for ``self.data.status``. + + + +#### stats + +```python +@property +def stats() -> RunStats +``` + +This run's task rollup (``total``, ``successful``, ``failed``, +``spend``, …). Shortcut for ``self.data.stats``. + + + +## ExportRef Objects + +```python +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. + + + +#### load + +```python +def load() -> "ExportHandle" +``` + +``GET .../exports/{id}`` — fetch the export and return a +loaded :class:`ExportHandle`. + + + +#### wait + +```python +def wait(*, + 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. + + + +#### download\_to\_path + +```python +def download_to_path(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). + + + +## ExportHandle Objects + +```python +class ExportHandle(ExportRef) +``` + +An :class:`ExportRef` plus a guaranteed, synchronous ``data`` snapshot. + + + +#### status + +```python +@property +def status() -> ExportStatus +``` + +This export's status. Shortcut for ``self.data.status``. + + + +## CurrentRun Objects + +```python +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. + + + +#### load + +```python +def load() -> "RunHandle" +``` + +``GET /jobs/{id}`` → the latest run as a loaded +:class:`RunHandle`. + + + +#### pause + +```python +def pause() -> "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`. + + + +#### resume + +```python +def resume() -> "RunHandle" +``` + +``POST /jobs/{id}/resume`` — un-pause the current run (the +dispatcher resumes polling). Returns the fresh loaded handle. + + + +#### stop + +```python +def stop() -> "RunHandle" +``` + +``POST /jobs/{id}/stop`` — terminally stop the current run. +Returns the fresh loaded :class:`RunHandle`. + + + +#### cancel + +```python +def cancel() -> "RunHandle" +``` + +Alias for :meth:`stop`. + + + +#### wait + +```python +def wait(*, + 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. + + + +#### results + +```python +def results(*, status: str | None = None) -> Iterator[TaskResult] +``` + +Auto-paginate task results from the current run. + + + +#### task\_history + +```python +def task_history(task_id: str) -> TaskHistoryResponse +``` + +Current-run per-attempt event log for one task. + + + +#### download\_task\_to\_file + +```python +def download_task_to_file(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\_memory + +```python +def download_task_to_memory(task: TaskResult) -> bytes +``` + +Download one ``task``'s body straight from its presigned +``result_url`` and return the raw bytes. + + + +#### start\_export + +```python +def start_export() -> "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`. + + + +#### download\_all\_results + +```python +def download_all_results(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=``). + + + +## ScheduleControls Objects + +```python +class ScheduleControls() +``` + +Operations on a scheduled job's schedule, reached via +``job.schedule``. Scheduled jobs only — regular jobs 409. + + + +#### pause + +```python +def pause() -> "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. + + + +#### resume + +```python +def resume() -> "JobHandle" +``` + +``POST /jobs/{id}/schedule/state`` → ``active`` — re-enable +scheduled fires on a paused job. Idempotent; returns the fresh +loaded handle. + + + +#### update + +```python +def update(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. + + + +## JobRef Objects + +```python +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. + + + +#### run + +```python +@property +def run() -> CurrentRun +``` + +Operations on the **current run** — ``pause`` / ``resume`` / +``stop`` / ``wait`` / ``results`` / downloads / ``start_export``. + + + +#### schedule + +```python +@property +def schedule() -> ScheduleControls +``` + +Operations on the **schedule** — ``pause`` / ``resume`` / +``update`` (scheduled jobs only). + + + +#### status + +```python +@property +def status() -> 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``. + + + +#### accepted\_tasks + +```python +@property +def accepted_tasks() -> int | None +``` + +How many tasks landed at submit. Only known on refs returned +by a ``submit_*`` call; returns ``None`` otherwise. + + + +#### load + +```python +def load() -> "JobHandle" +``` + +``GET /jobs/{id}`` — fetch the full job and return a loaded +:class:`JobHandle` whose ``.data`` is ready. + + + +#### close + +```python +def close() -> "JobHandle" +``` + +``POST /jobs/{id}/close`` — lock the job (no more ``add_tasks``). +Returns the fresh loaded handle. + + + +#### delete + +```python +def delete() -> None +``` + +``DELETE /jobs/{id}`` — async hard delete. + + + +#### rerun + +```python +def rerun(*, + 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. + + + +#### retry\_failed + +```python +def retry_failed(*, + 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. + + + +#### add\_tasks + +```python +def add_tasks(body: AddTasksRequest | AddTasksDict) -> AddTasksResponse +``` + +``POST /jobs/{id}/tasks`` — append to the open initial run. + + + +#### runs + +```python +def runs(*, 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``. + + + +#### add\_file\_input + +```python +def add_file_input(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.) + + + +#### get\_webhook + +```python +def get_webhook() -> WebhookConfig +``` + +`GET /jobs/{id}/webhook` — this job's current webhook config. +Raises `BatchAPIError` (404) when none is set. + + + +#### set\_webhook + +```python +def set_webhook(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. + + + +#### delete\_webhook + +```python +def delete_webhook() -> None +``` + +`DELETE /jobs/{id}/webhook` — clear this job's webhook config. +Idempotent. + + + +#### wait\_for\_ingest + +```python +def wait_for_ingest(*, + 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)``. + + + +## JobHandle Objects + +```python +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`. + + + +#### status + +```python +@property +def status() -> JobStatus +``` + +This job's status. Shortcut for ``self.data.status``. + +# Waiters + +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. + + + +## WaiterTimeout Objects + +```python +class WaiterTimeout(TimeoutError) +``` + +Raised when a waiter's `timeout` elapsed before the target state. + + + +## WaiterError Objects + +```python +class WaiterError(RuntimeError) +``` + +Raised when the resource entered a `failure_states` value. + + + +#### poll\_until + +```python +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. + +# Downloads + +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). + + + +## DownloadedResult Objects + +```python +@dataclass(slots=True) +class DownloadedResult() +``` + +One row's worth of downloaded content held in memory. + + + +#### DEFAULT\_MAX\_BYTES\_PER\_FILE + +50 MiB + + + +#### DEFAULT\_MAX\_TOTAL\_BYTES\_IN\_MEMORY + +500 MiB + + + +## DownloadLimitExceeded Objects + +```python +class DownloadLimitExceeded(RuntimeError) +``` + +Raised when a download exceeds a configured cap. + + + +#### download\_to\_dir + +```python +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. + + + +#### download\_task\_to\_file + +```python +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. + + + +#### download\_task\_to\_memory + +```python +def download_task_to_memory(task: TaskResult) -> bytes +``` + +Download one task's body straight from its `result_url` and +return the raw bytes. + + + +#### download\_to\_memory + +```python +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. + +# Cost estimation + +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. + + + +#### ParamValue + +dict form: `custom_headers` map + + + +## Tier Objects + +```python +class Tier(str, Enum) +``` + +The pricing tier a task falls into. Exactly one per task. + + + +## TaskCost Objects + +```python +@dataclass(slots=True, frozen=True) +class TaskCost() +``` + +The credit interval for a single task. `min == max` for every +tier except `auto`. + + + +## CostLine Objects + +```python +@dataclass(slots=True, frozen=True) +class CostLine() +``` + +One row of a breakdown: all tasks sharing a tier, aggregated. + + + +## CostEstimate Objects + +```python +@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. + + + +#### exact + +```python +@property +def exact() -> bool +``` + +True when the charge is a single number (no auto tasks). + + + +#### auto\_tasks + +```python +@property +def auto_tasks() -> int +``` + +How many tasks use `mode=auto` — the only source of range. + + + +#### format + +```python +def format() -> str +``` + +Multi-line breakdown table, e.g.:: + +1000 tasks → 4800-6000 credits + 950 x base (1) = 950 + 50 x auto (1-25) = 50-1250 + +# Schedule builders + +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. + + + +## At Objects + +```python +@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. + + + +## Rate Objects + +```python +@dataclass +class Rate() +``` + +Interval-based fire policy — every N units, no alignment to +wall clock. Timezone is irrelevant and not accepted here. + + + +## Daily Objects + +```python +@dataclass +class Daily() +``` + +Fire every day. No knobs. + + + +## Weekly Objects + +```python +@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. + + + +## Monthly Objects + +```python +@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. + + + +## Calendar Objects + +```python +@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). + +# Errors + +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. + + + +## ProblemDetail Objects + +```python +@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. + + + +#### from\_response + +```python +@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. + + + +## BatchAPIError Objects + +```python +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. + +# Models + + + +## JobType Objects + +```python +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. + + + +## JobStatus Objects + +```python +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. + + + +## ScheduleState Objects + +```python +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`. + + + +## RunTrigger Objects + +```python +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). + + + +## RunStatus Objects + +```python +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. + + + +## ResultType Objects + +```python +class ResultType(Enum) +``` + +Body format of a successful task result; matches the job's `format` 1:1. + + + +## Format Objects + +```python +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. + + + +## Method Objects + +```python +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`. + + + +## TaskInput Objects + +```python +class TaskInput(BaseModel) +``` + + + +#### external\_id + +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 + +Must be http(s). Other schemes rejected at submit. + + + +#### method + +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 + +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 + +Per-task scraper params. Override the job-level +`zenrows_params` on key collision (task wins). + + + +## Status Objects + +```python +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. + + + +## AddTasksRequest Objects + +```python +class AddTasksRequest(BaseModel) +``` + + + +#### last\_batch + +Set true on the final batch. Closes the job (status → +`closed`) and marks the run's `last_batch_received`. + + + +## Spend Objects + +```python +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. + + + +## TaskSpend Objects + +```python +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. + + + +## RunStats Objects + +```python +class RunStats(BaseModel) +``` + + + +#### total + +Number of tasks in this run. + + + +#### completed + +successful + failed. + + + +#### failure\_reasons + +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 + +Indicative spend summed across every task attempt in +this run. Absent on runs whose tasks predate this +field (treat as zero). + + + +## PauseState Objects + +```python +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`. + + + +## IngestStatus Objects + +```python +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). + + + +## FailureReason Objects + +```python +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). + + + +## Run Objects + +```python +class Run(BaseModel) +``` + + + +#### last\_batch\_received + +Meaningful only for the initial run of an open job. +Once true, `addTasks` is rejected and the run drains +into `completed`. + + + +#### pause\_state + +Reversible-suspend flag, orthogonal to `status`. +Omitted from responses when `active` / absent (legacy +rows). Flip via `POST /jobs/{id}/pause` and +`/resume`. + + + +#### ingest\_status + +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). + + + +#### failure\_reason + +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). + + + +## ScheduleRate Objects + +```python +class ScheduleRate(BaseModel) +``` + +Interval-based fire policy — every N units. + + + +## ScheduleCadence Objects + +```python +class ScheduleCadence(BaseModel) +``` + +Picks which days the schedule fires on. Exactly one of +`daily`, `weekly`, `monthly` must be set. + + + +#### daily + +Fire every day. No knobs. + + + +## Method1 Objects + +```python +class Method1(Enum) +``` + +The task's HTTP method. Omitted for GET (the default). +The request `body` is intentionally not part of listing +responses. + + + +## WebhookConfig Objects + +```python +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 + +HTTPS only. Host must resolve via DNS within 1 second +(1+ A/AAAA record). + + + +#### signature + +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 Objects + +```python +class TestWebhookRequest(BaseModel) +``` + +Body for `POST /v1/webhook/test`. Same shape as the submit-time +`webhook` field; `signature` is optional and defaults `false`. + + + +#### url + +HTTPS only. Host must resolve via DNS within 1 second. +Identical validation to the submit/PUT webhook URL. + + + +#### signature + +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 Objects + +```python +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 + +`true` iff the receiver responded with a 2xx status. `false` +on non-2xx, timeout, or transport error. + + + +#### event\_id + +ULID of the synthetic event. Different on every call so +receivers' dedup tables don't suppress repeated tests. + + + +#### status\_code + +HTTP status returned by the receiver. Absent on timeout or +transport error (no response was received). + + + +#### error + +Human-readable reason when `delivered: false`. Same +vocabulary as real webhook deliveries +(`http_4xx:`, `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..849eba0 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,2225 @@ +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 the scraper API'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 the scraper API, + 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 + description: | + Every task accepted, duplicates included. The duplicate + count is on `latest_run.duplicate_tasks` rather than here: + a large (202) submission is answered before anything has + walked the task list, so a top-level field would be null on + exactly the submissions large enough to care. + 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 } + duplicate_tasks: + type: integer + default: 0 + description: | + Not `required`, so a client built against this spec keeps + parsing responses from a server that predates the field. + + Repeats **within this batch**, same rule as + `Run.duplicate_tasks`. Tasks already on the + run are not consulted: a URL sent in an earlier batch and + again in this one does not count. + 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' } + duplicate_tasks: + type: integer + nullable: true + description: | + How many of the tasks this run was materialised from repeat + a request an earlier task in the same list already makes. + **Advisory — nothing is deduplicated.** Every task is + scraped, charged and returned separately, and `stats.total` + counts them all: three identical tasks are + `total: 3, duplicate_tasks: 2`. + + Two tasks are the same request when `url`, the HTTP method, + the body, and the merged `zenrows_params` (job-level + overlaid with per-task) all match. `external_id` and + `metadata` are excluded — they don't change what is fetched + or what it costs. URLs are compared exactly: no trailing- + slash, query-order, or encoding normalisation. + + `Idempotency-Key` does not cover this. It deduplicates + whole submits, never URLs inside one. + + **`null` means not counted, which is different from `0`.** + A large (202) submission returns before anything has walked + the task list — counting there would put the cost on the + request path, which is exactly what large uploads can't + afford. The number lands with the `ingest_status: done` + latch, so poll `GET /jobs/{id}` for it. + + Tasks added later via `addTasks` do not move this: that + count is per-batch and is on the `addTasks` response. + 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 = "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/ZenRows/zenrows-python-sdk" +Issues = "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/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 12da50d..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -flake8==7.1.1 -wheel==0.45.0 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="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/ZenRows/zenrows-python-sdk", - project_urls={ - "Bug Tracker": "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/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..d50b5e7 --- /dev/null +++ b/src/zenrows/batch/_estimate.py @@ -0,0 +1,389 @@ +"""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. + +An estimate also reports `duplicate_tasks`: how many of the tasks +repeat a request an earlier task in the same list already makes. Batch +does **not** deduplicate — each one is a separate scrape, a separate +charge, and a separate result — and `Idempotency-Key` does not help +(it deduplicates whole submits, never URLs inside one). The server +returns the same count on the submit response; this computes it before +you spend anything. + +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. +""" + +import json +from collections.abc import Iterable +from dataclasses import dataclass, field +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, ...] + duplicate_tasks: int = field(default=0) + """How many tasks repeat a request an earlier task already makes. + Advisory: nothing is deduplicated, so these are counted in + `task_count` and priced into `min`/`max` like any other task. Three + identical tasks read as `task_count=3, duplicate_tasks=2`. The + submit response carries the same number.""" + + @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}") + if self.duplicate_tasks: + noun = "task" if self.duplicate_tasks == 1 else "tasks" + lines.append( + f" {self.duplicate_tasks:>6} duplicate {noun} — scraped and charged separately" + ) + 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 _task_url(task: TaskLike) -> str: + """Pull the URL from any accepted task shape. `AnyUrl` is rendered + the way the client will serialise it at submit, so the comparison + matches what the server receives.""" + if isinstance(task, str): + return task + if isinstance(task, TaskInput): + return str(task.url) + if isinstance(task, dict): + return str(task.get("url", "")) + raise TypeError(f"unsupported task type for estimation: {type(task).__name__}") + + +def _task_method(task: TaskLike) -> str: + """Canonical HTTP method. GET (and its spellings, and the default) + collapse to "" — matching the server, which stores GET as absent.""" + if isinstance(task, str): + return "" + raw: object = None + if isinstance(task, TaskInput): + raw = task.method + elif isinstance(task, dict): + raw = task.get("method") + value = getattr(raw, "value", raw) + method = str(value or "").strip().upper() + return "" if method == "GET" else method + + +def _task_body(task: TaskLike) -> object: + if isinstance(task, str): + return None + if isinstance(task, TaskInput): + return task.body + if isinstance(task, dict): + return task.get("body") + return None + + +def _scalar_key(value: ParamValue) -> str: + """Render a param value the way the server coerces it — booleans to + `"true"` / `"false"`, everything else to its string form — so + `{"js_render": True}` and `{"js_render": "true"}` are one request, + as they are at the wire.""" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, dict): + return json.dumps(value, sort_keys=True, separators=(",", ":")) + return str(value) + + +def _request_key(task: TaskLike, merged: ParamMap) -> tuple: + """What decides whether two tasks fetch the same thing: the URL, the + canonical method, the body, and the merged params. + + Deliberately excluded: `external_id` and `metadata`. They are + caller-side annotation — two tasks differing only there still fetch + the same bytes twice and are still billed twice, which is the point + of counting. + + URLs are compared exactly. No trailing-slash, query-order, or + encoding normalisation: those rules aren't settled, and guessing + would report duplicates the engine doesn't have. + + The body is compared by JSON value rather than by the bytes the + server will see, so two bodies differing only in key order count as + one request here. The server, comparing what arrived on the wire, + may read them as distinct — the only case where the two counts can + disagree, and it errs toward telling you about redundant work. + """ + body = _task_body(task) + body_key = ( + None + if body is None + else json.dumps(body, sort_keys=True, separators=(",", ":"), default=str) + ) + params_key = tuple(sorted((k, _scalar_key(v)) for k, v in merged.items())) + return (_task_url(task), _task_method(task), body_key, params_key) + + +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``. + + ``duplicate_tasks`` reports how many tasks repeat a request an + earlier task already makes. Batch does not deduplicate, so those + are priced in like any other task — the count is there to tell you + before the invoice does. + + 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 + duplicates = 0 + seen: set[tuple] = set() + for task in tasks: + count += 1 + merged = {**job_params, **_task_params(task)} + key = _request_key(task, merged) + if key in seen: + duplicates += 1 + else: + seen.add(key) + 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, + duplicate_tasks=duplicates, + ) + + +__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", "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/webhook/test", body=model), TestWebhookResponse + ) + + # ===== HMAC key lifecycle ===== + + def list_hmac_keys(self) -> HMACKeyList: + return _parse(self._t.request_json("GET", "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/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..3878cbf --- /dev/null +++ b/src/zenrows/batch/models.py @@ -0,0 +1,1202 @@ +# generated by datamodel-codegen: +# filename: openapi.yaml +# timestamp: 2026-09-25T09:22:21+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 + duplicate_tasks: int | None = None + """ + How many of the tasks this run was materialised from repeat + a request an earlier task in the same list already makes. + **Advisory — nothing is deduplicated.** Every task is + scraped, charged and returned separately, and `stats.total` + counts them all: three identical tasks are + `total: 3, duplicate_tasks: 2`. + + Two tasks are the same request when `url`, the HTTP method, + the body, and the merged `zenrows_params` (job-level + overlaid with per-task) all match. `external_id` and + `metadata` are excluded — they don't change what is fetched + or what it costs. URLs are compared exactly: no trailing- + slash, query-order, or encoding normalisation. + + `Idempotency-Key` does not cover this. It deduplicates + whole submits, never URLs inside one. + + **`null` means not counted, which is different from `0`.** + A large (202) submission returns before anything has walked + the task list — counting there would put the cost on the + request path, which is exactly what large uploads can't + afford. The number lands with the `ingest_status: done` + latch, so poll `GET /jobs/{id}` for it. + + Tasks added later via `addTasks` do not move this: that + count is per-batch and is on the `addTasks` response. + + """ + 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 + """ + Every task accepted, duplicates included. The duplicate + count is on `latest_run.duplicate_tasks` rather than here: + a large (202) submission is answered before anything has + walked the task list, so a top-level field would be null on + exactly the submissions large enough to care. + + """ + 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 + duplicate_tasks: int | None = 0 + """ + Not `required`, so a client built against this spec keeps + parsing responses from a server that predates the field. + + Repeats **within this batch**, same rule as + `Run.duplicate_tasks`. Tasks already on the + run are not consulted: a URL sent in an earlier batch and + again in this one does not count. + + """ + 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..bb2d329 --- /dev/null +++ b/src/zenrows/client.py @@ -0,0 +1,203 @@ +"""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] + + +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 get( + self, + url: str, + params: dict | None = None, + headers: dict | None = None, + **kwargs: Any, + ) -> requests.Response: + return self._worker("GET", url, params, headers, **kwargs) + + 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 get_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 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..ab81225 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,35 @@ 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_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 +62,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 +83,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 +101,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..b5f20fe --- /dev/null +++ b/tests/test_estimate.py @@ -0,0 +1,301 @@ +"""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) + + +# ----- duplicate detection (ENG-286) ----- +# +# Batch does not deduplicate: identical URLs in one job are separate +# scrapes, separate charges, separate results. The estimate counts them +# so the caller sees it before the invoice does. + + +def test_no_duplicates_in_a_clean_list(): + est = estimate_cost(["https://a.example.com", "https://b.example.com"]) + assert est.duplicate_tasks == 0 + + +def test_repeated_url_counts_as_redundant(): + est = estimate_cost(["https://a.example.com"] * 3) + # All three are priced — nothing is collapsed. + assert est.task_count == 3 + assert est.min == 3 + assert est.duplicate_tasks == 2 + + +def test_external_id_and_metadata_do_not_distinguish_tasks(): + est = estimate_cost( + [ + {"url": "https://a.example.com", "external_id": "row-1"}, + {"url": "https://a.example.com", "external_id": "row-2", "metadata": {"k": "v"}}, + ] + ) + assert est.duplicate_tasks == 1 + + +def test_urls_are_compared_exactly(): + est = estimate_cost( + [ + "https://a.example.com/p", + "https://a.example.com/p/", + "https://a.example.com/p?x=1&y=2", + "https://a.example.com/p?y=2&x=1", + ] + ) + assert est.duplicate_tasks == 0 + + +def test_differing_task_params_keep_tasks_distinct(): + est = estimate_cost( + [ + {"url": "https://a.example.com", "zenrows_params": {"proxy_country": "us"}}, + {"url": "https://a.example.com", "zenrows_params": {"proxy_country": "de"}}, + ] + ) + assert est.duplicate_tasks == 0 + + +def test_task_restating_the_job_default_duplicates_one_that_inherits_it(): + est = estimate_cost( + [ + "https://a.example.com", + {"url": "https://a.example.com", "zenrows_params": {"js_render": True}}, + ], + zenrows_params={"js_render": "true"}, + ) + # Booleans and their string spellings are one value at the wire. + assert est.duplicate_tasks == 1 + + +def test_task_overriding_the_job_default_does_not(): + est = estimate_cost( + [ + "https://a.example.com", + {"url": "https://a.example.com", "zenrows_params": {"js_render": False}}, + ], + zenrows_params={"js_render": "true"}, + ) + assert est.duplicate_tasks == 0 + + +def test_post_body_distinguishes_tasks(): + same = estimate_cost( + [ + {"url": "https://a.example.com", "method": "POST", "body": {"q": "shoes"}}, + {"url": "https://a.example.com", "method": "POST", "body": {"q": "shoes"}}, + ] + ) + different = estimate_cost( + [ + {"url": "https://a.example.com", "method": "POST", "body": {"q": "shoes"}}, + {"url": "https://a.example.com", "method": "POST", "body": {"q": "boots"}}, + ] + ) + assert (same.duplicate_tasks, different.duplicate_tasks) == (1, 0) + + +def test_get_spellings_collapse_onto_the_default(): + est = estimate_cost( + [ + "https://a.example.com", + {"url": "https://a.example.com"}, + {"url": "https://a.example.com", "method": "get"}, + ] + ) + assert est.duplicate_tasks == 2 + + +def test_post_and_get_on_one_url_are_different_requests(): + est = estimate_cost( + [ + {"url": "https://a.example.com"}, + {"url": "https://a.example.com", "method": "POST", "body": {"q": "shoes"}}, + ] + ) + assert est.duplicate_tasks == 0 + + +def test_taskinput_models_are_deduplicated_too(): + est = estimate_cost( + [ + TaskInput(url="https://a.example.com", external_id="one"), + TaskInput(url="https://a.example.com", external_id="two"), + TaskInput(url="https://b.example.com"), + ] + ) + assert est.duplicate_tasks == 1 + + +def test_format_names_duplicates_only_when_present(): + clean = estimate_cost(["https://a.example.com"]) + assert "duplicate" not in clean.format() + + dirty = estimate_cost(["https://a.example.com"] * 2) + assert "1 duplicate task — scraped and charged separately" in dirty.format() + + +def test_client_estimate_cost_reports_duplicates(): + client = ZenRowsBatchClient(api_key="k") + est = client.estimate_cost( + {"tasks": [{"url": "https://a.example.com"}, {"url": "https://a.example.com"}]} + ) + assert isinstance(est, CostEstimate) + assert est.duplicate_tasks == 1 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 - )