diff --git a/.github/assets/logo/dark.svg b/.github/assets/logo/dark.svg new file mode 100644 index 0000000..b2541ff --- /dev/null +++ b/.github/assets/logo/dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/.github/assets/logo/light.svg b/.github/assets/logo/light.svg new file mode 100644 index 0000000..4555cf0 --- /dev/null +++ b/.github/assets/logo/light.svg @@ -0,0 +1,3 @@ + + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..06e9d94 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: + - main + - feature/batch-api/rc + +permissions: + contents: read + +jobs: + check: + name: "Lint, typecheck, and test" + runs-on: ubuntu-latest + steps: + - name: "Fetch source code" + uses: actions/checkout@v4 + + - name: "Install uv" + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: "Install dependencies" + run: uv sync --all-extras + + - name: "Lint, format check, and typecheck" + run: make check + + - name: "Unit tests" + run: make test diff --git a/.gitignore b/.gitignore index a21eff9..478db78 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,16 @@ pip-delete-this-directory.txt .idea/* .vscode/ + +# uv / venv +.venv/ +.uv-cache/ + +# Test + ruff caches +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Local scratch +.DS_Store diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c50b2ac..89c9b72 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,29 +1,80 @@ -## Development +# Development -Useful commands for development and publishing. +This project uses [uv](https://docs.astral.sh/uv/) for env + dependencies, +[ruff](https://docs.astral.sh/ruff/) for lint + format, and +[ty](https://docs.astral.sh/ty/) for static type checking. Source lives +under `src/zenrows/`; tests under `tests/`. -### Install +## First-time setup -`make install` will install the dependencies from the `requirements.txt` file. +```bash +uv sync --all-extras +``` -### Build +Creates `.venv/` and installs everything in `pyproject.toml`, including +dev tools. After that, prefix commands with `uv run …` or use the +Makefile targets below. -`make build` generates the distribution packages. It will not delete previous builds. Remember to change the `__version__` before publishing or it will fail. +## Layout -### Clean +``` +src/zenrows/ +├── __init__.py # re-exports both clients +├── client.py # ZenRowsClient (legacy sync scraper) +└── batch/ + ├── __init__.py # ZenRowsBatchClient + key models + ├── client.py # hand-written typed facade + ├── _transport.py # httpx wrapper, RFC 7807 → exceptions + ├── errors.py # BatchAPIError, ProblemDetail + └── models.py # GENERATED — pydantic v2 (do not edit) +``` -`make clean` removes previous builds and cache files. +The Batch SDK is split deliberately: -### Lint +| File | Owner | Regenerate? | +|----------------|-------------------|-------------------| +| `models.py` | datamodel-codegen | `make generate` | +| `client.py` | hand-written | never auto | +| `_transport.py`| hand-written | never auto | +| `errors.py` | hand-written | never auto | -`make lint` runs the linter (`flake8`) on the source and test files. +This way the wire types stay in lockstep with the OpenAPI document +while the ergonomic surface (method names, helpers, retries, URL +override) stays in our control. -### Test +## Common tasks -`make test` runs all the tests. +| Make target | What it does | +|-----------------|--------------| +| `make sync` | `uv sync --all-extras` | +| `make test` | `uv run pytest` | +| `make check` | `ty check` + `ruff check` + `ruff format --check` (CI mode) | +| `make typecheck`| `ty check src` (static types; `models.py` excluded) | +| `make lint` | `ruff check --fix` | +| `make format` | `ruff format` | +| `make generate` | Re-emit `src/zenrows/batch/models.py` from `docs/openapi.yaml` | +| `make build` | Build wheel + sdist via hatchling | +| `make clean` | Drop caches + build outputs | -### Upload to PyPI +## Refreshing the OpenAPI spec -`python -m twine upload dist/*` uploads the latest build to PyPI. It will upload the whole `dist` folder, failing if there was a previous version. Run the `clean` command on those cases. Upload attempts of existing versions will fail with a `File already exists` error. +`docs/openapi.yaml` is the SDK-local copy of the spec. `make generate` +reads it to emit the models. To refresh after a backend spec change: -For uploading to the test repository, use `python -m twine upload --repository testpypi dist/*`. The same restrictions apply. +1. Copy the updated spec into `docs/openapi.yaml`. +2. Run `make generate`. +3. Run `make check && make test`. +4. If the wire shape changed, update `src/zenrows/batch/client.py` + so the facade method signatures still typecheck. + +## Publishing + +```bash +make clean +make build # produces dist/*.whl + dist/*.tar.gz +uv run twine upload dist/* # or test PyPI: +uv run twine upload --repository testpypi dist/* +``` + +Bump `version` in `pyproject.toml` and `src/zenrows/__version__.py` +together before each release. diff --git a/Makefile b/Makefile index 571e388..ba8e32e 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,69 @@ -.PHONY: install build clean lint test +.PHONY: install sync test lint format typecheck check generate docs clean build -install: - pip install -r requirements.txt +# Bootstrap: install + dev deps, build the local venv. +install sync: + uv sync --all-extras -build: - python setup.py sdist bdist_wheel +# Run the suite. +test: + uv run pytest -clean: - python setup.py clean - rm -rf dist build zenrows.egg-info .pytest_cache - find . -name '__pycache__' -delete -o -name '*.pyc' -delete +# Static type check (ty — Astral). Shipped surface only; tests are +# covered by the suite. Generated models.py is excluded in pyproject. +typecheck: + uv run ty check src +# Lint + format + type check (CI mode — no fixes). +check: typecheck + uv run ruff check src/ tests/ + uv run ruff format --check src/ tests/ + +# Lint + format (writes fixes). lint: - flake8 --config flake8 setup.py tests zenrows + uv run ruff check --fix src/ tests/ +format: + uv run ruff format src/ tests/ -test: - python -m pytest tests +# Regenerate the pydantic v2 models from the backend's canonical spec. +# docs/openapi.yaml is the SDK-local copy of the spec; refresh it from the +# backend when the API changes. +# The HTTP client + facade are HAND-WRITTEN in src/zenrows/batch/client.py; +# only the type definitions come from this command. +generate: + uv run datamodel-codegen \ + --input docs/openapi.yaml \ + --input-file-type openapi \ + --output src/zenrows/batch/models.py \ + --output-model-type pydantic_v2.BaseModel \ + --target-python-version 3.10 \ + --use-schema-description \ + --use-field-description \ + --use-double-quotes \ + --field-constraints \ + --use-standard-collections \ + --use-union-operator \ + --enum-field-as-literal one \ + --collapse-root-models \ + --use-annotated \ + --capitalise-enum-members \ + --reuse-model \ + --use-default + +# Regenerate the markdown API reference (docs/batch-client-reference.md) from +# the SDK's docstrings via pydoc-markdown (ephemeral — no permanent dep). The +# builder relabels internal module headers to public section titles and strips +# the `zenrows.batch._x.` qualifiers, so the private `_module` layout never +# leaks into the customer-facing reference. See scripts/build_reference.py. +docs: + @mkdir -p docs + @uv run --with pydoc-markdown python scripts/build_reference.py > docs/batch-client-reference.md + @echo "wrote docs/batch-client-reference.md ($$(wc -l < docs/batch-client-reference.md) lines)" + +# Clean build artifacts + caches. +clean: + rm -rf dist build *.egg-info src/*.egg-info .pytest_cache .ruff_cache + find . -type d -name __pycache__ -prune -exec rm -rf {} + + +# Build wheel + sdist via hatchling. +build: + uv build diff --git a/README.md b/README.md index ee56bf0..a4694ec 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,46 @@ -# ZenRows Python SDK -SDK to access [ZenRows](https://www.zenrows.com/) API directly from Python. ZenRows handles proxies rotation, headless browsers, and CAPTCHAs for you. +

+ + + Zenrows Logo + +

+ +# Zenrows Python SDK + +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`. It also uses [Retry](https://urllib3.readthedocs.io/en/latest/reference/urllib3.util.html) to automatically retry failed requests (status codes 429, 500, 502, 503, and 504). Retries are not active by default; you need to specify the number of retries, as shown below. It already includes an exponential back-off retry delay between failed requests. +`client.fetch()` is the primary method for the main page-scraping product; `client.get()` still works and is kept as a deprecated alias. + ```python from zenrows import ZenRowsClient client = ZenRowsClient("YOUR-API-KEY", retries=1) url = "https://www.zenrows.com/" -response = client.get(url, params={ +response = client.fetch(url, params={ # Our algorithm allows to automatically extract content from any website "autoparse": False, @@ -107,7 +126,7 @@ print(response.text) To limit the concurrency, it uses [asyncio](https://docs.python.org/3/library/asyncio.html), which will simultaneously send a maximum of requests. The concurrency is determined by the plan you are in, so take a look at the [pricing](https://www.zenrows.com/pricing) and set it accordingly. Take into account that each client instance will have its own limit, meaning that two different scripts will not share it, and 429 (Too Many Requests) errors might arise. -The main difference with the sequential snippet above is `client.get_async` instead of `client.get`. The rest will work exactly the same, and we will support the `get` function. But the async is necessary to parallelize calls and allow async/await syntax. Remember to run the scripts with `asyncio.run` or it will fail with a `coroutine 'main' was never awaited` error. +The main difference with the sequential snippet above is `client.fetch_async` instead of `client.fetch`. The rest will work exactly the same. But the async is necessary to parallelize calls and allow async/await syntax. Remember to run the scripts with `asyncio.run` or it will fail with a `coroutine 'main' was never awaited` error. We use `asyncio.gather` in the example below. It will wait for all the calls to finish, and the results are stored in a `responses` array. The whole list of URLs will run, even if some fail. Then each response will have the status, request, response content, and other values as usual. @@ -122,7 +141,7 @@ async def main(): "https://www.zenrows.com/", # ... ] - responses = await asyncio.gather(*[client.get_async(url) for url in urls]) + responses = await asyncio.gather(*[client.fetch_async(url) for url in urls]) for response in responses: print(response.text) @@ -130,6 +149,267 @@ async def main(): asyncio.run(main()) ``` +### Extract + +[Extract](https://docs.zenrows.com) (beta) runs a page through Zenrows' AI-powered structured extraction instead of returning raw HTML. Use `client.extract()` — it's the same request as `fetch()`, with the `extract` param set for you (defaults to `"auto"`; pass `"native"` or `"standard"` for the other contracts). + +```python +from zenrows import ZenRowsClient + +client = ZenRowsClient("YOUR-API-KEY") +url = "https://www.zenrows.com/" + +response = client.extract(url) # extract: "auto" +# response = client.extract(url, mode="native") + +print(response.json()) +``` + +## Quickstart — Batch API (`ZenRowsBatchClient`) + +> **⚠️ Beta.** The Batch API is currently in beta and not yet generally +> available — free to use while it's in beta, but the shape of the API may +> still change before GA. + +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..c820a41 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,2177 @@ +openapi: 3.1.0 + +info: + title: ZenRows Batch API + summary: Asynchronous batch web-scraping API. + description: | + The ZenRows Batch API is an asynchronous batch web-scraping service. + Submit a job containing many URLs; the API scrapes each one, tracks + progress, and serves the results back to you. Errors are returned as + RFC 7807 Problem JSON. + + ### Model + A **Job** is a *template* — a reusable submission with its + configuration. A **Run** is one execution of that template; a job + has 1+ runs over its lifetime. A **Task** is one URL within one + run. The job carries a `latest_run` projection; the authoritative + state for execution lives on the run. + + This document is the public API specification. + version: "0.2.0" + +servers: + - url: https://async.api.zenrows.com/v1 + description: Production + +tags: + - name: jobs + description: Create, inspect, list, close, rerun, and delete scraping jobs. + - name: runs + description: Inspect runs of a job. Each run is one execution of the job's template. + - name: tasks + description: Add tasks to an open job's initial run; fetch per-task results and content. + - name: hmac + description: | + Per-org HMAC key lifecycle. A secret is returned to you exactly + once, at `/rotate`. These keys sign outbound webhooks when you + opt in via `webhook.signature: true` at submit or PUT, so your + receiver can verify each delivery. Supports phased rotation with + an active key and a staged candidate. + - name: job_inputs + description: | + Caller-uploaded CSV inputs for jobs. Two-step: create a slot + (presigned PUT URL + id), upload the CSV, then reference it via + `file_input_id` on /jobs. Unclaimed slots expire after 24h. + - name: webhook + description: | + Webhook config testing. The per-job webhook config lives under + the `jobs` tag — this tag is for the `POST /webhook/test` + endpoint, which lets you validate a receiver (reachability, TLS, + signature) before attaching it to a real job. + +security: + - apiKey: [] + +paths: + /jobs: + post: + tags: [jobs] + operationId: submitJob + summary: Submit a new scraping job + description: | + Validates inputs and authenticates the caller, then persists + the job, its first run, and (for small submits) all tasks + before returning. Validation failures (4xx) create nothing. A + `503` from a storage or queue failure usually creates nothing + either, but a late-stage failure can leave the job visible + with its run already `stopped` — safe to `DELETE`. Retry a + `503` with a **fresh** `Idempotency-Key`: a key claimed by a + failed attempt has no stored response and replays as `409`. + + Submissions under 10,000 inline `tasks` return `201 Created` + with the task rows already in place — results are immediately + queryable via `GET /v1/jobs/{id}/results`. + + Submissions at or above **10,000 inline tasks** are processed + off the request path and return `202 Accepted` — `accepted_tasks` + is the final validated count, but task rows stream into + storage over the following seconds (large submissions can take + longer). `GET /v1/jobs/{id}/results` returns only the rows + written so far until ingest completes; the run's `stats.total` + is correct from the start so completion polling + (`stats.completed >= stats.total`) is reliable from the first + response. While ingestion is in progress, + `POST /v1/jobs/{id}/tasks` on an open job returns `409` — + retry it once results pages fill out. The run's + `ingest_status` field reports the ingest state: poll + `GET /v1/jobs/{id}` until `latest_run.ingest_status` is + `done` to know every task row is visible. + + `type: scheduled` is reserved for a future feature and + currently returns `501 not_implemented`. + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/SubmitJobRequest' } + responses: + '201': + description: Job created (sync path). Run 1 is started and all task rows are written. + headers: + X-Request-ID: { $ref: '#/components/headers/RequestID' } + content: + application/json: + schema: { $ref: '#/components/schemas/SubmitJobResponse' } + '202': + description: | + Job accepted (large submission). The validated task list + is durable and `accepted_tasks` is the final count, but + individual task rows stream into storage off the request + path. `GET /v1/jobs/{id}/results` may return partial pages + until ingest completes; the run's + `stats.total` is correct from this response forward and + `stats.completed` climbs from 0, so the run reaches + `completed` only after every task drains — completion + polling is safe. `latest_run.ingest_status` is `pending` + in this response and flips to `done` on `GET /v1/jobs/{id}` + once every task row is visible. Body shape matches the + 201 response. + headers: + X-Request-ID: { $ref: '#/components/headers/RequestID' } + content: + application/json: + schema: { $ref: '#/components/schemas/SubmitJobResponse' } + '400': { $ref: '#/components/responses/InvalidArgument' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '402': { $ref: '#/components/responses/PaymentRequired' } + '409': { $ref: '#/components/responses/IdempotencyConflict' } + '501': { $ref: '#/components/responses/NotImplemented' } + '503': { $ref: '#/components/responses/Unavailable' } + + get: + tags: [jobs] + operationId: listJobs + summary: List jobs for the authenticated caller + description: | + Newest-first (ULID-ordered). Optional `status` / `type` filters. + parameters: + - in: query + name: status + schema: { $ref: '#/components/schemas/JobStatus' } + - in: query + name: type + schema: { $ref: '#/components/schemas/JobType' } + - in: query + name: limit + schema: { type: integer, minimum: 1, maximum: 100, default: 50 } + - in: query + name: cursor + description: Opaque; echo the previous response's `next_cursor` to continue. + schema: { type: string } + responses: + '200': + description: Page of jobs. + content: + application/json: + schema: { $ref: '#/components/schemas/ListJobsResponse' } + '401': { $ref: '#/components/responses/Unauthenticated' } + + /jobs/{job_id}: + parameters: + - $ref: '#/components/parameters/JobID' + get: + tags: [jobs] + operationId: getJob + summary: Get a job (template + latest_run projection) + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Job' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: [jobs] + operationId: deleteJob + summary: Delete a job and all its artifacts (asynchronous) + description: | + Flips the job to `deleted` immediately and **flips the latest + run to `deleted`** if it was in-flight (so no new tasks get + picked up), then returns 202. The job's stored result bodies + and all of its data are deleted asynchronously in the + background. Idempotent. + responses: + '202': + description: Accepted for deletion. + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + + /jobs/{job_id}/tasks: + parameters: + - $ref: '#/components/parameters/JobID' + post: + tags: [tasks] + operationId: addTasks + summary: Append tasks to an open job's initial run + description: | + Valid only when the job is `open` AND its latest run is + `run_sequence == 1` AND `last_batch_received == false`. + Targets the latest (initial) run. Closed/deleted jobs and + reruns reject further adds with 409. + + Setting `last_batch: true` closes the job (status → `closed`) + and signals the run to flip to `completed` once tasks drain. + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/AddTasksRequest' } + responses: + '200': + description: Tasks queued. + content: + application/json: + schema: { $ref: '#/components/schemas/AddTasksResponse' } + '400': { $ref: '#/components/responses/InvalidArgument' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '402': { $ref: '#/components/responses/PaymentRequired' } + '404': { $ref: '#/components/responses/NotFound' } + '409': + description: | + Job is not open, last batch already received, or the + initial run's task ingestion is still in progress (large + `202`-accepted submissions) — in the last case, retry + once ingest completes. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + + /jobs/{job_id}/close: + parameters: + - $ref: '#/components/parameters/JobID' + post: + tags: [jobs] + operationId: closeJob + summary: Close an open job (no more tasks coming) + description: | + Flips `job.status` from `open` to `closed` and sets the + initial run's `last_batch_received = true`. Same effect as + posting `addTasks` with `last_batch: true` and zero tasks. + + Does NOT stop the job — any pending or processing tasks + continue. If all known tasks are already terminal, the run + flips to `completed` immediately. + + Idempotent: calling on an already-closed job returns the + current state with 200. + responses: + '200': + description: Close accepted. Body is the refreshed Job object. + content: + application/json: + schema: { $ref: '#/components/schemas/Job' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + + /jobs/{job_id}/stop: + parameters: + - $ref: '#/components/parameters/JobID' + post: + tags: [jobs] + operationId: stopJob + summary: Stop the current run + description: | + Synchronously transitions `latest_run.status` from `running` + or `pending` to terminal `stopped`. No new tasks are picked + up; tasks already in flight may still finish and record their + result. Pending tasks are left as-is — not re-queued, not + synchronously failed. + + Result bodies are kept. To free the storage, follow up with + `DELETE /v1/jobs/{id}/runs/{run_id}`. + + Idempotent on `stopped`. Returns 409 on `completed` / + `deleted` (use `/rerun` to start fresh) and 404 on a deleted + job. + responses: + '200': + description: Run is stopped. Body is the refreshed Run object. + content: + application/json: + schema: { $ref: '#/components/schemas/Run' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + '409': + description: Latest run is not in a stoppable state. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + + /jobs/{job_id}/cancel: + parameters: + - $ref: '#/components/parameters/JobID' + post: + tags: [jobs] + operationId: cancelJob + summary: Cancel the current run + description: | + User-facing alias for `/stop`. Same semantics, same terminal + `stopped` state, same response shape. Provided so callers + using the noun "cancel" don't have to learn the internal name. + responses: + '200': + description: Run is stopped. Body is the refreshed Run object. + content: + application/json: + schema: { $ref: '#/components/schemas/Run' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + '409': + description: Latest run is not in a stoppable state. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + + /jobs/{job_id}/pause: + parameters: + - $ref: '#/components/parameters/JobID' + post: + tags: [jobs] + operationId: pauseJob + summary: Pause the current run + description: | + Reversible suspend of the latest run. Sets + `latest_run.pause_state = paused`; the dispatcher stops + polling this run's queue. The run's `status` + (`running` / `pending`) is unchanged — pause is orthogonal. + Already-in-flight tasks may still settle. + + Idempotent on already-paused runs. Returns 409 when the + latest run is terminal (use `/rerun` to start fresh) and + 404 on a deleted job. + + Billing note: in-flight gateway calls still consume credits. + Use `/cancel` for a hard cost-stop. + responses: + '200': + description: Run is paused. Body is the refreshed Run object. + content: + application/json: + schema: { $ref: '#/components/schemas/Run' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + '409': + description: Latest run is terminal; pause does not apply. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + + /jobs/{job_id}/resume: + parameters: + - $ref: '#/components/parameters/JobID' + post: + tags: [jobs] + operationId: resumeJob + summary: Resume a paused run + description: | + Inverse of `/pause`. Sets `latest_run.pause_state = active`; + the dispatcher resumes polling. Idempotent on already-active + runs. + responses: + '200': + description: Run is active. Body is the refreshed Run object. + content: + application/json: + schema: { $ref: '#/components/schemas/Run' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + '409': + description: Latest run is terminal; resume does not apply. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + + /jobs/{job_id}/rerun: + parameters: + - $ref: '#/components/parameters/JobID' + post: + tags: [jobs] + operationId: rerunJob + summary: Start a new run (full replay or partial retry of the previous run) + description: | + Two modes share this endpoint, picked by the optional + `?status=` filter: + + **Full rerun (no `status`).** Replays every task from the + previous run into a fresh new run. Same `task_id` carries + through together with `external_id`, `url`, `metadata`, and + per-task `zenrows_params`. **No source-child link** — the + new run is fully independent. For scheduled jobs with no + prior run, builds from `template_tasks` instead — the + manual equivalent of an automatic scheduled fire. + + **Partial retry (`?status=failed[,pending,...]`).** Copies + every prev-run task row, but rows matching the filter are + reset to `pending` and re-enqueued; everything else is + copied verbatim with `source_run_id` (the run that owns the + stored result) stamped. The new run's stats are pre-seeded + from the inherited terminal rows. On chained retries, + `source_run_id` chases back to the run that owns the result + so intermediate retries can be deleted freely; + `DELETE /v1/jobs/{id}/runs/{run_id}` only 409s when a + newer run still has tasks pointing at the targeted run. + + Both modes stamp `trigger: manual` on the new run. + + Accepted `status` values: `pending`, `processing`, + `failed`, `successful`. Unknown values → 400. Empty + comma-separated list → 400. + + Preconditions: + - The job is not `deleted`. + - If a previous run exists, it must be terminal-non-deleted + (`completed` / `stopped` / `failed`). + - `?status=` mode additionally requires a prior run. + - Full mode on a scheduled job with no prior run requires + non-empty `template_tasks`. + + Side effects: + - Auto-closes an `open` job (existing behavior). + - Honors `Idempotency-Key`: a repeat call with the same + key returns the original new-run id. + + Large reruns are accepted asynchronously: past a server-side + row-count threshold (the same one that upgrades large + submissions) the call returns `202` and the new run's task + rows stream into storage off the request path. Treat `201` + and `202` both as success. + parameters: + - in: query + name: status + description: | + Optional comma-separated list of task statuses to + retry. When omitted: full replay (no source-child + link). When set: only matching statuses are reset to + `pending`; everything else is inherited with + `source_run_id`. + schema: + type: string + example: failed,pending + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '201': + description: New run created (sync path). All task rows are written. + content: + application/json: + schema: { $ref: '#/components/schemas/RerunJobResponse' } + '202': + description: | + New run accepted (large rerun). `retried_tasks` / + `inherited_tasks` are the final counts, but individual + task rows stream into storage off the request path. + `latest_run.ingest_status` is `pending` in this response + and flips to `done` on `GET /v1/jobs/{id}` once every + task row is visible; the run's `stats.total` is correct + from this response forward, so completion polling is + safe. Body shape matches the 201 response. + content: + application/json: + schema: { $ref: '#/components/schemas/RerunJobResponse' } + '400': { $ref: '#/components/responses/InvalidArgument' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + '409': + description: Latest run not terminal, no prior run available, no tasks match the filter, or idempotency-key reused with a different body. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + '503': { $ref: '#/components/responses/Unavailable' } + + /jobs/{job_id}/schedule: + parameters: + - $ref: '#/components/parameters/JobID' + put: + tags: [jobs] + operationId: updateJobSchedule + summary: Replace the schedule of a scheduled job + description: | + Wholesale replaces the schedule on a `type: scheduled` job. + The body is the same `JobSchedule` shape used at submit. + Only valid on scheduled jobs — regular jobs reject with 409. + + An in-flight run keeps running; the new schedule only + governs future fires. `next_scheduled_run` is recomputed + and re-stamped before the response returns. + + Naturally idempotent — re-applying the same body is a + no-op success. No `Idempotency-Key` required. + + The change is applied atomically from your perspective; if a + transient failure leaves `next_scheduled_run` briefly stale, + re-PUT the same body to recover (the operation is idempotent). + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/JobSchedule' } + responses: + '200': + description: Schedule updated; returns the updated Job. + content: + application/json: + schema: { $ref: '#/components/schemas/Job' } + '400': { $ref: '#/components/responses/InvalidArgument' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + '409': + description: Job isn't a scheduled job. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + '503': { $ref: '#/components/responses/Unavailable' } + + /jobs/{job_id}/schedule/state: + parameters: + - $ref: '#/components/parameters/JobID' + post: + tags: [jobs] + operationId: updateScheduleState + summary: Pause or resume a scheduled job + description: | + Flips `schedule_state` between `active` and `paused`. Only + valid on `type: scheduled` jobs. + + While `paused`, scheduled fires are skipped; resuming makes + the next scheduled fire produce a run normally. + + `next_scheduled_run` stays computed while paused — + it's "what would fire next if you resumed." + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/UpdateScheduleStateRequest' } + responses: + '200': + description: Schedule state updated; returns the updated Job. + content: + application/json: + schema: { $ref: '#/components/schemas/Job' } + '400': { $ref: '#/components/responses/InvalidArgument' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + '409': + description: Job isn't a scheduled job. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + '503': { $ref: '#/components/responses/Unavailable' } + + /jobs/{job_id}/webhook: + parameters: + - $ref: '#/components/parameters/JobID' + get: + tags: [jobs] + operationId: getJobWebhook + summary: Read the current webhook config + description: | + Returns the job's webhook config or `404` when none is set. + The same shape is also surfaced inline on `GET /v1/jobs/{id}` + via the `webhook` field. + responses: + '200': + description: Webhook is configured. + content: + application/json: + schema: { $ref: '#/components/schemas/WebhookConfig' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': + description: Job not found, or no webhook configured. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + put: + tags: [jobs] + operationId: putJobWebhook + summary: Replace the webhook config + description: | + Replaces the webhook config wholesale. Both `url` and + `signature` are required — no defaulting at the mutate + boundary (preventing a `PUT {"url":"..."}` from silently + disabling signing on a job that previously had it on). + + Validation matches submit: URL must be HTTPS with a host + that resolves via DNS within 1 second. When `signature: + true`, the org must already have an active HMAC key + (`POST /v1/hmac/keys/rotate`), or the request fails with + `400 webhook_signing_requires_active_key`. + + A `PUT` mid-retry-cycle is picked up on the next delivery + attempt — config is read fresh on every dispatch hop. + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/WebhookConfig' } + responses: + '200': + description: New webhook config persisted. + content: + application/json: + schema: { $ref: '#/components/schemas/WebhookConfig' } + '400': + description: Validation failed (HTTPS / DNS / missing active HMAC key). + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + '409': + description: Concurrent modification — retry the request. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + delete: + tags: [jobs] + operationId: deleteJobWebhook + summary: Clear the webhook config + description: | + Idempotent: returns `204` whether or not a webhook was + configured. After this call no further `run.completed` / + `run.failed` deliveries are sent for this Job — including a + currently-in-flight one that hasn't been delivered yet + (a delivery whose Job no longer has a webhook configured is + dropped). + responses: + '204': { description: Webhook cleared. } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + '409': + description: Concurrent modification — retry the request. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + + /jobs/{job_id}/runs: + parameters: + - $ref: '#/components/parameters/JobID' + get: + tags: [runs] + operationId: listJobRuns + summary: List runs of a job (newest first) + parameters: + - in: query + name: limit + schema: { type: integer, minimum: 1, maximum: 100, default: 50 } + - in: query + name: cursor + schema: { type: string } + responses: + '200': + description: Page of runs. + content: + application/json: + schema: { $ref: '#/components/schemas/ListJobRunsResponse' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + + /jobs/{job_id}/runs/{run_id}: + parameters: + - $ref: '#/components/parameters/JobID' + - $ref: '#/components/parameters/RunID' + get: + tags: [runs] + operationId: getJobRun + summary: Get one run by id + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Run' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: [runs] + operationId: deleteJobRun + summary: Delete one run and its artifacts (asynchronous) + description: | + Async hard-delete of a single run. Synchronously: flips the + run to `deleted` and (if this was the latest run) updates + the job's `latest_run` projection to the next-newest + non-deleted run. The run's stored result bodies and its + per-task data are then deleted asynchronously. + + Distinct from `/stop`: this deletes the result bodies; `/stop` + keeps them. To stop a run *and* free its storage, call + `/stop` then `DELETE /runs/{run_id}` (order matters only + for semantics — both are idempotent). + + Idempotent — re-DELETE returns 202. + responses: + '202': + description: Accepted for deletion. + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + + /jobs/{job_id}/results: + parameters: + - $ref: '#/components/parameters/JobID' + get: + tags: [tasks] + operationId: listLatestRunResults + summary: Page through per-task results for the latest run of a job + description: Shortcut for the latest run; see `listRunResults` for the explicit form. + parameters: + - { $ref: '#/components/parameters/ResultStatus' } + - { $ref: '#/components/parameters/ResultCursor' } + responses: + '200': + description: Page of results. + content: + application/json: + schema: { $ref: '#/components/schemas/ListResultsResponse' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + + /jobs/{job_id}/runs/{run_id}/results: + parameters: + - $ref: '#/components/parameters/JobID' + - $ref: '#/components/parameters/RunID' + get: + tags: [tasks] + operationId: listRunResults + summary: Page through per-task results for a specific run + parameters: + - { $ref: '#/components/parameters/ResultStatus' } + - { $ref: '#/components/parameters/ResultCursor' } + responses: + '200': + description: Page of results. + content: + application/json: + schema: { $ref: '#/components/schemas/ListResultsResponse' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + + /jobs/{job_id}/tasks/{task_id}/content: + parameters: + - $ref: '#/components/parameters/JobID' + - $ref: '#/components/parameters/TaskID' + get: + tags: [tasks] + operationId: getLatestRunTaskContent + summary: Fetch the scraped content for a task in the latest run + description: Latest-run shortcut. See `getRunTaskContent` for explicit-run form. + responses: + '200': + description: Scraped content. + content: + text/html: + schema: { type: string } + application/json: + schema: { type: object } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + '409': + description: Task is still pending or processing. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + '422': + description: Task is failed. Body is the captured error as Problem JSON. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + + /jobs/{job_id}/runs/{run_id}/tasks/{task_id}/content: + parameters: + - $ref: '#/components/parameters/JobID' + - $ref: '#/components/parameters/RunID' + - $ref: '#/components/parameters/TaskID' + get: + tags: [tasks] + operationId: getRunTaskContent + summary: Fetch the scraped content for a task in a specific run + responses: + '200': + description: Scraped content. + content: + text/html: + schema: { type: string } + application/json: + schema: { type: object } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + '409': + description: Task is still pending or processing. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + '422': + description: Task is failed. Body is the captured error as Problem JSON. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + + /jobs/{job_id}/tasks/{task_id}/history: + parameters: + - $ref: '#/components/parameters/JobID' + - $ref: '#/components/parameters/TaskID' + get: + tags: [tasks] + operationId: getLatestRunTaskHistory + summary: Get the attempt history for a task in the latest run + responses: + '200': + description: List of attempt events. + content: + application/json: + schema: { $ref: '#/components/schemas/TaskHistoryResponse' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + + /jobs/{job_id}/runs/{run_id}/tasks/{task_id}/history: + parameters: + - $ref: '#/components/parameters/JobID' + - $ref: '#/components/parameters/RunID' + - $ref: '#/components/parameters/TaskID' + get: + tags: [tasks] + operationId: getRunTaskHistory + summary: Get the attempt history for a task in a specific run + responses: + '200': + description: List of attempt events. + content: + application/json: + schema: { $ref: '#/components/schemas/TaskHistoryResponse' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + + /hmac/keys: + get: + tags: [hmac] + operationId: listHMACKeys + summary: List the org's HMAC key metadata + description: | + Returns kid + created_at for whichever slots are populated + (active and/or candidate). NEVER returns secret material — + the only chance to capture a secret is the rotate response. + responses: + '200': + description: Key metadata. + content: + application/json: + schema: { $ref: '#/components/schemas/HMACKeyList' } + '401': { $ref: '#/components/responses/Unauthenticated' } + + /hmac/keys/rotate: + post: + tags: [hmac] + operationId: rotateHMACKey + summary: Generate the initial key, or stage a candidate + description: | + Unified rotation endpoint: + - No active key yet → generates the org's first active key. + - Active exists, no candidate → generates a candidate for + phased rotation. + - Both slots populated → 409 `candidate_pending`. Finalize + or cancel first. + + The response body's `secret` is the **only** time the raw + key value leaves the service. Capture it now or call rotate + again to issue a new one. + responses: + '201': + description: Key created. `secret` returned once. + content: + application/json: + schema: { $ref: '#/components/schemas/HMACKeyCreated' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '409': + description: Candidate already pending. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + delete: + tags: [hmac] + operationId: cancelHMACRotation + summary: Discard the pending candidate + description: | + Drops the candidate slot. Idempotent: returns 204 even when + no candidate is present. + responses: + '204': + description: Candidate discarded (or none was pending). + '401': { $ref: '#/components/responses/Unauthenticated' } + + /hmac/keys/rotate/finalize: + post: + tags: [hmac] + operationId: finalizeHMACKey + summary: Promote the candidate to active + description: | + Promotes the pending candidate to active; the prior active + is discarded. Returns the new active's metadata; no secret. + Returns 409 `no_candidate` if nothing is pending. + responses: + '200': + description: Candidate promoted. + content: + application/json: + schema: { $ref: '#/components/schemas/HMACKeyFinalized' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '409': + description: No candidate to finalize, or concurrent modification. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + + /webhook/test: + post: + tags: [webhook] + operationId: testWebhook + summary: Send a synthetic test event to a webhook URL + description: | + Synchronous one-shot dispatch of a `webhook.test` event + against a caller-supplied URL. Independent of any job — body + carries the same `{ url, signature }` shape as the submit-time + webhook field. Used by integrators to verify a receiver is + reachable, terminates TLS, and validates the signature before + attaching the URL to a real job. + + Validation is identical to submit (HTTPS, 1-second DNS lookup, + active HMAC key required when `signature: true`). On validation + failure, returns the same Problem JSON codes + (`callback_url_must_be_https`, `callback_url_unresolvable`, + `webhook_signing_requires_active_key`). + + On validation pass, always returns `200 OK`; the receiver + outcome rides in the body so callers can distinguish + "the API rejected my config" (4xx) from "my receiver + returned 500" (200 + `delivered: false`). + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/TestWebhookRequest' } + responses: + '200': + description: | + Dispatch attempted. Inspect `delivered` to see whether the + receiver returned 2xx. + content: + application/json: + schema: { $ref: '#/components/schemas/TestWebhookResponse' } + '400': + description: | + Validation failed — HTTPS / DNS / missing active HMAC key. + The receiver was not contacted. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '503': { $ref: '#/components/responses/Unavailable' } + + /job_inputs: + post: + tags: [job_inputs] + operationId: createJobInput + summary: Create a CSV upload slot + description: | + Allocates a `file_input_id`, persists the parse spec, and + returns a presigned PUT URL the caller uploads the CSV body + to. The slot lives 24 h; subsequent `POST /v1/jobs` references + it by `file_input_id`. + + The CSV is parsed when the job is submitted, where a + 50 MB / 100 000-row cap is enforced. + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateJobInputRequest' } + responses: + '201': + description: Slot created. + content: + application/json: + schema: { $ref: '#/components/schemas/CreateJobInputResponse' } + '400': { $ref: '#/components/responses/InvalidArgument' } + '401': { $ref: '#/components/responses/Unauthenticated' } + + /jobs/{job_id}/runs/{run_id}/exports: + parameters: + - $ref: '#/components/parameters/JobID' + - $ref: '#/components/parameters/RunID' + post: + tags: [runs] + operationId: startResultsExport + summary: Start an async results export + description: | + Kicks off an async zip of every task body in the run for + short-term download. Returns an `export_id` immediately; poll + `getResultsExport` for status and the presigned download URL. + + Each export expires 12 h after creation, after which the id + 404s. There is no delete / update — expiry is the cleanup. + + The export fails with + `error: "results are larger then 1 gb"` if the combined size + of the results exceeds the 1 GiB cap. + responses: + '202': + description: Export accepted; the zip is produced asynchronously. + content: + application/json: + schema: { $ref: '#/components/schemas/StartExportResponse' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + + /jobs/{job_id}/runs/{run_id}/exports/{export_id}: + parameters: + - $ref: '#/components/parameters/JobID' + - $ref: '#/components/parameters/RunID' + - $ref: '#/components/parameters/ExportID' + get: + tags: [runs] + operationId: getResultsExport + summary: Poll a results export + description: | + Returns the current status and (on `completed`) a freshly + presigned `download_url`. The URL is signed on every call, + so callers may stash the metadata but should re-fetch right + before downloading. + + 404 covers both "id doesn't exist" and "expired" — callers + treat them the same. + responses: + '200': + description: Export state (with `download_url` once complete). + content: + application/json: + schema: { $ref: '#/components/schemas/Export' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '404': { $ref: '#/components/responses/NotFound' } + +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: X-API-Key + description: | + API key issued via the zenrows account. Only the header + transport is accepted — query-param keys are rejected. + + parameters: + JobID: + name: job_id + in: path + required: true + description: ULID assigned by the server on submit. + schema: { type: string, pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' } + RunID: + name: run_id + in: path + required: true + description: ULID assigned by the server when a run is created (submit or rerun). + schema: { type: string, pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' } + TaskID: + name: task_id + in: path + required: true + description: Task id within a run (ULID-generated or caller-supplied). + schema: { type: string, pattern: '^[A-Za-z0-9._-]{1,128}$' } + ExportID: + name: export_id + in: path + required: true + description: ULID assigned by the server when a results export is started. + schema: { type: string, pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' } + IdempotencyKey: + name: Idempotency-Key + in: header + required: false + description: | + Optional opaque token scoping a single submit / rerun + request. Re-submitting with the same key returns the + original response (or 409 on body mismatch). + schema: { type: string, maxLength: 128 } + ResultStatus: + name: status + in: query + schema: + type: string + enum: [all, successful, failed, processing] + default: all + description: | + `processing` covers both `pending` and `processing` task + states (anything not yet terminal). + ResultCursor: + name: cursor + in: query + schema: { type: string } + description: Opaque pagination token; echo `next_cursor`. + + headers: + RequestID: + description: Correlation ID echoed from the server. Use in support requests. + schema: { type: string } + + responses: + InvalidArgument: + description: Request validation failed. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + Unauthenticated: + description: Missing / invalid API key. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + PaymentRequired: + description: Subscription has no credit available. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + IdempotencyConflict: + description: Idempotency-Key has already been used by this caller with a different body. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + NotFound: + description: Resource not found (or not owned by caller). + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + NotImplemented: + description: Feature is reserved but not yet implemented. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + Unavailable: + description: Transient upstream failure; safe to retry. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + + schemas: + JobType: + type: string + enum: [regular, scheduled] + default: regular + description: | + - `regular` — a run is created at submit time. + - `scheduled` — fires on a recurring or one-shot schedule. + Submit stores the task list as a template on the job row; + each scheduled fire produces a fresh Run. `schedule` + field required. Read-only template: `addTasks` and + `close` return 409. `rerun` (full or filtered) and `stop` + are allowed. + + JobStatus: + type: string + enum: [open, closed, deleted] + description: | + - `open` — initial run still accepting `addTasks`. Only + meaningful while `latest_run.run_sequence == 1`. + - `closed` — no more tasks accepted (created closed, or + closed via `/close` / `addTasks{last_batch}` / `/rerun`). + - `deleted` — async deletion in progress; the job disappears + once it finishes. + + ScheduleState: + type: string + enum: [active, paused] + description: | + Run/pause flag on a scheduled job. While `paused`, scheduled + fires are skipped. Default at submit: `active`. Flip via + `POST /v1/jobs/{id}/schedule/state`. + + UpdateScheduleStateRequest: + type: object + required: [schedule_state] + properties: + schedule_state: { $ref: '#/components/schemas/ScheduleState' } + + RunTrigger: + type: string + enum: [manual, scheduled] + description: | + What set this run in motion. Always set. + - `manual` — caller-initiated: `POST /jobs`, `POST /jobs/{id}/rerun` + (any job type, full or `?status=`-filtered, manual fire of + a scheduled job). + - `scheduled` — automatic, fired by the configured + schedule (recurring or one-shot). + + RunStatus: + type: string + enum: [running, pending, completed, stopped, failed, deleted] + description: | + In-flight: + - `running` — work queued / in flight. + - `pending` — initial run of an open job, idle between batches. + + Terminal: + - `completed` — natural finish. + - `stopped` — caller called `POST /jobs/{id}/stop`. No new + tasks are picked up; in-flight tasks may still finish. + Result bodies are kept. `stats.completed < stats.total` + signals "stopped early". + - `failed` — the run was auto-failed on an account-level error + (insufficient credits / inactive subscription). No new tasks + are picked up; `failure_reason` carries the cause. Re-runnable + once the account is resolved. Result bodies already produced + are kept. + - `deleted` — caller called + `DELETE /v1/jobs/{id}/runs/{run_id}`. The run's result + bodies and data are being deleted; once complete the run + disappears. + + TaskStatus: + type: string + enum: [pending, processing, successful, failed] + + ResultType: + type: string + enum: [html, json, markdown, plaintext, pdf] + description: Body format of a successful task result; matches the job's `format` 1:1. + + Format: + type: string + enum: [html, json, markdown, plaintext, pdf] + description: | + Derived server-side from `zenrows_params` at submit time. + Precedence: + - `response_type: markdown|plaintext|pdf` → matching format. + - `autoparse: true`, `json_response: true`, or non-empty + `css_extractor` → `json`. + - otherwise → `html`. + Stamped on every successful task result and used to set the + right `Content-Type` when you fetch the content. + + Metadata: + type: object + description: Opaque user-supplied key/value pairs. Stored on the task; not forwarded. + additionalProperties: + type: string + maxLength: 1024 + maxProperties: 20 + + ScraperParams: + type: object + description: | + Subset of the zenrows universal-scraper-API parameter overview + (https://docs.zenrows.com/universal-scraper-api/api-reference). + The API pins the surface to a known set — unknown keys are + rejected with `400 invalid_argument` (`unknown_param`). The + `screenshot*` family is deliberately excluded; `mode` accepts + `"auto"` only for now. + + Each value can be any JSON scalar (string, boolean, integral + number); the API coerces to a string internally. + + Per-task `zenrows_params` override job-level `zenrows_params` + on key collision. + + Supported keys: `mode`, `js_render`, `js_instructions`, + `custom_headers`, `premium_proxy`, `proxy_country`, + `session_id`, `original_status`, `allowed_status_codes`, + `wait_for`, `wait`, `block_resources`, `json_response`, + `css_extractor`, `autoparse`, `response_type`, `outputs`. + + `custom_headers` additionally accepts an object of header + name → value pairs (or that object serialised as a JSON + string), e.g. `{"custom_headers": {"Referer": + "https://www.google.com/"}}`. The headers are forwarded to + the scraping target, subject to Fetch's usual + header sanitisation. At most 32 headers / 8 KB serialised; + names must be valid HTTP tokens. Header filtering and + browser-header sanitisation are owned by Fetch, + not Conveyor. A task-level + `custom_headers` replaces the job-level object wholesale — + no per-name merging. + additionalProperties: + oneOf: + - { type: string } + - { type: boolean } + - { type: integer } + - { type: object, additionalProperties: { type: string } } + + TaskInput: + type: object + required: [url] + properties: + external_id: + type: string + maxLength: 128 + pattern: '^[A-Za-z0-9._-]+$' + description: | + Optional caller-supplied correlation id — typically an + identifier from the caller's own system. Surfaced verbatim + in result/content responses so callers can match results + back to their records. **Not required to be unique** — + callers may reuse the same value across tasks (e.g. when + multiple scrapes correlate to the same upstream record). + Independent of the server- + assigned `task_id`. + url: + type: string + format: uri + maxLength: 2048 + description: Must be http(s). Other schemes rejected at submit. + metadata: { $ref: '#/components/schemas/Metadata' } + method: + type: string + enum: [GET, POST] + default: GET + description: | + HTTP method used against `url`. Case-insensitive. POST is + for **safe/idempotent** requests only (GraphQL queries, + search endpoints): tasks are retried on transient failures + and reruns, so the target may see the same POST more than + once. Callers that cannot tolerate a duplicate should + disable reruns. POST rides the standard (non-headless) + scraping path — combining it with `js_render`, + `js_instructions`, or `json_response` is rejected with + 400 `method_param_conflict`. + body: + description: | + Request body, only with `method: POST`. Any JSON value, + 16 KiB max. An object/array/number/boolean is sent as its + JSON encoding with `Content-Type: application/json`; a + string is sent verbatim with + `Content-Type: application/x-www-form-urlencoded`. Set a + different target Content-Type via the `custom_headers` + zenrows param. Never echoed in results listings. + zenrows_params: + $ref: '#/components/schemas/ScraperParams' + description: | + Per-task scraper params. Override the job-level + `zenrows_params` on key collision (task wins). + + SubmitJobRequest: + type: object + properties: + type: { $ref: '#/components/schemas/JobType' } + status: + type: string + enum: [open, closed] + default: closed + description: | + Initial state. `open` is only allowed for `regular` jobs; + after the initial run, `open` has no meaning so the job + is auto-closed. + zenrows_params: + $ref: '#/components/schemas/ScraperParams' + description: | + Job-level scraper params, applied to every task of every + run of the job. Each task can override individual keys + via its own `zenrows_params` (task wins on collision). + schedule: + $ref: '#/components/schemas/JobSchedule' + description: | + Schedule block for `type: scheduled`. Required there, + ignored otherwise. + tasks: + type: array + description: | + Required for closed jobs (1–1000) unless `file_input_id` + is provided. Optional for open jobs — may be empty if the + caller will follow up with `addTasks`. Mutually exclusive + with `file_input_id`. + minItems: 0 + maxItems: 1000 + items: { $ref: '#/components/schemas/TaskInput' } + file_input_id: + type: string + description: | + Reference to a previously-uploaded CSV input (see + `POST /v1/job_inputs`). Mutually exclusive with `tasks`. + Eligible only for regular-closed and scheduled job types. + The uploaded CSV is parsed under the saved spec; its rows + become tasks (regular-closed) or template_tasks + (scheduled). + external_id: + type: string + maxLength: 128 + pattern: '^[A-Za-z0-9._-]+$' + description: | + Optional caller-supplied correlation id for the job — + same semantics as `task.external_id`. Shape-checked, + **not** required to be unique. Surfaced verbatim in + `getJob` / `listJobs` responses. + name: + type: string + maxLength: 100 + description: | + Optional human-readable label for the job. Free-form — + no shape rules. Up to 100 characters. Surfaced verbatim + in `getJob` / `listJobs` responses. No uniqueness, no + indexing; cannot be changed after submit. + metadata: { $ref: '#/components/schemas/Metadata' } + webhook: + $ref: '#/components/schemas/WebhookConfig' + description: | + Optional `run.completed` / `run.failed` delivery config. + A terminal run fires `run.completed` on a natural finish, or + `run.failed` (with `failure_reason` + partial stats) when the + run auto-fails on an account-level error. `signature` + defaults to `false` here so first-time integrations + don't need an HMAC key. Config is mutable post-submit + via `PUT /v1/jobs/{id}/webhook` and `DELETE + /v1/jobs/{id}/webhook`; the current config is surfaced + on `GET /v1/jobs/{id}`. + + SubmitJobResponse: + type: object + required: [job_id, status, accepted_tasks] + properties: + job_id: { type: string } + status: { $ref: '#/components/schemas/JobStatus' } + latest_run: + $ref: '#/components/schemas/Run' + description: Absent for `scheduled` jobs that haven't fired yet. + accepted_tasks: { type: integer } + webhook: + $ref: '#/components/schemas/WebhookConfig' + description: | + Echo of the webhook config persisted on the job (when + one was supplied). Omitted when no webhook was set. + + AddTasksRequest: + type: object + required: [tasks] + properties: + tasks: + type: array + minItems: 1 + maxItems: 1000 + items: { $ref: '#/components/schemas/TaskInput' } + last_batch: + type: boolean + default: false + description: | + Set true on the final batch. Closes the job (status → + `closed`) and marks the run's `last_batch_received`. + + AddTasksResponse: + type: object + required: [accepted_tasks, job_status, latest_run] + properties: + accepted_tasks: { type: integer } + job_status: { $ref: '#/components/schemas/JobStatus' } + latest_run: { $ref: '#/components/schemas/Run' } + + RerunJobResponse: + type: object + required: [job_id, status, latest_run, retried_tasks, inherited_tasks] + properties: + job_id: { type: string } + status: { $ref: '#/components/schemas/JobStatus' } + latest_run: { $ref: '#/components/schemas/Run' } + rerun_of: + type: string + description: | + `run_id` of the previous run that was replayed. Empty on + the first manual fire of a scheduled job (no prior run). + retried_tasks: + type: integer + description: | + Number of rows reset to `pending` and re-enqueued. Equals + `latest_run.stats.total` for a full rerun; equals the + filter-matched count for a `?status=` partial retry. + inherited_tasks: + type: integer + description: | + Number of rows copied verbatim from the previous run with + `source_run_id` stamped. Zero for a full rerun; non-zero + only when `?status=` is set. + + Spend: + type: object + description: | + Indicative `{credits, cost}` pair — what was charged for + the scoped work. **Not billing-grade**; your account + statement is authoritative. Use as a "how much did this + cost?" indicator, not for reconciliation. + required: [credits, cost] + properties: + credits: { type: integer, minimum: 0 } + cost: { type: number, format: double, minimum: 0 } + + TaskSpend: + type: object + description: | + Per-task indicative spend with two roll-ups: `total` + accumulates across every attempt (including retries), + `last_attempt` carries just the most recent gateway call. + On a task that succeeded on its first try the two are + equal; on a retried task they diverge. + required: [total, last_attempt] + properties: + total: { $ref: '#/components/schemas/Spend' } + last_attempt: { $ref: '#/components/schemas/Spend' } + + RunStats: + type: object + required: [total, completed, successful, failed] + properties: + total: { type: integer, description: Number of tasks in this run. } + completed: { type: integer, description: successful + failed. } + successful: { type: integer } + failed: { type: integer } + failure_reasons: + type: object + description: | + Coarse rollup of terminal failures keyed by a small public + taxonomy. Lets you answer "what kinds of failures did I + get?" without paging every error blob. Best-effort, + indicative — omitted on runs with no failures yet and on + runs that predate the feature. + + Vocabulary (the only keys that appear): + - `auth_failed` — credentials or billing + - `blocked` — anti-bot / policy denials + - `bad_target` — target URL is the problem (bad host, 404, 410, too large) + - `rate_limited` — target throttled the request + - `timeout` — the scrape didn't complete in time + - `gateway_error` — ZenRows-side transport / 5xx + - `other` — anything else + additionalProperties: + type: integer + minimum: 0 + example: + blocked: 9 + gateway_error: 4 + spend: + allOf: [{ $ref: '#/components/schemas/Spend' }] + description: | + Indicative spend summed across every task attempt in + this run. Absent on runs whose tasks predate this + field (treat as zero). + + Run: + type: object + required: [run_id, job_id, run_sequence, status, stats, created_at, updated_at] + properties: + run_id: { type: string } + job_id: { type: string } + run_sequence: { type: integer, minimum: 1 } + status: { $ref: '#/components/schemas/RunStatus' } + stats: { $ref: '#/components/schemas/RunStats' } + last_batch_received: + type: boolean + description: | + Meaningful only for the initial run of an open job. + Once true, `addTasks` is rejected and the run drains + into `completed`. + pause_state: + type: string + enum: [active, paused] + description: | + Reversible-suspend flag, orthogonal to `status`. + Omitted from responses when `active` / absent (legacy + rows). Flip via `POST /jobs/{id}/pause` and + `/resume`. + ingest_status: + type: string + enum: [pending, done] + description: | + Present only on runs created by a large (202) submission + or a large (202) rerun. `pending` — task rows are still + streaming into storage; reads may return partial pages + and `addTasks` returns `409`. `done` — every accepted + task row is visible. Omitted on runs whose tasks were + written on the request path (201 submissions and + reruns, `addTasks` batches). + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + failure_reason: + type: string + enum: [insufficient_credits, subscription_inactive] + description: | + Present only when `status == failed`: the account-level + cause of the auto-fail. `insufficient_credits` (out of + credits) or `subscription_inactive` (subscription not + active). Omitted otherwise. Distinct from + `stats.failure_reasons` (the per-task rollup). + + JobSchedule: + type: object + description: | + Structured scheduling block attached to `type: scheduled` + jobs. Exactly one of `at`, `rate`, or `calendar` must be + set. + properties: + at: + type: string + description: | + One-shot fire at a specific wall-clock timestamp. + Mutually exclusive with `rate` and `schedule`. + + **Must be tz-naive** — no trailing `Z`, no offset. The + sibling `timezone` field (mandatory) is the single + authoritative interpreter. This keeps DST transitions + deterministic. + example: "2026-09-01T09:00:00" + rate: + $ref: '#/components/schemas/ScheduleRate' + calendar: + $ref: '#/components/schemas/ScheduleCalendar' + timezone: + type: string + description: | + IANA timezone name (e.g. `Europe/Berlin`, `UTC`). + **Required** when `at` or `calendar` is set; + ignored by `rate` (interval-based, no wall-clock + meaning). Anchoring wall-clock times to a named zone + (rather than a UTC offset baked into the string) keeps + DST transitions deterministic. + example: "Europe/Berlin" + + ScheduleRate: + type: object + required: [every, unit] + description: Interval-based fire policy — every N units. + properties: + every: + type: integer + minimum: 1 + example: 15 + unit: + type: string + enum: [minute, hour, day] + + ScheduleCalendar: + type: object + required: [times_of_day, cadence] + description: | + Calendar-style fire policy. Fires at every `times_of_day` + entry on every day matching the cadence. + properties: + times_of_day: + type: array + minItems: 1 + description: | + Wall-clock times on a 24-hour clock, full hours only + (`"09:00"`, `"18:00"`). Minute granularity is rejected + with 400. + items: + type: string + pattern: '^([01][0-9]|2[0-3]):00$' + example: ["09:00", "18:00"] + cadence: + $ref: '#/components/schemas/ScheduleCadence' + + ScheduleCadence: + type: object + description: | + Picks which days the schedule fires on. Exactly one of + `daily`, `weekly`, `monthly` must be set. + properties: + daily: + type: object + description: Fire every day. No knobs. + weekly: + type: object + required: [days] + properties: + days: + type: array + minItems: 1 + items: + type: string + enum: [mon, tue, wed, thu, fri, sat, sun] + example: [mon, wed, fri] + monthly: + type: object + required: [days] + properties: + days: + type: array + minItems: 1 + items: + type: integer + minimum: 1 + maximum: 31 + example: [1, 15] + + Job: + type: object + required: [job_id, type, status, created_at, updated_at] + properties: + job_id: { type: string } + type: { $ref: '#/components/schemas/JobType' } + status: { $ref: '#/components/schemas/JobStatus' } + format: { $ref: '#/components/schemas/Format' } + zenrows_params: + type: object + additionalProperties: { type: string } + description: | + Stored canonical form — values are always strings even + though submit accepts any JSON scalar (see ScraperParams). + external_id: + type: string + description: | + Caller-supplied correlation id passed at submit (omitted + when the caller did not supply one). Not server-enforced + unique. + name: + type: string + description: | + Optional human label passed at submit (omitted when the + caller did not supply one). Free-form, up to 100 chars. + metadata: { $ref: '#/components/schemas/Metadata' } + schedule: + $ref: '#/components/schemas/JobSchedule' + description: | + Schedule block — present only for `type: scheduled` + jobs. + next_scheduled_run: + type: string + format: date-time + nullable: true + description: | + Server-computed timestamp of the next expected fire, + stamped at submit and re-stamped on every fire. `null` + for non-scheduled jobs and for one-shot `at(...)` + schedules that have already fired. Stays computed when + `schedule_state == paused` — "what would fire next if + you resumed." + schedule_state: + $ref: '#/components/schemas/ScheduleState' + description: | + Set only for `type: scheduled`. Default `active` at + submit; flip via `POST /v1/jobs/{id}/schedule/state`. + webhook: + $ref: '#/components/schemas/WebhookConfig' + description: | + Webhook delivery config. Present iff a + webhook is configured. Mutable via `PUT/DELETE + /v1/jobs/{id}/webhook`; `signature` never appears + alone — the whole `webhook` key is omitted when no + URL is set. + latest_run: + $ref: '#/components/schemas/Run' + description: | + Snapshot projection of the latest run. Absent for + `scheduled` jobs that haven't fired yet. + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + ListJobsResponse: + type: object + required: [jobs] + properties: + jobs: + type: array + items: { $ref: '#/components/schemas/Job' } + next_cursor: + type: string + nullable: true + + ListJobRunsResponse: + type: object + required: [runs] + properties: + runs: + type: array + items: { $ref: '#/components/schemas/Run' } + next_cursor: + type: string + nullable: true + + TaskResult: + type: object + required: [task_id, run_id, url, status] + properties: + task_id: { type: string } + external_id: + type: string + description: | + Caller-supplied correlation id from submit/AddTasks. + Omitted when the caller did not supply one. + run_id: { type: string } + url: { type: string, format: uri } + metadata: { $ref: '#/components/schemas/Metadata' } + method: + type: string + enum: [GET, POST] + description: | + The task's HTTP method. Omitted for GET (the default). + The request `body` is intentionally not part of listing + responses. + status: { $ref: '#/components/schemas/TaskStatus' } + type: { $ref: '#/components/schemas/ResultType' } + result_url: + type: string + nullable: true + description: | + 24-hour presigned download URL for the result body, or a + `/v1/jobs//runs//tasks//content` URL you can + fetch directly. Empty for non-successful tasks. + error: + $ref: '#/components/schemas/Problem' + description: | + Present on failed tasks. The scraping engine's error + response as Problem JSON, or a synthesised envelope with + `code: "gateway_unreachable"` when it couldn't be reached. + source_run_id: + type: string + description: | + Set when this row was copied from another run by + `/rerun?status=`. The row is terminal at creation, is + never re-executed, and its `result_url` resolves to the + source run's stored result. On chained retries, + `source_run_id` chases back to the run that actually + owns the result. Empty for normally-executed rows. + spend: { $ref: '#/components/schemas/TaskSpend' } + + ListResultsResponse: + type: object + required: [results] + properties: + results: + type: array + items: { $ref: '#/components/schemas/TaskResult' } + next_cursor: + type: string + nullable: true + + TaskHistoryEvent: + type: object + required: [started_at, ended_at, attempt] + properties: + started_at: { type: string, format: date-time } + ended_at: { type: string, format: date-time } + attempt: + type: integer + minimum: 1 + description: 1-indexed attempt within the run. + error: + $ref: '#/components/schemas/Problem' + spend: + allOf: [{ $ref: '#/components/schemas/Spend' }] + description: | + Indicative spend charged for this single attempt. Zero + on attempts that didn't reach the scraping engine or + were not charged. + + TaskHistoryResponse: + type: object + required: [events] + properties: + events: + type: array + items: { $ref: '#/components/schemas/TaskHistoryEvent' } + + WebhookConfig: + type: object + required: [url, signature] + description: | + Webhook delivery config for `run.completed` / `run.failed`. Returned on + `GET /v1/jobs/{id}` (inline under `webhook`) and on + `GET /v1/jobs/{id}/webhook`. `PUT` requires both fields — + no defaulting at the mutate boundary (otherwise toggling + `url` would silently disable signing). Submit body accepts + the same shape with `signature` optional (defaults `false`). + properties: + url: + type: string + format: uri + description: | + HTTPS only. Host must resolve via DNS within 1 second + (1+ A/AAAA record). + signature: + type: boolean + description: | + Opt-in HMAC signing. When `true`, each delivery is signed + with the org's active HMAC key + (`POST /v1/hmac/keys/rotate`) and carries + `X-Signature: t=,v1=,kid=`. The signed + input is `t + "." + raw_body`, HMAC-SHA256. When `false`, + deliveries carry **no** `X-Signature` header — header + absence is the signal. + + TestWebhookRequest: + type: object + required: [url] + description: | + Body for `POST /v1/webhook/test`. Same shape as the submit-time + `webhook` field; `signature` is optional and defaults `false`. + properties: + url: + type: string + format: uri + description: | + HTTPS only. Host must resolve via DNS within 1 second. + Identical validation to the submit/PUT webhook URL. + signature: + type: boolean + default: false + description: | + When `true`, the test event is signed with the org's + active HMAC key — same `X-Signature: t,v1,kid` header + real deliveries use. Returns `400 + webhook_signing_requires_active_key` when no active key + exists. + + TestWebhookResponse: + type: object + required: [delivered, event_id, elapsed_ms] + description: | + Outcome of the synthetic test dispatch. HTTP status is always + `200` when the request was validated — the receiver outcome + is in the body. The synthetic envelope uses `event_type: + "webhook.test"` and stable sentinel IDs (`job_id`/`run_id` = + `"test"`) so receivers can recognise and discard test traffic. + properties: + delivered: + type: boolean + description: | + `true` iff the receiver responded with a 2xx status. `false` + on non-2xx, timeout, or transport error. + event_id: + type: string + description: | + ULID of the synthetic event. Different on every call so + receivers' dedup tables don't suppress repeated tests. + status_code: + type: integer + description: | + HTTP status returned by the receiver. Absent on timeout or + transport error (no response was received). + error: + type: string + description: | + Human-readable reason when `delivered: false`. Same + vocabulary as real webhook deliveries + (`http_4xx:`, `http_5xx:`, `timeout`, + `transport_error:`). Absent on success. + elapsed_ms: + type: integer + description: Wall-clock duration of the receiver POST in milliseconds. + + FileInputColumnRef: + description: | + Either a column name (string) — requires `csv.header: true` — + or a 0-based column index (integer). Other shapes are + rejected at create-time. + oneOf: + - type: string + minLength: 1 + - type: integer + minimum: 0 + + CreateJobInputRequest: + type: object + required: [type] + properties: + type: + type: string + enum: [csv] + description: Only `csv` is supported in v1. + csv: + type: object + required: [fields] + properties: + delimiter: + type: string + minLength: 1 + maxLength: 1 + default: "," + description: Single-character field delimiter. + quote: + type: string + minLength: 1 + maxLength: 1 + default: "\"" + description: Single-character quoting character. + header: + type: boolean + default: false + description: | + When true, the first CSV row is consumed as a header + row and `csv.fields.*` values may be column names. + fields: + type: object + required: [url] + properties: + url: { $ref: '#/components/schemas/FileInputColumnRef' } + external_id: { $ref: '#/components/schemas/FileInputColumnRef' } + additionalProperties: false + description: | + Map from canonical task field → CSV column. Only + `url` (required) and `external_id` (optional) are + accepted. Each value is a column index (int) or a + column name (string, requires `header: true`). + + FileInputUploadTarget: + type: object + required: [method, url, expires_at] + properties: + method: + type: string + enum: [PUT] + url: + type: string + format: uri + description: | + Presigned PUT URL. Caller MUST send the body with the + exact `Content-Type` shown in `headers` — the signature + binds the content-type. + headers: + type: object + additionalProperties: { type: string } + example: { "Content-Type": "text/csv" } + expires_at: + type: string + format: date-time + description: PUT URL TTL (~30 min). + + CreateJobInputResponse: + type: object + required: [file_input_id, upload, expires_at] + properties: + file_input_id: { type: string } + upload: { $ref: '#/components/schemas/FileInputUploadTarget' } + expires_at: + type: string + format: date-time + description: | + 24 h slot lifetime — beyond this the slot and its uploaded + body are removed and the `file_input_id` returns 404. + + HMACKeyMeta: + type: object + required: [kid, created_at] + description: | + Public view of one HMAC key — id + creation time. Never + includes secret material; that's only returned at /rotate. + properties: + kid: + type: string + description: ULID identifying this key. Stable for the + life of the slot; a new candidate gets a new kid. + pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' + created_at: { type: string, format: date-time } + + HMACKeyList: + type: object + description: Slots populated at the time of the call. + properties: + active: { $ref: '#/components/schemas/HMACKeyMeta' } + candidate: { $ref: '#/components/schemas/HMACKeyMeta' } + + HMACKeyCreated: + type: object + required: [kid, secret, created_at] + description: | + Response to `/rotate`. `secret` is base64-encoded raw key + material. **This is the ONLY response that ever contains + the secret value** — capture it now or generate a new one + via another /rotate call. + properties: + kid: { type: string, pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' } + secret: { type: string, description: "Base64-encoded 32-byte HMAC key." } + created_at: { type: string, format: date-time } + + HMACKeyFinalized: + type: object + required: [active_kid, created_at] + description: Response to `/rotate/finalize`. No secret. + properties: + active_kid: { type: string, pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' } + created_at: { type: string, format: date-time } + + Problem: + type: object + description: | + RFC 7807 Problem Details. + required: [type, title, status, code] + properties: + type: { type: string, format: uri } + title: { type: string } + status: { type: integer } + detail: { type: string } + code: { type: string } + instance: { type: string } + invalid_tasks: + type: array + description: Present on validation errors. + items: + type: object + required: [index, reason] + properties: + index: { type: integer } + reason: + type: string + enum: + - malformed_url + - unsupported_scheme + - missing_host + - url_too_long + - metadata_too_large + - metadata_key_invalid + - invalid_external_id + - unknown_param + - invalid_param_value + value: + type: string + description: | + Offending input. For URL / metadata reasons this is + a redacted/truncated form of the bad value. For + `unknown_param` / `invalid_param_value` this is the + param key. + additionalProperties: true + + ExportStatus: + type: string + enum: [pending, running, completed, failed] + description: | + Lifecycle state of a results export. + * `pending` — export accepted, not started yet. + * `running` — the zip is being produced. + * `completed` — `download_url` will be present. + * `failed` — `error` carries the reason. + + StartExportResponse: + type: object + required: [export_id, status, created_at, expires_at] + description: Returned by `startResultsExport`. + properties: + export_id: + type: string + description: ULID identifying this export. Use it for `getResultsExport`. + pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' + status: + $ref: '#/components/schemas/ExportStatus' + created_at: { type: string, format: date-time } + expires_at: + type: string + format: date-time + description: | + 12 h after `created_at`. Past this point the export and + its download are removed and the export id 404s. + + Export: + type: object + required: [export_id, status, created_at, expires_at] + description: | + Polled view of a results export. `download_url` is presigned + fresh on every successful poll — stash the metadata, but + re-fetch the URL right before you download. + properties: + export_id: + type: string + pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' + status: + $ref: '#/components/schemas/ExportStatus' + error: + type: string + nullable: true + description: | + Non-empty only when `status = failed`. Stable strings — + e.g. `"results are larger then 1 gb"` when the combined + results exceed the 1 GiB cap. + download_url: + type: string + format: uri + description: | + Presigned download URL for the zipped run results. + Present only when `status = completed`. Short-lived — the + server mints a new one on every poll. + created_at: { type: string, format: date-time } + expires_at: + type: string + format: date-time + description: | + 12 h after `created_at`. The download is unavailable + after this point. diff --git a/examples/01_submit_and_wait.py b/examples/01_submit_and_wait.py new file mode 100644 index 0000000..afc993d --- /dev/null +++ b/examples/01_submit_and_wait.py @@ -0,0 +1,54 @@ +"""01: Submit → wait → iterate results. + +The canonical end-to-end flow. Demonstrates: + - `submit_regular(urls, ...)` — the type-specific shortcut that + skips the `type=`/`status=` boilerplate. Accepts either bare URL + strings or inline dicts with per-task `external_id` / `metadata` + / `params`. + - The returned `JobRef` knows its client, so current-run ops chain + off the `.run` facet (`job.run.wait()` / `job.run.results()`) + without re-passing the job id. + - `job.run.wait()` returns a `RunHandle` for the run you waited on + (guaranteed `.data`), so `run.results()` and + `run.download_to_dir(...)` are one call away. + +Run with: + export ZENROWS_API_KEY=zr_... + python examples/01_submit_and_wait.py +""" + +import os + +from zenrows import ZenRowsBatchClient + + +def main() -> None: + client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"]) + + job = client.submit_regular( + [ + {"url": "https://example.com/a", "external_id": "order-1"}, + {"url": "https://example.com/b", "external_id": "order-2"}, + {"url": "https://example.com/c", "external_id": "order-3"}, + ], + zenrows_params={"js_render": "true", "premium_proxy": "true"}, + ) + print(f"submitted {job}") + + # Block until the current run is terminal (default target: + # `completed | stopped | deleted`). Internally jittered + # exponential backoff — friendly to the API for both 5-second + # and 5-minute jobs. Returns the RunHandle. + run = job.run.wait(timeout=600.0) + t = run.data.stats + print(f"run {run.run_id} {run.data.status.value}: {t.successful}/{t.total} successful") + + for row in run.results(status="successful"): + # external_id is whatever the caller supplied at submit; it + # rides through to results unmodified so caller systems can + # correlate without knowing our task_id. + print(f" {row.external_id or row.task_id} -> {row.result_url}") + + +if __name__ == "__main__": + main() diff --git a/examples/02_download_to_dir.py b/examples/02_download_to_dir.py new file mode 100644 index 0000000..3c185f8 --- /dev/null +++ b/examples/02_download_to_dir.py @@ -0,0 +1,81 @@ +"""02: Bulk download every successful body to disk. + +The Batch API doesn't ship bodies inline — results give you metadata ++ a presigned `result_url` per task. For real bulk ingestion you +usually want every body on disk; `download_to_dir` bundles +"list → presigned GET → write" into one call with built-in safety +caps + optional parallelism + progress bar. + +Highlights: + - Resource-style: `client.get_job(...)` returns a `JobHandle` so + the current-run wait + download are one chain off `.run`. + - `concurrency=N` fans body-fetches across a ThreadPool. + - `progress=True` shows a tqdm bar (soft dep — degrades to no-op + if tqdm isn't installed). + - `use_external_id=True` writes `.` filenames + instead of the default `.`. Useful when the + downstream pipeline addresses files by your own ids; ids are + coerced to safe filenames (chars outside `[A-Za-z0-9._-]` become + `_`), missing ids fall back to task_id, and clashes get `_01`, + `_02`, … appended. + - `max_files` + `max_bytes_per_file` are tunable safety caps that + raise `DownloadLimitExceeded` — a runaway job can't silently + fill the disk. + - `status="successful"` (the default) skips failed rows; pass + `status=None` to grab everything. + +Run with: + export ZENROWS_API_KEY=zr_... + python examples/02_download_to_dir.py --job-id 01J… +""" + +import argparse +import os + +from zenrows import ZenRowsBatchClient +from zenrows.batch import DownloadLimitExceeded + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--job-id", + required=True, + help="Job id whose results you want to download.", + ) + parser.add_argument( + "--out", + default="./out", + help="Target directory (default: ./out).", + ) + parser.add_argument( + "--concurrency", + type=int, + default=8, + help="Parallel body-fetch workers (default: 8).", + ) + args = parser.parse_args() + + client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"]) + job = client.get_job(args.job_id) + run = job.run.wait(timeout=600.0, progress=True) + + try: + written = run.download_to_dir( + args.out, + use_external_id=True, # name files by caller's id + concurrency=args.concurrency, # parallel body fetches + progress=True, # live tqdm bar + max_files=20_000, # cap row count + max_bytes_per_file=10 * 1024 * 1024, # cap single body @ 10 MiB + ) + print(f"wrote {written} files to {args.out}/") + except DownloadLimitExceeded as exc: + # `limit_name` is one of: max_files, max_bytes_per_file. + # `limit` + `observed` for diagnosis. + print(f"aborted: {exc.limit_name} cap hit ({exc.observed} > {exc.limit})") + raise + + +if __name__ == "__main__": + main() diff --git a/examples/03_csv_input.py b/examples/03_csv_input.py new file mode 100644 index 0000000..d392d26 --- /dev/null +++ b/examples/03_csv_input.py @@ -0,0 +1,60 @@ +"""03: Submit thousands of URLs via a CSV upload. + +For jobs with too many URLs to fit comfortably in a JSON payload, +the Batch API exposes a two-step upload flow: + + 1. POST /job_inputs → presigned PUT URL + file_input_id + 2. PUT → upload the CSV body + 3. POST /jobs → reference the file_input_id + +`upload_csv` collapses (1)+(2) into one call; `submit_regular` +takes the resulting `file_input_id` as a kwarg instead of inline +`urls`. Eligible for `regular` (closed) and `scheduled` jobs. + +The `fields` map says how to interpret the CSV. Each value is either: + - an integer → 0-based column index (works regardless of header) + - a string → the column's header name (requires `header=True`) + +`url` is required; `external_id` is optional. + +Run with: + export ZENROWS_API_KEY=zr_... + python examples/03_csv_input.py +""" + +import os +import tempfile +from pathlib import Path + +from zenrows import ZenRowsBatchClient + + +def main() -> None: + client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"]) + + # Toy CSV for the sake of the example. In real use this is a + # large file on disk you'd pass a Path to. + csv = "URL,Customer Ref\nhttps://example.com/a,cust-1\nhttps://example.com/b,cust-2\n" + with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False) as tmp: + tmp.write(csv) + csv_path = Path(tmp.name) + + file_input_id = client.upload_csv( + csv_path, + fields={"url": "URL", "external_id": "Customer Ref"}, + header=True, + ) + print(f"uploaded slot {file_input_id}") + + # `submit_regular(file_input_id=...)` — no inline urls, no + # `type=`/`status=` boilerplate. Returns a JobRef ready to + # `.run.wait()` and `.run.results()` against. + job = client.submit_regular( + file_input_id=file_input_id, + zenrows_params={"js_render": "true"}, + ) + print(f"submitted {job}") + + +if __name__ == "__main__": + main() diff --git a/examples/04_paginated_scanners.py b/examples/04_paginated_scanners.py new file mode 100644 index 0000000..c61e053 --- /dev/null +++ b/examples/04_paginated_scanners.py @@ -0,0 +1,61 @@ +"""04: Cursor-free browsing with the paginated scanners. + +Every list endpoint on the Batch API is cursor-paginated (`limit` + +`next_cursor`). Threading cursors by hand is the #1 source of bugs +in user code, so the SDK ships scanners that drain to exhaustion: + + - `client.iter_jobs(...)` → `Iterator[JobHandle]` + - `client.iter_runs(job_id)` → `Iterator[RunHandle]` + - `client.iter_results(job_id, ...)` → `Iterator[TaskResult]` + +Each is a generator — memory stays bounded; pages are fetched on +demand. Filtering kwargs (`job_type=`, `status=`) pass through to +the underlying list call. + +You still have `list_jobs / list_runs / list_results` if you want +the raw page + cursor. + +Run with: + export ZENROWS_API_KEY=zr_... + python examples/04_paginated_scanners.py + python examples/04_paginated_scanners.py --job-id 01J… +""" + +import argparse +import os + +from zenrows import ZenRowsBatchClient +from zenrows.batch import JobStatus, JobType + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--job-id", + default=None, + help="Optional job id; if set, also lists every run of that job.", + ) + args = parser.parse_args() + + client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"]) + + # Show every closed regular job, newest first. Each yielded item + # is a JobHandle with `.data` already populated from the page. + print("--- closed regular jobs ---") + for job in client.iter_jobs(job_type=JobType.REGULAR, status=JobStatus.CLOSED): + latest = job.data.latest_run + total = latest.stats.total if latest else 0 + print(f"{job.job_id} {job.status.value:<8} tasks={total}") + + # For one job, walk all its runs (handy for `/rerun`-heavy + # workflows). `client.iter_runs` yields RunHandle; `.data` is + # pre-populated from the page. + if args.job_id: + print(f"\n--- runs of {args.job_id} ---") + for run in client.iter_runs(args.job_id, page_size=50): + r = run.data + print(f" {run.run_id} #{r.run_sequence} {r.status.value}") + + +if __name__ == "__main__": + main() diff --git a/examples/05_error_handling.py b/examples/05_error_handling.py new file mode 100644 index 0000000..7ee7f21 --- /dev/null +++ b/examples/05_error_handling.py @@ -0,0 +1,49 @@ +"""05: RFC 7807 → `BatchAPIError` branching. + +Every non-2xx response from the Batch API is a Problem JSON body +with a stable `code` field. The SDK parses it once and exposes: + + - `BatchAPIError.code` — short string (`not_found`, + `idempotency_key_conflict`, + `file_input_not_found`, ...). Safe + to switch on. + - `BatchAPIError.status_code`— int from the HTTP response. + - `BatchAPIError.problem` — full `ProblemDetail` if the body + parsed; `None` otherwise. + +Switching on `code` is the recommended pattern — status codes are +not 1:1 with semantics (multiple 409s, multiple 404s, etc.). + +Run with: + export ZENROWS_API_KEY=zr_... + python examples/05_error_handling.py +""" + +import os + +from zenrows import ZenRowsBatchClient +from zenrows.batch import BatchAPIError + + +def main() -> None: + client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"]) + + try: + client.get_job("definitely-not-a-real-job-id") + except BatchAPIError as exc: + match exc.code: + case "not_found": + print(f"job missing or foreign: {exc.problem and exc.problem.detail}") + case "unauthenticated": + print("bad API key — check ZENROWS_API_KEY") + case "payment_required": + print("out of credits — top up at zenrows.com") + case _: + # Unknown code: log + re-raise so it surfaces to a + # monitoring layer instead of being silently swallowed. + print(f"unexpected {exc.status_code} ({exc.code}): {exc}") + raise + + +if __name__ == "__main__": + main() diff --git a/examples/06_retry_failed.py b/examples/06_retry_failed.py new file mode 100644 index 0000000..b53d46b --- /dev/null +++ b/examples/06_retry_failed.py @@ -0,0 +1,59 @@ +"""06: Retry only the failed tasks of a finished run. + +`job.retry_failed()` starts a fresh run that re-executes ONLY the +previous run's failed tasks (partial retry, SPEC §3.5). Successful +tasks are inherited verbatim — you don't pay to re-scrape them, and +the new run's totals already carry the prior successes. Pass +`include_pending=True` to also re-enqueue tasks that never started +(handy after a `stop()`). It's a thin shortcut for +`job.rerun(status="failed")`. + +Requires the previous run to be terminal (`completed` / `stopped`); +otherwise the API returns `409 run_not_terminal` — call +`job.run.stop()` first if it's still live. + +Run with: + export ZENROWS_API_KEY=zr_... + python examples/06_retry_failed.py --job-id 01J… +""" + +import argparse +import os + +from zenrows import ZenRowsBatchClient +from zenrows.batch import BatchAPIError + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--job-id", required=True, help="Job whose latest run finished.") + parser.add_argument( + "--include-pending", + action="store_true", + help="Also retry tasks that never started (status=failed,pending).", + ) + args = parser.parse_args() + + client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"]) + job = client.get_job(args.job_id) + + before = job.data.latest_run + if before: + t = before.stats + print(f"latest run {before.run_id}: {t.failed} failed / {t.total} total") + + try: + run = job.retry_failed(include_pending=args.include_pending) + except BatchAPIError as e: + # e.g. no_matching_tasks (nothing failed) or run_not_terminal. + print(f"retry skipped: {e.code}") + return + + print(f"retry run {run.run_id} started; waiting…") + run = run.wait(timeout=600.0) + t = run.data.stats + print(f"run {run.run_id} {run.data.status.value}: {t.successful}/{t.total} successful") + + +if __name__ == "__main__": + main() diff --git a/examples/07_hmac_rotation.py b/examples/07_hmac_rotation.py new file mode 100644 index 0000000..d3aea5d --- /dev/null +++ b/examples/07_hmac_rotation.py @@ -0,0 +1,58 @@ +"""07: Per-org HMAC key lifecycle. + +The Batch API holds an HMAC keyset per org for signing future +outbound webhooks. Two slots — `active` (used for signing) and +`candidate` (staged next-active during rotation). The state machine: + + (empty) ──rotate──► [active=K1] + [K1] ──rotate──► [active=K1, candidate=K2] + [K1,K2] ─finalize→ [active=K2] (K1 discarded) + [K1,K2] ─cancel──► [active=K1] (K2 discarded) + +The `secret` field is returned **only** in the `/rotate` response — +this is your one chance to capture it. Subsequent reads give you +just `kid` + `created_at`. + +Typical client flow: + 1. `rotate_hmac_key()` → install K2 alongside K1 in your verifier + 2. (deploy K2 everywhere) + 3. `finalize_hmac_key()` → server starts signing with K2; drop K1 + +Run with: + export ZENROWS_API_KEY=zr_... + python examples/07_hmac_rotation.py +""" + +import os + +from zenrows import ZenRowsBatchClient +from zenrows.batch import BatchAPIError + + +def main() -> None: + client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"]) + + keys = client.list_hmac_keys() + print(f"active: {keys.active and keys.active.kid}") + print(f"candidate: {keys.candidate and keys.candidate.kid}") + + # Initial generation OR staging a candidate — same endpoint, the + # server decides which based on current state. + try: + rotated = client.rotate_hmac_key() + # Capture `rotated.secret` HERE — it is not returned again. + print(f"new key kid={rotated.kid} secret={rotated.secret!r}") + except BatchAPIError as exc: + if exc.code == "candidate_pending": + print("a candidate is already staged — finalize or cancel first") + return + raise + + # Once your verifiers accept the new key, promote it: + # client.finalize_hmac_key() + # ...or back out: + # client.cancel_hmac_rotation() + + +if __name__ == "__main__": + main() diff --git a/examples/08_download_all_results.py b/examples/08_download_all_results.py new file mode 100644 index 0000000..26cfb79 --- /dev/null +++ b/examples/08_download_all_results.py @@ -0,0 +1,48 @@ +"""08: Download a whole run as one zip (server-side export). + +`download_all_results(path)` drives the async export flow: +`POST .../exports` kicks off a server-side zip of every task body, +the SDK polls until it's `completed`, then streams the presigned zip +to disk. Use this when you want a single artifact; for one file per +task instead, see `download_to_dir` (example 02). + +The zip has a 12h TTL and is capped at 1 GiB server-side — an +oversize run fails the export and raises `BatchAPIError`. For larger +runs (or one file per task), use the iterative client-side download +`download_to_dir` (example 02): it has no size limit but is slower, +fetching bodies one at a time (tunable via `concurrency=`). + +`job.run.download_all_results` targets the job's current run; address +a specific run with `client.get_run(job_id, run_id=...).download_all_results(...)`. + +Run with: + export ZENROWS_API_KEY=zr_... + python examples/08_download_all_results.py --job-id 01J… --out results.zip +""" + +import argparse +import os + +from zenrows import ZenRowsBatchClient +from zenrows.batch import BatchAPIError + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--job-id", required=True, help="Job whose latest run to export.") + parser.add_argument("--out", default="results.zip", help="Where to write the zip.") + args = parser.parse_args() + + client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"]) + job = client.get_job(args.job_id) + + try: + path = job.run.download_all_results(args.out) + print(f"wrote {path}") + except BatchAPIError as e: + # e.g. the run exceeded the 1 GiB export cap. + print(f"export failed: {e}") + + +if __name__ == "__main__": + main() diff --git a/examples/09_scheduled_jobs.py b/examples/09_scheduled_jobs.py new file mode 100644 index 0000000..fe6df1f --- /dev/null +++ b/examples/09_scheduled_jobs.py @@ -0,0 +1,140 @@ +"""09: Scheduled jobs — the full lifecycle. + +A `scheduled` job runs its task template automatically on a cadence; +each fire produces a fresh Run. This walks the actions you'll actually +use: + + - Create with the three typed cadence builders: `Rate` (fixed + interval), `Calendar` (times-of-day on a Daily/Weekly/Monthly + cadence), and `At` (a one-shot future fire). + - Attach a webhook so each fire's outcome is pushed to you — the + natural signal for a fire-and-forget job (`run.completed`, or + `run.failed` with a `failure_reason` on an account-level error). + - Manage the schedule via the `job.schedule` facet: + `pause()` / `resume()` the fires and `update()` the cadence. + (Distinct from `job.run.pause()`, which suspends the current run.) + - Inspect the Runs each fire produced and re-run a fire's failures. + - Tear it down with `delete()`. + +Run with: + export ZENROWS_API_KEY=zr_... + python examples/09_scheduled_jobs.py +""" + +import os +from datetime import datetime + +from zenrows import ZenRowsBatchClient +from zenrows.batch import At, Calendar, JobHandle, Monthly, Rate, Weekly + + +def _schedule_state(job: JobHandle) -> str: + """schedule_state is None on non-scheduled / legacy rows; here it's + always set, but keep the read total for the type-checker.""" + state = job.data.schedule_state + return state.value if state else "?" + + +def main() -> None: + client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"]) + + # --- 1. Create: a recurring job on a fixed interval ----------------- + # `Rate` fires every N minutes/hours/days from creation. Attach a + # webhook: scheduled jobs are fire-and-forget, so a webhook is how you + # learn each fire finished (`run.completed`) or auto-failed on an + # account error (`run.failed`, carrying `failure_reason`). + job = client.submit_scheduled( + Rate(every=6, unit="hour"), + [ + {"url": "https://example.com/prices", "external_id": "prices"}, + {"url": "https://example.com/stock", "external_id": "stock"}, + ], + zenrows_params={"js_render": "true"}, + name="market-poller", + webhook={"url": "https://hooks.example.com/zenrows", "signature": True}, + ) + print(f"created scheduled job {job.job_id} — every 6h ({job.status.value})") + + # --- 2. Other cadences ---------------------------------------------- + # Calendar: specific wall-clock times on a Weekly/Daily/Monthly + # cadence, in an explicit timezone. + weekly = client.submit_scheduled( + Calendar( + times_of_day=["09:00", "18:00"], + cadence=Weekly(days=["mon", "wed", "fri"]), + timezone="Europe/Berlin", + ), + ["https://example.com/report"], + name="triweekly-report", + ) + print(f"created {weekly.job_id} — Mon/Wed/Fri 09:00+18:00 Berlin") + + # Monthly on given day-of-month numbers. + monthly = client.submit_scheduled( + Calendar( + times_of_day=["00:00"], + cadence=Monthly(days=[1, 15]), + timezone="UTC", + ), + ["https://example.com/invoice"], + name="billing-scrape", + ) + print(f"created {monthly.job_id} — 1st + 15th at midnight UTC") + + # At: a single future fire (naive local datetime + timezone). + oneshot = client.submit_scheduled( + At(datetime(2026, 9, 1, 9, 0), timezone="Europe/Berlin"), + ["https://example.com/launch-day"], + name="launch-day", + ) + print(f"created {oneshot.job_id} — one-shot 2026-09-01 09:00 Berlin") + + # --- 3. Manage the schedule (via the `job.schedule` facet) ---------- + # Pause: the schedule keeps ticking server-side but fires are dropped + # until resume(). Idempotent. Each op returns a fresh JobHandle with + # the updated state. + paused = job.schedule.pause() + print(f"paused {job.job_id} (schedule_state={_schedule_state(paused)})") + + # Change the cadence while paused. An in-flight run keeps running; the + # new schedule governs only future fires. + job.schedule.update(Rate(every=30, unit="minute")) + print(f"re-scheduled {job.job_id} → every 30m") + + # Resume fires. + resumed = job.schedule.resume() + print(f"resumed {job.job_id} (schedule_state={_schedule_state(resumed)})") + + # --- 4. Fire it now and inspect the resulting run ------------------- + # A scheduled job's fires ARE runs. Rather than wait for the cadence, + # `rerun()` on a scheduled job with no prior run fires it from the + # template immediately — handy to smoke-test the job right after + # creating it. (`job.run.load()` would raise here otherwise, since + # a fresh schedule hasn't fired yet.) + run = job.rerun() + print(f"manually fired {job.job_id} → run {run.run_id}") + + run.wait(timeout=600.0) # block until this fire is terminal + s = run.data.stats + print(f" {run.data.status.value}: {s.successful}/{s.total} ok") + # A fire that auto-failed on an account error exposes why. + if run.data.status.value == "failed": + print(f" auto-failed: {run.data.failure_reason}") + # Re-run just this fire's failures — successful tasks are inherited, so + # you only pay to re-scrape what failed. + if s.failed: + retry = job.retry_failed() + print(f" retried failures → run {retry.run_id}") + + # Every fire (scheduled or manual) is a Run under the job, newest-first. + for r in job.runs(): + print(f" run {r.run_id} #{r.data.run_sequence} {r.data.status.value}") + + # --- 5. Tear down --------------------------------------------------- + for handle in (job, weekly, monthly, oneshot): + handle.delete() + print("deleted all demo jobs") + + +if __name__ == "__main__": + main() diff --git a/examples/10_lightweight_handles.py b/examples/10_lightweight_handles.py new file mode 100644 index 0000000..ec039f0 --- /dev/null +++ b/examples/10_lightweight_handles.py @@ -0,0 +1,54 @@ +"""10: Lightweight refs — act on a job/run id without a GET. + +`client.job(job_id)` and `client.run(job_id, run_id)` mint a `JobRef` / +`RunRef` with **no network call**. Lifecycle operations (`delete`, `stop`, +`close`, `rerun`, `retry_failed`, `add_tasks`) act on the id directly — so +when an id arrives from a webhook, a queue, or your own DB, you skip the +round-trip that `get_job` would spend just to fetch data you don't need. + +A ref carries no `.data`; call `.load()` for a `JobHandle` / `RunHandle` +whose `.data` snapshot is ready. Reach for `get_job` / `get_run` when you +want that data eagerly up front (they're `client.job(id).load()` in one +call). + +Run with: + export ZENROWS_API_KEY=zr_... + python examples/10_lightweight_handles.py --job-id 01J... [--run-id 01J...] [--delete] +""" + +import argparse +import os + +from zenrows import ZenRowsBatchClient + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--job-id", required=True) + ap.add_argument("--run-id", default=None, help="scrub just this run (with --delete)") + ap.add_argument("--delete", action="store_true", help="actually stop + delete (destructive)") + args = ap.parse_args() + + client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"]) + + # Minting a ref is free — no request goes out on this line. + job = client.job(args.job_id) + + # `.load()` does the one GET, returning a JobHandle with `.data`. + print(f"{args.job_id}: status={job.load().data.status.value}") + + if args.delete: + # Act on the id directly — each of these is a single request with no + # preceding GET (contrast `get_job(id).delete()`, which fetches first). + if args.run_id: + client.run(args.job_id, args.run_id).delete() # DELETE one run only + print(f"deleted run {args.run_id}") + client.job(args.job_id).run.stop() # POST /jobs/{id}/stop (current run) + client.job(args.job_id).delete() # DELETE /jobs/{id} + print(f"stopped + deleted {args.job_id}") + else: + print("(re-run with --delete to stop + delete via GET-free refs)") + + +if __name__ == "__main__": + main() diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..ca93890 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,32 @@ +# Examples + +Runnable samples for the Batch API client (`ZenRowsBatchClient`). +Each script reads the API key from the environment: + +```bash +export ZENROWS_API_KEY=zr_... +``` + +Script-specific flags (`--job-id`, `--out`, …) are surfaced via +`argparse`; each file's `--help` lists them. + +```bash +uv run python examples/01_submit_and_wait.py +uv run python examples/02_download_to_dir.py --job-id 01J... +``` + +| Sample | Highlights | +|---|---| +| `01_submit_and_wait.py` | `submit_regular` → `job.wait()` → `run.results()` | +| `02_download_to_dir.py` | bulk download to disk with `concurrency=` + `progress=` | +| `03_csv_input.py` | `upload_csv` helper end-to-end (slot + PUT + submit) | +| `04_paginated_scanners.py` | `iter_jobs` + `iter_runs` for cursor-free browsing | +| `05_error_handling.py` | RFC 7807 → `BatchAPIError.code` branching | +| `06_retry_failed.py` | `retry_failed()` — partial rerun of only the failed tasks | +| `07_hmac_rotation.py` | rotate / finalize / cancel lifecycle | +| `08_download_all_results.py` | `download_all_results()` — server-side export zip of a whole run | +| `09_scheduled_jobs.py` | scheduled cadences (`Rate`/`Calendar`/`At`), `pause`/`resume`/`update_schedule`, per-fire runs, `retry_failed` | +| `10_lightweight_handles.py` | `client.job(id)` / `client.run(id, rid)` — act on a known id without a GET (`.data` lazily fetches) | + +The samples are deliberately small — each demonstrates one feature +end-to-end so they double as living documentation. diff --git a/flake8 b/flake8 deleted file mode 100644 index 6deafc2..0000000 --- a/flake8 +++ /dev/null @@ -1,2 +0,0 @@ -[flake8] -max-line-length = 120 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6cd8752 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,114 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "zenrows" +version = "1.4.0" +description = "ZenRows Python SDK — synchronous scraping + Batch (async job) API" +readme = "README.md" +license = { text = "MIT" } +authors = [ + { name = "ZenRows", email = "support@zenrows.com" }, +] +keywords = ["zenrows", "scraper", "scraping", "async", "batch"] +classifiers = [ + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Software Development :: Libraries :: Python Modules", +] +requires-python = ">=3.10" +dependencies = [ + # Legacy scraping client (zenrows.ZenRowsClient). + "requests>=2.31", + # Batch (zenrows.batch) — pydantic v2 models + httpx transport. + "httpx>=0.27", + "pydantic>=2.9", + # Progress bars for `wait_for_run` / `download_to_*`. Tiny, no + # transitive deps; users in minimal envs can still uninstall it + # — the helpers lazy-import and degrade to no-op. + "tqdm>=4.66", + # `typing.NotRequired` is 3.11+; on 3.10 it comes from here. + # (pydantic already pulls this transitively — declared explicitly.) + "typing-extensions>=4.0; python_version < '3.11'", +] + +[project.urls] +Homepage = "https://www.zenrows.com/" +Documentation = "https://www.zenrows.com/documentation" +Repository = "https://github.com/ZenRows/zenrows-python-sdk" +Issues = "https://github.com/ZenRows/zenrows-python-sdk/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/zenrows"] + +[tool.hatch.build.targets.sdist] +include = ["src/zenrows", "README.md", "LICENSE"] + +# --- uv --- +[tool.uv] +dev-dependencies = [ + "pytest>=8", + "pytest-asyncio>=0.24", + "pytest-httpx>=0.30", + "ruff>=0.7", + "respx>=0.21", + # Astral static type checker (preview). Dev-only — never ships. + # Unpinned on purpose: we ride latest. It moves fast; if a release + # turns `make check` red, that's the signal, not a regression to revert. + "ty", + # OpenAPI → pydantic-v2 models. Run via `uv run make-models` + # (Makefile recipe). Output lands at src/zenrows/batch/models.py. + "datamodel-code-generator>=0.26", +] + +# --- ruff --- +[tool.ruff] +line-length = 100 +target-version = "py310" +# Generated code is owned by the codegen tool; don't lint it. +extend-exclude = ["src/zenrows/batch/models.py"] + +[tool.ruff.lint] +# Tasteful default. Add rules over time; don't import-storm now. +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "W", # pycodestyle warnings + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade — keeps us on modern syntax (no __future__) + "SIM", # flake8-simplify + "RUF", # ruff-specific +] +ignore = [ + # We deliberately don't use `from __future__ import annotations` — + # 3.10 is the floor and `X | Y` is native. + "UP037", +] + +[tool.ruff.lint.isort] +known-first-party = ["zenrows"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +# --- ty (static type checker) --- +[tool.ty.src] +# Generated code is owned by the codegen tool; don't type-check it +# (still imported for type resolution — exclude only suppresses its diagnostics). +exclude = ["src/zenrows/batch/models.py"] + +# --- pytest --- +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra --strict-markers" +asyncio_mode = "auto" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 0c27a5b..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -flake8==7.1.1 -wheel==0.46.2 diff --git a/scripts/build_reference.py b/scripts/build_reference.py new file mode 100644 index 0000000..3e1d5ec --- /dev/null +++ b/scripts/build_reference.py @@ -0,0 +1,73 @@ +"""Generate docs/batch-client-reference.md — a PUBLIC-facing markdown API reference. + +Renders each public module with pydoc-markdown, then presents it as the +public import surface (`from zenrows.batch import ...`) rather than where +things physically live: + + - the internal module headers (`# zenrows.batch._resources`) become + friendly section titles, and + - the `zenrows.batch._x.` qualifiers are stripped from anchors and inline + references, + +so the reference never exposes the private `_module` layout. The modules +stay private on purpose (curated `__all__` re-export from the package) — +this only fixes how the docs are presented. + +Run via `make docs` (which supplies pydoc-markdown through `uv --with`). +""" + +import re +import subprocess +import sys + +# (module, public section title) — order defines the reference layout. +SECTIONS = [ + ("zenrows.batch.client", "Client"), + ("zenrows.batch._resources", "Job, run & export handles"), + ("zenrows.batch._waiters", "Waiters"), + ("zenrows.batch._download", "Downloads"), + ("zenrows.batch._estimate", "Cost estimation"), + ("zenrows.batch._schedule", "Schedule builders"), + ("zenrows.batch.errors", "Errors"), + ("zenrows.batch.models", "Models"), +] + +HEADER = ( + "# ZenRows Batch — Python SDK Reference\n\n" + "_Auto-generated from the SDK docstrings via `make docs`. Do not edit by " + "hand. Everything below is imported from the top-level `zenrows.batch` " + "package (`from zenrows.batch import ...`)._\n" +) + +# Member-qualified references like `zenrows.batch._resources.JobHandle` +# (anchors + inline) → bare `JobHandle` (the public import name). +_QUALIFIER = re.compile(r"zenrows\.batch\.[A-Za-z_][A-Za-z0-9_]*\.") + + +def render(module: str, title: str) -> str: + out = subprocess.run( + ["pydoc-markdown", "-m", module, "-I", "src"], + capture_output=True, + text=True, + check=True, + ).stdout + # Drop the leading module anchor + `# zenrows.batch.` header and put + # the friendly section title in its place. + out = re.sub( + r'\A\n\n# [^\n]*\n', + f"# {title}\n", + out, + count=1, + ) + # Strip the private/module qualifiers everywhere else. + out = _QUALIFIER.sub("", out) + return out.rstrip() + "\n" + + +def main() -> None: + parts = [HEADER, *(render(mod, title) for mod, title in SECTIONS)] + sys.stdout.write("\n".join(parts)) + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py deleted file mode 100644 index 588730b..0000000 --- a/setup.py +++ /dev/null @@ -1,42 +0,0 @@ -import os -from setuptools import setup - -from zenrows.__version__ import __version__ - - -def read(fname): - return open(os.path.join(os.path.dirname(__file__), fname)).read() - - -setup( - name="zenrows", - version=__version__, - author="Ander Rodriguez", - author_email="ander@zenrows.com", - description="Python client for ZenRows API", - license="MIT", - keywords="zenrows scraper scraping", - url="https://github.com/ZenRows/zenrows-python-sdk", - project_urls={ - "Bug Tracker": "https://github.com/ZenRows/zenrows-python-sdk/issues", - "Documentation": "https://www.zenrows.com/documentation", - }, - packages=["zenrows"], - long_description=read("README.md"), - long_description_content_type="text/markdown", - classifiers=[ - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Topic :: Internet :: Proxy Servers", - "Topic :: Internet :: WWW/HTTP", - "Topic :: Software Development :: Libraries :: Python Modules", - ], - python_requires=">=3.6", - install_requires=["requests"], -) diff --git a/src/zenrows/__init__.py b/src/zenrows/__init__.py new file mode 100644 index 0000000..312ed5e --- /dev/null +++ b/src/zenrows/__init__.py @@ -0,0 +1,17 @@ +"""ZenRows Python SDK. + +Two clients live here: + + - `ZenRowsClient` — the original synchronous scraping API client. + Backward-compatible with pre-1.4 usage; freshened-up internals. + + - `ZenRowsBatchClient` — the new async-job / batch API client. + Built on a generated OpenAPI core with a hand-written ergonomic + facade on top. +""" + +from zenrows.__version__ import __version__ +from zenrows.batch import ZenRowsBatchClient +from zenrows.client import ZenRowsClient + +__all__ = ["ZenRowsBatchClient", "ZenRowsClient", "__version__"] diff --git a/zenrows/__version__.py b/src/zenrows/__version__.py similarity index 100% rename from zenrows/__version__.py rename to src/zenrows/__version__.py diff --git a/src/zenrows/batch/__init__.py b/src/zenrows/batch/__init__.py new file mode 100644 index 0000000..1a0ebe7 --- /dev/null +++ b/src/zenrows/batch/__init__.py @@ -0,0 +1,132 @@ +"""Public surface for the ZenRows Batch (async-job) API. + +What's where: + - `client.ZenRowsBatchClient` — the friendly typed facade. One + method per OpenAPI operation, returning real pydantic models. + - `models` — pydantic v2 models, regenerated from the backend's + canonical `../docs/openapi.yaml` via `make generate`. Do NOT hand-edit. + - `errors.BatchAPIError` / `errors.ProblemDetail` — RFC 7807 mapping. + - `_transport._Transport` — httpx wrapper; internal. + +Re-exports below are the curated, stable surface. Anything in +`models` that callers need but isn't here can be imported directly. + +Completion is surfaced via waiters (`job.wait()` / `run.wait()`) and +webhooks — poll or get notified; there's no callback to wire up. +""" + +from zenrows.batch._download import DownloadedResult, DownloadLimitExceeded +from zenrows.batch._estimate import ( + CostEstimate, + CostLine, + TaskCost, + Tier, +) +from zenrows.batch._resources import ( + CurrentRun, + ExportHandle, + ExportRef, + JobHandle, + JobRef, + RunHandle, + RunRef, + ScheduleControls, +) +from zenrows.batch._schedule import ( + At, + Cadence, + Calendar, + Daily, + Monthly, + Rate, + Schedule, + Weekly, +) +from zenrows.batch._typed_dicts import ( + AddTasksDict, + CreateJobInputDict, + CSVFieldsDict, + CSVSpecDict, + JobScheduleDict, + SubmitJobDict, + TaskInputDict, + WebhookDict, +) +from zenrows.batch._waiters import WaiterError, WaiterTimeout +from zenrows.batch.client import TERMINAL_RUN_STATUSES, ZenRowsBatchClient +from zenrows.batch.errors import BatchAPIError, ProblemDetail +from zenrows.batch.models import ( + AddTasksRequest, + CreateJobInputRequest, + CreateJobInputResponse, + IngestStatus, + Job, + JobStatus, + JobType, + Run, + RunStatus, + SubmitJobRequest, + SubmitJobResponse, + TaskInput, + TaskResult, + TaskStatus, + TestWebhookRequest, + TestWebhookResponse, + WebhookConfig, +) + +__all__ = [ + "TERMINAL_RUN_STATUSES", + "AddTasksDict", + "AddTasksRequest", + "At", + "BatchAPIError", + "CSVFieldsDict", + "CSVSpecDict", + "Cadence", + "Calendar", + "CostEstimate", + "CostLine", + "CreateJobInputDict", + "CreateJobInputRequest", + "CreateJobInputResponse", + "CurrentRun", + "Daily", + "DownloadLimitExceeded", + "DownloadedResult", + "ExportHandle", + "ExportRef", + "IngestStatus", + "Job", + "JobHandle", + "JobRef", + "JobScheduleDict", + "JobStatus", + "JobType", + "Monthly", + "ProblemDetail", + "Rate", + "Run", + "RunHandle", + "RunRef", + "RunStatus", + "Schedule", + "ScheduleControls", + "SubmitJobDict", + "SubmitJobRequest", + "SubmitJobResponse", + "TaskCost", + "TaskInput", + "TaskInputDict", + "TaskResult", + "TaskStatus", + "TestWebhookRequest", + "TestWebhookResponse", + "Tier", + "WaiterError", + "WaiterTimeout", + "WebhookConfig", + "WebhookDict", + "Weekly", + "ZenRowsBatchClient", +] diff --git a/src/zenrows/batch/_download.py b/src/zenrows/batch/_download.py new file mode 100644 index 0000000..b06ff50 --- /dev/null +++ b/src/zenrows/batch/_download.py @@ -0,0 +1,469 @@ +"""Download helpers for the Batch SDK. + +Every helper fetches task bodies straight from each result's presigned +`result_url` (a signed storage URL) — no auth header, no API content +endpoint in the loop. + +Bulk, both with optional concurrency + a tqdm progress bar: + + - `download_to_dir(...)` writes every task body to a directory on + disk. Streams one task at a time so memory stays bounded; safety + caps are `max_files` (count) and `max_bytes_per_file` (per body). + - `download_to_memory(...)` loads bodies into a list of + `DownloadedResult`. Has BOTH `max_count` and `max_total_bytes` + safety caps; refuses to start (or raises mid-stream) before + exhausting RAM. + +Single task (for a `TaskResult` you already hold from `results()`): + + - `download_task_to_file(task, target)` writes one body to a path or + an open binary file object. + - `download_task_to_memory(task)` returns one body as raw `bytes`. + +`concurrency > 1` fans the body GETs out across a `ThreadPoolExecutor`. +Results are NOT guaranteed to be in iteration order in either bulk +flavour. The iteration of `iter_results` itself stays single-threaded +so we never page faster than we drain. + +`progress=True` installs a `rich.progress` bar if `rich` is +importable; falls back to no-op if it isn't (so the SDK works in +minimal envs without a hard dep). +""" + +import os +import threading +from collections.abc import Callable, Iterable, Iterator +from concurrent.futures import ThreadPoolExecutor, as_completed +from contextlib import contextmanager, nullcontext +from dataclasses import dataclass +from pathlib import Path +from typing import IO, Any, Protocol + +import httpx + +from zenrows.batch.errors import BatchAPIError +from zenrows.batch.models import TaskResult, TaskStatus + + +class _BodyFetcher(Protocol): + """The slice of `ZenRowsBatchClient` the helpers need — just result + pagination. Bodies are pulled from each row's presigned `result_url`, + so no content endpoint is involved. + + Declared so unit tests can substitute a fake without spinning up + the whole client. + """ + + def _iter_results_raw( + self, + job_id: str, + *, + run_id: str | None = ..., + status: str | None = ..., + ) -> Iterator[TaskResult]: ... + + +@dataclass(slots=True) +class DownloadedResult: + """One row's worth of downloaded content held in memory.""" + + task_id: str + external_id: str | None + content_type: str + body: bytes + + +# Safety caps. Deliberate, conservative defaults — bulk downloads of +# scraping jobs can easily reach gigabytes if not bounded. +DEFAULT_MAX_FILES = 100_000 +DEFAULT_MAX_BYTES_PER_FILE = 50 * 1024 * 1024 # 50 MiB +DEFAULT_MAX_COUNT_IN_MEMORY = 10_000 +DEFAULT_MAX_TOTAL_BYTES_IN_MEMORY = 500 * 1024 * 1024 # 500 MiB + + +class DownloadLimitExceeded(RuntimeError): + """Raised when a download exceeds a configured cap.""" + + def __init__(self, limit_name: str, limit: int, observed: int): + self.limit_name = limit_name + self.limit = limit + self.observed = observed + super().__init__(f"download: {limit_name} cap exceeded ({observed} > {limit})") + + +# ----- to-disk ----- + + +def download_to_dir( + client: _BodyFetcher, + job_id: str, + target_dir: Path, + *, + run_id: str | None = None, + status: str | None = TaskStatus.SUCCESSFUL.value, + name_fn: Callable[[TaskResult], str] | None = None, + use_external_id: bool = False, + concurrency: int = 1, + progress: bool = False, + max_files: int = DEFAULT_MAX_FILES, + max_bytes_per_file: int = DEFAULT_MAX_BYTES_PER_FILE, +) -> int: + """Stream every task's body into `target_dir`. Returns the count. + + `concurrency` parallelises the body-fetch + write. With the + default 1, behaviour is exactly serial. With N>1, up to N bodies + are fetched in flight; iteration of the result list stays + single-threaded so we never out-pace the server's pagination. + + `progress=True` shows a live count via `tqdm` if it's + importable. + + See module docstring for the naming + capping rules. + """ + target_dir = Path(target_dir) + target_dir.mkdir(parents=True, exist_ok=True) + if not name_fn: + name_fn = _external_id_filename if use_external_id else _default_filename + + # `external_id` is NOT unique on the server side (callers can + # reuse it), so multiple rows can ask for the same filename. + # The allocator hands the first claimer the bare name and tacks + # `_01`, `_02`, … on subsequent claimers. Thread-safe under + # concurrency > 1. + allocator = _NameAllocator() + + def handle(row: TaskResult) -> int: + body, _ct = _fetch_result_url(row) + if len(body) > max_bytes_per_file: + raise DownloadLimitExceeded("max_bytes_per_file", max_bytes_per_file, len(body)) + chosen = allocator.claim(name_fn(row)) + path = target_dir / chosen + # parent dirs may have been requested by a custom name_fn + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(body) + return 1 + + return _run_bulk( + client, + job_id, + run_id=run_id, + status=status, + per_row=handle, + concurrency=concurrency, + progress=progress, + max_rows=max_files, + max_rows_limit_name="max_files", + progress_description="downloading to disk", + ) + + +def _default_filename(row: TaskResult) -> str: + """`.`. Server-assigned ULID — collision-free.""" + return f"{row.task_id}.{_ext_for(row)}" + + +def _external_id_filename(row: TaskResult) -> str: + """`.`, coerced to a filesystem-safe name. + + `external_id` is caller-controlled *data* and can legitimately hold + characters that aren't valid in a filename (spaces, slashes, …), so + when writing files we **coerce** rather than reject: every char + outside `[A-Za-z0-9._-]` becomes `_`, and a missing/empty + external_id falls back to `task_id`. (Validating here would fail on + data the API already accepted; enforce clean ids at submit time if + you need to.) Pass a custom `name_fn` for different behaviour. + + `external_id` is not server-enforced unique; the name allocator + (see `_NameAllocator`) handles cross-row collisions by appending + `_01`, `_02`, … to later claimers. + """ + raw = row.external_id or "" + safe = "".join(c if (c.isalnum() or c in "._-") else "_" for c in raw) + base = safe or row.task_id + return f"{base}.{_ext_for(row)}" + + +class _NameAllocator: + """Hands out unique filenames against a running set. + + `claim("order-1.html")` returns `"order-1.html"` the first time + and `"order-1_01.html"`, `"order-1_02.html"`, … on subsequent + calls with the same input. Thread-safe; `download_to_dir` with + `concurrency=N` shares one allocator across workers. + + Doesn't peek at the filesystem — only the in-memory counter + matters. Pre-existing files in `target_dir` from a previous run + are NOT considered for collision; if you re-download into a + populated directory, you'll overwrite the first occurrence of + each name. Use a fresh dir if that matters. + """ + + def __init__(self) -> None: + self._counts: dict[str, int] = {} + self._lock = threading.Lock() + + def claim(self, name: str) -> str: + with self._lock: + n = self._counts.get(name, 0) + self._counts[name] = n + 1 + if n == 0: + return name + stem, ext = os.path.splitext(name) + return f"{stem}_{n:02d}{ext}" + + +def _ext_for(row: TaskResult) -> str: + """File extension for one result. `row.type` is the pydantic + `ResultType` enum or None; failed-but-downloaded rows have no + type and fall back to `.bin`.""" + if not row.type: + return "bin" + return row.type.value + + +# ----- single task (straight from the presigned result_url) ----- +# +# NOTE (please read before "improving" this): the API also exposes a +# `GET .../tasks/{id}/content` endpoint, but it exists for the **web UI** +# — it proxies the body back through the API with display-friendly +# headers so a browser can render it inline. The SDK deliberately does +# NOT use it: `result_url` is a presigned storage URL, so fetching it is +# one hop straight to the bucket (no API round-trip, no auth header, and +# it keeps body bandwidth off the API). Do not reintroduce a +# `/content`-based download path here — pull from `result_url`. + + +def _fetch_result_url(task: TaskResult) -> tuple[bytes, str]: + """GET a task's presigned `result_url` directly — no API round-trip + and no auth header (it's a signed storage URL). Returns + `(body, content_type)`. Raises `ValueError` when the task carries no + result (e.g. a failed task).""" + if not task.result_url: + raise ValueError( + f"task {task.task_id!r} has no result_url — " + "only successful tasks have a downloadable body" + ) + with httpx.Client(timeout=httpx.Timeout(60.0)) as bare: + r = bare.get(str(task.result_url)) + if r.status_code >= 400: + raise BatchAPIError(r.status_code, problem=None, raw=r.content) + return r.content, r.headers.get("Content-Type", "") + + +def download_task_to_file(task: TaskResult, target: "str | Path | IO[bytes]") -> None: + """Download one task's body straight from its `result_url` and write + it to `target` — a filesystem path (`str` / `Path`) or an already-open + binary file object.""" + body, _ct = _fetch_result_url(task) + if isinstance(target, (str, Path)): + path = Path(target) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(body) + else: + target.write(body) + + +def download_task_to_memory(task: TaskResult) -> bytes: + """Download one task's body straight from its `result_url` and + return the raw bytes.""" + return _fetch_result_url(task)[0] + + +# ----- to-memory ----- + + +def download_to_memory( + client: _BodyFetcher, + job_id: str, + *, + run_id: str | None = None, + status: str | None = TaskStatus.SUCCESSFUL.value, + concurrency: int = 1, + progress: bool = False, + max_count: int = DEFAULT_MAX_COUNT_IN_MEMORY, + max_total_bytes: int = DEFAULT_MAX_TOTAL_BYTES_IN_MEMORY, + max_bytes_per_file: int = DEFAULT_MAX_BYTES_PER_FILE, +) -> list[DownloadedResult]: + """Load every (matching) task body into a list and return it. + + Three independent caps that all raise `DownloadLimitExceeded`: + - `max_count` — how many rows total + - `max_total_bytes` — running sum of body sizes + - `max_bytes_per_file` — any single oversize body aborts + + `concurrency` + `progress` work the same as on `download_to_dir`. + Returned list ordering is NOT guaranteed when concurrency > 1. + """ + out: list[DownloadedResult] = [] + total = [0] # boxed so the closure can mutate + + def handle(row: TaskResult) -> int: + body, content_type = _fetch_result_url(row) + if len(body) > max_bytes_per_file: + raise DownloadLimitExceeded("max_bytes_per_file", max_bytes_per_file, len(body)) + total[0] += len(body) + if total[0] > max_total_bytes: + raise DownloadLimitExceeded("max_total_bytes", max_total_bytes, total[0]) + out.append( + DownloadedResult( + task_id=row.task_id, + external_id=row.external_id, + content_type=content_type, + body=body, + ) + ) + return 1 + + _run_bulk( + client, + job_id, + run_id=run_id, + status=status, + per_row=handle, + concurrency=concurrency, + progress=progress, + max_rows=max_count, + max_rows_limit_name="max_count", + progress_description="downloading to memory", + ) + return out + + +# ----- shared driver ----- + + +def _run_bulk( + client: _BodyFetcher, + job_id: str, + *, + run_id: str | None, + status: str | None, + per_row: Callable[[TaskResult], int], + concurrency: int, + progress: bool, + max_rows: int, + max_rows_limit_name: str, + progress_description: str, +) -> int: + """Iterate results + apply `per_row` to each, with optional + parallelism + progress bar. + + Returns the number of rows processed. + + Cap checking is done *here* rather than in `per_row` so the + count is authoritative even when the closures run out of order. + """ + rows = client._iter_results_raw(job_id, run_id=run_id, status=status) + + with _maybe_progress(progress, progress_description) as advance: + if concurrency <= 1: + written = 0 + for row in rows: + if written >= max_rows: + raise DownloadLimitExceeded(max_rows_limit_name, max_rows, written + 1) + per_row(row) + written += 1 + advance(1) + return written + + return _run_parallel( + rows, + per_row=per_row, + concurrency=concurrency, + max_rows=max_rows, + max_rows_limit_name=max_rows_limit_name, + advance=advance, + ) + + +def _run_parallel( + rows: Iterable[TaskResult], + *, + per_row: Callable[[TaskResult], int], + concurrency: int, + max_rows: int, + max_rows_limit_name: str, + advance: Callable[[int], None], +) -> int: + """Fan `per_row` across a thread pool. + + We submit at most `concurrency * 2` futures at a time so the + upstream paginator doesn't race ahead and buffer the entire + result set in memory. The pool is sized to `concurrency`; the + extra slack absorbs latency without unbounded queuing. + """ + written = 0 + in_flight: set[Any] = set() + pool = ThreadPoolExecutor(max_workers=concurrency) + iterator = iter(rows) + try: + # Prime the pool. + for _ in range(concurrency * 2): + try: + row = next(iterator) + except StopIteration: + break + if written + len(in_flight) >= max_rows: + raise DownloadLimitExceeded(max_rows_limit_name, max_rows, max_rows + 1) + in_flight.add(pool.submit(per_row, row)) + + while in_flight: + # Drain completed futures one at a time so we can keep + # filling the pool incrementally. + done = next(as_completed(in_flight)) + in_flight.discard(done) + done.result() # re-raises any worker exception + written += 1 + advance(1) + + # Refill — but stop submitting once we've reached the cap. + if written + len(in_flight) >= max_rows: + # Drain the rest of the in-flight futures; do NOT + # submit more. + continue + try: + row = next(iterator) + except StopIteration: + continue + in_flight.add(pool.submit(per_row, row)) + finally: + pool.shutdown(wait=True) + return written + + +@contextmanager +def _maybe_progress(enabled: bool, description: str): + """Yields an `advance(n)` callable. With `tqdm` installed and + `enabled=True`, drives a `tqdm` bar; otherwise a no-op closure. + + `tqdm` is a soft dependency — leaving it out is fine, you just + don't get a progress bar.""" + if not enabled: + with nullcontext(): + yield lambda _n: None + return + try: + from tqdm.auto import tqdm + except ImportError: + # tqdm not installed — quietly degrade. + with nullcontext(): + yield lambda _n: None + return + + # `total=None` shows an indeterminate spinner that fills in once + # the count is known; we don't know it upfront because the result + # set is streamed via cursor pagination. + with tqdm(total=None, desc=description, unit="task") as bar: + yield bar.update + + +__all__ = [ + "DEFAULT_MAX_BYTES_PER_FILE", + "DEFAULT_MAX_COUNT_IN_MEMORY", + "DEFAULT_MAX_FILES", + "DEFAULT_MAX_TOTAL_BYTES_IN_MEMORY", + "DownloadLimitExceeded", + "DownloadedResult", + "download_to_dir", + "download_to_memory", +] diff --git a/src/zenrows/batch/_estimate.py b/src/zenrows/batch/_estimate.py new file mode 100644 index 0000000..9deb559 --- /dev/null +++ b/src/zenrows/batch/_estimate.py @@ -0,0 +1,272 @@ +"""Client-side cost estimation for Batch jobs. + +The Batch API prices a scrape per **successful request**, driven by two +scraper knobs (`js_render`, `premium_proxy`) plus the dynamic +`mode=auto`. The rate card is small, stable, and identical for every +caller, so estimation lives client-side rather than behind a network +round-trip — there is intentionally no `POST /v1/jobs/estimate` +endpoint. + +What an estimate answers: *if every URL succeeds exactly once, what +is the charge?* It is **not** a consumption forecast — it ignores +failures, `retries`, and `reruns`, which affect realized usage but +not the per-success price. Realized cost is whatever teller bills +post-factum; this is advisory. + +The math is a sum of per-task intervals: + + task in `mode=auto` → [1, 25] (dynamic, billed post-factum) + otherwise, by merged flags → exact 1 | 5 | 10 | 25 + + job.min = Σ taskᵢ.min job.max = Σ taskᵢ.max + +An estimate is *exact* (`min == max`) iff it contains no auto tasks; +the interval width is exactly `24 x (number of auto tasks)`. + +`mode=auto` and the explicit `js_render` / `premium_proxy` flags are +mutually exclusive at submit, so every task sits in +exactly one tier. If a malformed param map carries both, `auto` +wins here (it's what the engine would honor) — but the server would +reject that body at submit anyway. + +The per-task `method` / `body` fields (POST tasks) do NOT affect the +rate card — pricing is driven by the flags above regardless of HTTP +method, and the render-tier combinations the platform can't execute +for POST (`js_render`, `js_instructions`, `json_response`) are +rejected at submit, so a priced job is a billable job. +""" + +from collections.abc import Iterable +from dataclasses import dataclass +from enum import Enum + +from zenrows.batch.models import TaskInput + +# Credits charged per *successful* request, by configuration. +BASE_CREDITS = 1 +JS_CREDITS = 5 +PREMIUM_PROXY_CREDITS = 10 +JS_AND_PROXY_CREDITS = 25 +# `mode=auto` is charged dynamically post-factum, anywhere in this range. +AUTO_MIN_CREDITS = 1 +AUTO_MAX_CREDITS = 25 + +# Param values arrive as str | bool | int. These spellings +# count as "on" for the boolean flags. +_TRUTHY = frozenset({"true", "1", "yes", "on"}) + +# What `Tier` keys can appear, in the order a breakdown should render. +ParamValue = str | bool | int | dict[str, str] # dict form: `custom_headers` map +ParamMap = dict[str, ParamValue] +TaskLike = str | TaskInput | dict + + +class Tier(str, Enum): + """The pricing tier a task falls into. Exactly one per task.""" + + BASE = "base" + JS = "js_render" + PREMIUM = "premium_proxy" + JS_AND_PREMIUM = "js_render+premium_proxy" + AUTO = "auto" + + +# Stable render order for breakdown lines. +_TIER_ORDER = ( + Tier.BASE, + Tier.JS, + Tier.PREMIUM, + Tier.JS_AND_PREMIUM, + Tier.AUTO, +) + + +@dataclass(slots=True, frozen=True) +class TaskCost: + """The credit interval for a single task. `min == max` for every + tier except `auto`.""" + + tier: Tier + min: int + max: int + + @property + def exact(self) -> bool: + return self.min == self.max + + +@dataclass(slots=True, frozen=True) +class CostLine: + """One row of a breakdown: all tasks sharing a tier, aggregated.""" + + tier: Tier + count: int + unit_min: int + unit_max: int + + @property + def subtotal_min(self) -> int: + return self.count * self.unit_min + + @property + def subtotal_max(self) -> int: + return self.count * self.unit_max + + @property + def exact(self) -> bool: + return self.unit_min == self.unit_max + + +@dataclass(slots=True, frozen=True) +class CostEstimate: + """Result of `client.estimate_cost`. Credits assuming every task succeeds + once. `min == max` (`exact`) when no task uses `mode=auto`. + + Designed-for-later (not built today): money pricing. Credits are + the only unit now. Money would layer in as `money = credits x + price_per_credit`, where the per-credit price is plan-dependent and + not part of the static rate card. The natural, non-breaking + extension is additive fields — e.g. an optional `money_min` / + `money_max` (or a nested `money` object) on this class and a + matching subtotal on `CostLine` — populated only once a per-credit + price is supplied. Nothing here is renamed to a credit-specific + name precisely so that addition reads naturally.""" + + task_count: int + min: int + max: int + breakdown: tuple[CostLine, ...] + + @property + def exact(self) -> bool: + """True when the charge is a single number (no auto tasks).""" + return self.min == self.max + + @property + def auto_tasks(self) -> int: + """How many tasks use `mode=auto` — the only source of range.""" + return sum(line.count for line in self.breakdown if line.tier is Tier.AUTO) + + def __str__(self) -> str: + credits = f"{self.min}" if self.exact else f"{self.min}-{self.max}" + return f"{credits} credits ({self.task_count} tasks)" + + def format(self) -> str: + """Multi-line breakdown table, e.g.:: + + 1000 tasks → 4800-6000 credits + 950 x base (1) = 950 + 50 x auto (1-25) = 50-1250 + """ + head = f"{self.task_count} tasks → {self}" + lines = [head] + for line in self.breakdown: + unit = f"{line.unit_min}" if line.exact else f"{line.unit_min}-{line.unit_max}" + sub = ( + f"{line.subtotal_min}" if line.exact else f"{line.subtotal_min}-{line.subtotal_max}" + ) + lines.append(f" {line.count:>6} x {line.tier.value} ({unit}) = {sub}") + return "\n".join(lines) + + +def _truthy(value: ParamValue | None) -> bool: + """Coerce a scraper-param scalar to a boolean. Booleans pass + through; ints are nonzero-truthy; strings match the on-spellings.""" + if isinstance(value, bool): + return value + if isinstance(value, int): + return value != 0 + if isinstance(value, str): + return value.strip().lower() in _TRUTHY + return False + + +def _is_auto(params: ParamMap) -> bool: + return str(params.get("mode", "")).strip().lower() == "auto" + + +def _cost_for_params(params: ParamMap) -> TaskCost: + """Price one task from its **merged** scraper params (job-level + overlaid with per-task, task wins).""" + if _is_auto(params): + return TaskCost(Tier.AUTO, AUTO_MIN_CREDITS, AUTO_MAX_CREDITS) + js = _truthy(params.get("js_render")) + px = _truthy(params.get("premium_proxy")) + if js and px: + return TaskCost(Tier.JS_AND_PREMIUM, JS_AND_PROXY_CREDITS, JS_AND_PROXY_CREDITS) + if px: + return TaskCost(Tier.PREMIUM, PREMIUM_PROXY_CREDITS, PREMIUM_PROXY_CREDITS) + if js: + return TaskCost(Tier.JS, JS_CREDITS, JS_CREDITS) + return TaskCost(Tier.BASE, BASE_CREDITS, BASE_CREDITS) + + +def _task_params(task: TaskLike) -> ParamMap: + """Pull per-task `zenrows_params` from any accepted task shape.""" + if isinstance(task, str): + return {} + if isinstance(task, TaskInput): + return dict(task.zenrows_params or {}) + if isinstance(task, dict): + return dict(task.get("zenrows_params") or {}) + raise TypeError(f"unsupported task type for estimation: {type(task).__name__}") + + +def _estimate_cost( + tasks: Iterable[TaskLike], + *, + zenrows_params: ParamMap | None = None, +) -> CostEstimate: + """Estimate the credit cost of a job, assuming every task succeeds + once. Pure and offline — no network call. + + `tasks` is the same shape `submit_regular` accepts: bare URL + strings, ``TaskInput`` models, or task dicts. Per-task + ``zenrows_params`` override the job-level ``zenrows_params`` on key + collision (task wins), matching the worker's merge. + + Returns a `CostEstimate` with `min`/`max` credits and a per-tier + `breakdown`. `min == max` (``.exact``) when no task uses + ``mode=auto``. + + Note: `file_input` (CSV) jobs can't be estimated this way — the + row count isn't known client-side. Estimate from the in-memory + task list, or count the rows yourself first. + """ + job_params = dict(zenrows_params or {}) + # tier -> [count, unit_min, unit_max] + agg: dict[Tier, list[int]] = {} + total_min = 0 + total_max = 0 + count = 0 + for task in tasks: + count += 1 + merged = {**job_params, **_task_params(task)} + tc = _cost_for_params(merged) + total_min += tc.min + total_max += tc.max + if tc.tier in agg: + agg[tc.tier][0] += 1 + else: + agg[tc.tier] = [1, tc.min, tc.max] + + breakdown = tuple( + CostLine(tier, agg[tier][0], agg[tier][1], agg[tier][2]) + for tier in _TIER_ORDER + if tier in agg + ) + return CostEstimate(task_count=count, min=total_min, max=total_max, breakdown=breakdown) + + +__all__ = [ + "AUTO_MAX_CREDITS", + "AUTO_MIN_CREDITS", + "BASE_CREDITS", + "JS_AND_PROXY_CREDITS", + "JS_CREDITS", + "PREMIUM_PROXY_CREDITS", + "CostEstimate", + "CostLine", + "TaskCost", + "Tier", +] diff --git a/src/zenrows/batch/_logging.py b/src/zenrows/batch/_logging.py new file mode 100644 index 0000000..59a8526 --- /dev/null +++ b/src/zenrows/batch/_logging.py @@ -0,0 +1,96 @@ +"""Structured logging for the Batch SDK. + +One logger per module under the `zenrows.batch` namespace. Logging +follows the stdlib `logging` conventions — callers opt in by +configuring a handler on `zenrows.batch` (or any ancestor); we never +install one ourselves. That keeps quiet libraries quiet by default +and lets apps route Batch SDK events into whatever observability +stack they already have. + +Log levels we use: + - DEBUG one record per HTTP request (method, path, status, ms). + - INFO notable client-side events (waiter started, download + completed, key rotation). + - WARNING soft errors a caller probably wants to know about + (progress dep missing while progress=True, etc.). + - ERROR non-2xx responses that turned into BatchAPIError. + +Fields are passed as `logging.LogRecord` `extra=` so structured-log +backends (json, OpenTelemetry, Datadog) can index them. Plain-text +formatters see the message string and ignore the extras. +""" + +import logging +from collections.abc import Mapping +from typing import Any + +# Root logger for the Batch SDK. Submodules get child loggers via +# `_logger("zenrows.batch.client")` etc. — children inherit handlers +# from this one when callers configure it. +ROOT = "zenrows.batch" + + +def logger(name: str) -> logging.Logger: + """`logging.getLogger` namespaced under `zenrows.batch`. + + Pass the bare module name (e.g. `"client"`); the prefix is + added here so callers never have to remember the full string. + """ + return logging.getLogger(f"{ROOT}.{name}") + + +def log_request( + log: logging.Logger, + *, + method: str, + path: str, + status: int, + elapsed_ms: float, + extra: Mapping[str, Any] | None = None, +) -> None: + """Emit a per-request DEBUG record. Cheap when the level is off. + + The structured fields (`extra=`) match what most backends index + on out of the box: `http.method`, `http.path`, `http.status`, + `elapsed_ms`. Plain-text formatters get a single-line message. + """ + if not log.isEnabledFor(logging.DEBUG): + return + payload: dict[str, Any] = { + "http.method": method, + "http.path": path, + "http.status": status, + "elapsed_ms": round(elapsed_ms, 2), + } + if extra: + payload.update(extra) + log.debug("%s %s -> %d (%.1f ms)", method, path, status, elapsed_ms, extra=payload) + + +def log_error( + log: logging.Logger, + *, + method: str, + path: str, + status: int, + code: str, + detail: str | None, +) -> None: + """Emit an ERROR record for a non-2xx that becomes BatchAPIError.""" + log.error( + "%s %s -> %d %s%s", + method, + path, + status, + code, + f": {detail}" if detail else "", + extra={ + "http.method": method, + "http.path": path, + "http.status": status, + "error.code": code, + }, + ) + + +__all__ = ["ROOT", "log_error", "log_request", "logger"] diff --git a/src/zenrows/batch/_resources.py b/src/zenrows/batch/_resources.py new file mode 100644 index 0000000..748c008 --- /dev/null +++ b/src/zenrows/batch/_resources.py @@ -0,0 +1,921 @@ +"""Resource handles for the Batch SDK — a two-tier (typestate) design +with namespaced sub-facets. + +For each resource there's a *reference* and a *loaded handle*: + + - ``JobRef`` wraps ``(client, job_id)`` and exposes every + job-template operation that needs **only the id** — ``close``, + ``delete``, ``rerun`` / ``retry_failed``, ``add_tasks``, ``runs()``. + It holds no snapshot; ``ref.load()`` fetches and returns a… + - ``JobHandle`` — a ``JobRef`` plus a guaranteed, synchronous + ``data: Job``. Returned by ``get_job`` / ``iter_jobs`` and the ops + that echo fresh state. + +Two sub-facets hang off every job (ref or handle), because the API has +two distinct scopes that both use the word "pause": + + - ``job.run.*`` — operations on the **current run**: ``pause`` / + ``resume`` (run-level suspend), ``stop`` / ``cancel``, ``wait``, + ``results``, downloads, ``start_export``. + - ``job.schedule.*`` — operations on the **schedule**: ``pause`` / + ``resume`` (skip future fires) and ``update``. + +Address a *specific historical* run with ``client.run(job_id, run_id)`` +(a ``RunRef``); it deliberately has no ``pause`` / ``stop``, since the +API only pauses or stops the current run. + +Handles are immutable snapshots: mutating ops (``pause``, ``stop``, …) +return a *new* loaded handle carrying the server's fresh state rather +than updating in place. Every method here delegates to the client's +transport, so behaviour is identical to the flat, pydantic-typed +surface. +""" + +from collections.abc import Callable, Iterator +from pathlib import Path +from typing import IO, TYPE_CHECKING + +import httpx + +from zenrows.batch._download import download_task_to_file as _download_task_to_file +from zenrows.batch._download import download_task_to_memory as _download_task_to_memory +from zenrows.batch._typed_dicts import AddTasksDict +from zenrows.batch.errors import BatchAPIError +from zenrows.batch.models import ( + AddTasksRequest, + AddTasksResponse, + Export, + ExportStatus, + Job, + JobStatus, + RerunJobResponse, + Run, + RunStats, + RunStatus, + StartExportResponse, + SubmitJobResponse, + TaskHistoryResponse, + TaskResult, + WebhookConfig, +) + +if TYPE_CHECKING: + from zenrows.batch._download import DownloadedResult + from zenrows.batch._schedule import Schedule + from zenrows.batch._typed_dicts import JobScheduleDict + from zenrows.batch.client import ZenRowsBatchClient + + +# ======================= specific runs ======================= + + +class RunRef: + """A reference to a **specific** run by ``(job_id, run_id)``. + + Read/download operations on one (usually historical) run. No + ``pause`` / ``stop``: the API only suspends or stops the *current* + run (see ``job.run`` — the :class:`CurrentRun` facet). Call + ``load()`` for a :class:`RunHandle` with data. + """ + + def __init__( + self, + client: "ZenRowsBatchClient", + job_id: str, + run_id: str, + ): + self._client = client + self.job_id = job_id + self.run_id = run_id + + def __repr__(self) -> str: + return f"<{type(self).__name__} job_id={self.job_id!r} run_id={self.run_id!r}>" + + def load(self) -> "RunHandle": + """``GET /jobs/{id}/runs/{run_id}`` — fetch the run and return a + loaded :class:`RunHandle`.""" + return RunHandle( + self._client, + self.job_id, + self.run_id, + self._client._get_run_data(self.job_id, self.run_id), + ) + + # ----- lifecycle ----- + + def delete(self) -> None: + """``DELETE /jobs/{id}/runs/{run_id}`` — scrub one run only.""" + self._client._delete_run(self.job_id, self.run_id) + + # ----- results / content ----- + + def results(self, *, status: str | None = None) -> Iterator[TaskResult]: + return self._client._iter_results_raw(self.job_id, run_id=self.run_id, status=status) + + def task_history(self, task_id: str) -> TaskHistoryResponse: + return self._client._get_task_history_raw(self.job_id, task_id, run_id=self.run_id) + + # ----- bulk download ----- + + def download_to_dir( + self, + target_dir: str | Path, + *, + status: str | None = "successful", + name_fn: Callable[[TaskResult], str] | None = None, + use_external_id: bool = False, + concurrency: int = 1, + progress: bool = False, + max_files: int | None = None, + max_bytes_per_file: int | None = None, + ) -> int: + return self._client._download_dir( + self.job_id, + self.run_id, + target_dir, + status=status, + name_fn=name_fn, + use_external_id=use_external_id, + concurrency=concurrency, + progress=progress, + max_files=max_files, + max_bytes_per_file=max_bytes_per_file, + ) + + def download_to_memory( + self, + *, + status: str | None = "successful", + concurrency: int = 1, + progress: bool = False, + max_count: int | None = None, + max_total_bytes: int | None = None, + max_bytes_per_file: int | None = None, + ) -> "list[DownloadedResult]": + return self._client._download_memory( + self.job_id, + self.run_id, + status=status, + concurrency=concurrency, + progress=progress, + max_count=max_count, + max_total_bytes=max_total_bytes, + max_bytes_per_file=max_bytes_per_file, + ) + + # ----- single-task download ----- + + def download_task_to_file(self, task: TaskResult, target: "str | Path | IO[bytes]") -> None: + """Download one ``task``'s body straight from its presigned + ``result_url`` to ``target`` — a path (``str`` / ``Path``) or an + open binary file object.""" + _download_task_to_file(task, target) + + def download_task_to_memory(self, task: TaskResult) -> bytes: + """Download one ``task``'s body straight from its presigned + ``result_url`` and return the raw bytes.""" + return _download_task_to_memory(task) + + # ----- waiter ----- + + def wait( + self, + *, + target_statuses: set[str] | frozenset[str] | None = None, + failure_statuses: set[str] | frozenset[str] | None = None, + timeout: float = 300.0, + poll_interval: float = 2.0, + progress: bool = False, + ) -> "RunHandle": + """Block until this run reaches a target state. Returns a fresh + loaded :class:`RunHandle` so chains like + ``run.wait().download_to_dir(...)`` work without re-wrapping.""" + run = self._client._wait_for_run_raw( + self.job_id, + run_id=self.run_id, + target_statuses=target_statuses, + failure_statuses=failure_statuses, + timeout=timeout, + poll_interval=poll_interval, + progress=progress, + ) + return RunHandle(self._client, self.job_id, self.run_id, run) + + # ----- async results-zip export ----- + + def start_export(self) -> "ExportRef": + """``POST .../exports`` — kick off an async zip of this run's + bodies. Returns an :class:`ExportRef` carrying the just-issued + id; chain ``.wait()`` to block for completion.""" + return self._client.start_results_export(self.job_id, self.run_id) + + def export(self, export_id: str) -> "ExportRef": + """Address a specific export of this run by id (no network call). + Lazy — the first method call surfaces 404 if the id is wrong or + TTL-swept.""" + return ExportRef(self._client, self.job_id, self.run_id, export_id) + + def download_all_results( + self, + target_path: str | Path, + *, + wait_timeout: float = 600.0, + poll_interval: float = 2.0, + ) -> Path: + """Start an export of this run's results, wait for it, and save + the zip to ``target_path``. See + ``ZenRowsBatchClient.download_all_results`` for the contract. + + Capped at 1 GiB per run server-side; for larger runs use + ``download_to_dir`` (no size limit, but slower — one body at a + time, tunable via ``concurrency=``).""" + return self._client.download_all_results( + self.job_id, + self.run_id, + target_path, + wait_timeout=wait_timeout, + poll_interval=poll_interval, + ) + + +class RunHandle(RunRef): + """A :class:`RunRef` plus a guaranteed, synchronous ``data`` snapshot.""" + + def __init__( + self, + client: "ZenRowsBatchClient", + job_id: str, + run_id: str, + data: Run, + ): + super().__init__(client, job_id, run_id) + self.data = data + + def __repr__(self) -> str: + t = self.data.stats + return ( + f"" + ) + + @property + def status(self) -> RunStatus: + """This run's status. Shortcut for ``self.data.status``.""" + return self.data.status + + @property + def stats(self) -> RunStats: + """This run's task rollup (``total``, ``successful``, ``failed``, + ``spend``, …). Shortcut for ``self.data.stats``.""" + return self.data.stats + + +# ============================ exports ============================ + + +class ExportRef: + """A reference to a results-export by id. + + A results export is async: ``start_export()`` returns immediately + with a ``pending`` ref; the server zips the run in the background + and it reaches ``completed`` (download URL ready) or ``failed`` + (with an ``error`` message — e.g. the 1 GiB size cap). Call + ``load()`` / ``wait()`` for an :class:`ExportHandle` with data. + """ + + def __init__( + self, + client: "ZenRowsBatchClient", + job_id: str, + run_id: str, + export_id: str, + *, + start_response: StartExportResponse | None = None, + ): + self._client = client + self.job_id = job_id + self.run_id = run_id + self.export_id = export_id + # Only set on the ref returned by start_export; carries the + # initial status/timestamps without forcing a GET. + self.start_response = start_response + + def __repr__(self) -> str: + bits = [f"export_id={self.export_id!r}"] + if self.start_response: + bits.append(f"status={self.start_response.status.value!r}") + return f"<{type(self).__name__} {' '.join(bits)}>" + + def load(self) -> "ExportHandle": + """``GET .../exports/{id}`` — fetch the export and return a + loaded :class:`ExportHandle`.""" + data = self._client._get_export(self.job_id, self.run_id, self.export_id) + return ExportHandle( + self._client, + self.job_id, + self.run_id, + self.export_id, + data, + start_response=self.start_response, + ) + + # ----- waiter ----- + + def wait( + self, + *, + target_statuses: set[str] | frozenset[str] | None = None, + timeout: float = 600.0, + poll_interval: float = 2.0, + ) -> "ExportHandle": + """Block until the export reaches a terminal state (defaults to + ``{completed, failed}``). Returns the loaded handle.""" + from zenrows.batch.client import TERMINAL_EXPORT_STATUSES + + data = self._client._wait_for_export_raw( + self.job_id, + self.run_id, + self.export_id, + target_statuses=target_statuses or TERMINAL_EXPORT_STATUSES, + timeout=timeout, + poll_interval=poll_interval, + ) + return ExportHandle( + self._client, + self.job_id, + self.run_id, + self.export_id, + data, + start_response=self.start_response, + ) + + # ----- download ----- + + def download_to_path( + self, + target_path: str | Path, + *, + chunk_size: int = 1 << 20, + ) -> Path: + """Stream the export zip to ``target_path``. The export must + already be ``completed`` — call ``.wait()`` first or pair with + ``client.download_all_results(...)`` for the one-shot flow. + + Fetches once for a fresh presigned URL (the server signs a new + URL per request).""" + data = self.load().data + if data.status != ExportStatus.COMPLETED: + raise BatchAPIError( + status_code=0, + problem=None, + raw=(data.error if data.error else "export not completed").encode(), + ) + if not data.download_url: + raise BatchAPIError( + status_code=0, + problem=None, + raw=b"export completed but server returned no download_url", + ) + target = Path(target_path) + target.parent.mkdir(parents=True, exist_ok=True) + with ( + httpx.Client(timeout=httpx.Timeout(60.0)) as bare, + bare.stream("GET", str(data.download_url)) as r, + ): + if r.status_code >= 400: + raise BatchAPIError(r.status_code, problem=None, raw=r.read()) + with target.open("wb") as f: + for chunk in r.iter_bytes(chunk_size): + f.write(chunk) + return target + + +class ExportHandle(ExportRef): + """An :class:`ExportRef` plus a guaranteed, synchronous ``data`` snapshot.""" + + def __init__( + self, + client: "ZenRowsBatchClient", + job_id: str, + run_id: str, + export_id: str, + data: Export, + *, + start_response: StartExportResponse | None = None, + ): + super().__init__(client, job_id, run_id, export_id, start_response=start_response) + self.data = data + + def __repr__(self) -> str: + return f"" + + @property + def status(self) -> ExportStatus: + """This export's status. Shortcut for ``self.data.status``.""" + return self.data.status + + +# ===================== current-run facet ===================== + + +class CurrentRun: + """Operations on a job's **current run**, reached via ``job.run``. + + The pause / stop family only ever targets the latest run (the API's + run-less endpoints resolve it server-side), which is why they live + here and not on :class:`RunRef`. Minted lazily by the ``job.run`` + property; holds no snapshot of its own. + """ + + def __init__(self, client: "ZenRowsBatchClient", job_id: str): + self._client = client + self.job_id = job_id + + def __repr__(self) -> str: + return f"" + + def _current_run_id(self) -> str: + job = self._client._get_job_data(self.job_id) + if not job.latest_run: + raise ValueError(f"job {self.job_id!r} has no run yet") + return job.latest_run.run_id + + def load(self) -> "RunHandle": + """``GET /jobs/{id}`` → the latest run as a loaded + :class:`RunHandle`.""" + job = self._client._get_job_data(self.job_id) + if not job.latest_run: + raise ValueError(f"job {self.job_id!r} has no run yet") + return RunHandle(self._client, self.job_id, job.latest_run.run_id, job.latest_run) + + # ----- lifecycle ----- + + def pause(self) -> "RunHandle": + """``POST /jobs/{id}/pause`` — reversibly suspend the current + run: the dispatcher stops pulling its queue (in-flight tasks may + still settle), setting ``latest_run.pause_state = paused``. + Orthogonal to ``status``; undo with ``resume()``. Returns the + fresh loaded :class:`RunHandle`.""" + run = self._client._post_pause(self.job_id) + return RunHandle(self._client, self.job_id, run.run_id, run) + + def resume(self) -> "RunHandle": + """``POST /jobs/{id}/resume`` — un-pause the current run (the + dispatcher resumes polling). Returns the fresh loaded handle.""" + run = self._client._post_resume(self.job_id) + return RunHandle(self._client, self.job_id, run.run_id, run) + + def stop(self) -> "RunHandle": + """``POST /jobs/{id}/stop`` — terminally stop the current run. + Returns the fresh loaded :class:`RunHandle`.""" + run = self._client._post_stop(self.job_id) + return RunHandle(self._client, self.job_id, run.run_id, run) + + def cancel(self) -> "RunHandle": + """Alias for :meth:`stop`.""" + return self.stop() + + # ----- waiter ----- + + def wait( + self, + *, + target_statuses: set[str] | frozenset[str] | None = None, + failure_statuses: set[str] | frozenset[str] | None = None, + timeout: float = 300.0, + poll_interval: float = 2.0, + progress: bool = False, + ) -> "RunHandle": + """Block until the current run reaches a target state. Returns a + loaded :class:`RunHandle`, so chains like + ``job.run.wait().download_to_dir(...)`` work cleanly. + + See ``ZenRowsBatchClient.wait_for_run`` for the full contract.""" + run = self._client._wait_for_run_raw( + self.job_id, + run_id=None, + target_statuses=target_statuses, + failure_statuses=failure_statuses, + timeout=timeout, + poll_interval=poll_interval, + progress=progress, + ) + return RunHandle(self._client, self.job_id, run.run_id, run) + + # ----- results / content ----- + + def results(self, *, status: str | None = None) -> Iterator[TaskResult]: + """Auto-paginate task results from the current run.""" + return self._client._iter_results_raw(self.job_id, status=status) + + def task_history(self, task_id: str) -> TaskHistoryResponse: + """Current-run per-attempt event log for one task.""" + return self._client._get_task_history_raw(self.job_id, task_id) + + # ----- bulk download ----- + + def download_to_dir( + self, + target_dir: str | Path, + *, + status: str | None = "successful", + name_fn: Callable[[TaskResult], str] | None = None, + use_external_id: bool = False, + concurrency: int = 1, + progress: bool = False, + max_files: int | None = None, + max_bytes_per_file: int | None = None, + ) -> int: + return self._client._download_dir( + self.job_id, + None, + target_dir, + status=status, + name_fn=name_fn, + use_external_id=use_external_id, + concurrency=concurrency, + progress=progress, + max_files=max_files, + max_bytes_per_file=max_bytes_per_file, + ) + + def download_to_memory( + self, + *, + status: str | None = "successful", + concurrency: int = 1, + progress: bool = False, + max_count: int | None = None, + max_total_bytes: int | None = None, + max_bytes_per_file: int | None = None, + ) -> "list[DownloadedResult]": + return self._client._download_memory( + self.job_id, + None, + status=status, + concurrency=concurrency, + progress=progress, + max_count=max_count, + max_total_bytes=max_total_bytes, + max_bytes_per_file=max_bytes_per_file, + ) + + # ----- single-task download ----- + + def download_task_to_file(self, task: TaskResult, target: "str | Path | IO[bytes]") -> None: + """Download one ``task``'s body straight from its presigned + ``result_url`` to ``target`` — a path (``str`` / ``Path``) or an + open binary file object. The per-task counterpart of + ``download_to_dir`` — handy inside a ``results()`` loop when a + Python-side filter picks which bodies to keep.""" + _download_task_to_file(task, target) + + def download_task_to_memory(self, task: TaskResult) -> bytes: + """Download one ``task``'s body straight from its presigned + ``result_url`` and return the raw bytes.""" + return _download_task_to_memory(task) + + # ----- async results-zip export ----- + + def start_export(self) -> "ExportRef": + """``POST .../exports`` — kick off an async zip of the current + run's bodies. Resolves the current run id first (one GET), then + returns an :class:`ExportRef`.""" + return self._client.start_results_export(self.job_id, self._current_run_id()) + + def download_all_results( + self, + target_path: str | Path, + *, + wait_timeout: float = 600.0, + poll_interval: float = 2.0, + ) -> Path: + """Start an export of the current run's results, wait for it, + and save the zip to ``target_path``. See + ``ZenRowsBatchClient.download_all_results`` for the contract. + + Capped at 1 GiB per run server-side; for larger runs use + ``download_to_dir`` (no size limit, but slower — one body at a + time, tunable via ``concurrency=``).""" + return self._client.download_all_results( + self.job_id, + self._current_run_id(), + target_path, + wait_timeout=wait_timeout, + poll_interval=poll_interval, + ) + + +# ===================== schedule facet ===================== + + +class ScheduleControls: + """Operations on a scheduled job's schedule, reached via + ``job.schedule``. Scheduled jobs only — regular jobs 409.""" + + def __init__(self, client: "ZenRowsBatchClient", job_id: str): + self._client = client + self.job_id = job_id + + def __repr__(self) -> str: + return f"" + + def pause(self) -> "JobHandle": + """``POST /jobs/{id}/schedule/state`` → ``paused`` — skip future + scheduled fires (an in-flight run keeps running). The schedule + keeps ticking server-side but fires are dropped until + ``resume()``. Idempotent; returns the fresh loaded handle.""" + return JobHandle( + self._client, self.job_id, self._client._post_schedule_state(self.job_id, "paused") + ) + + def resume(self) -> "JobHandle": + """``POST /jobs/{id}/schedule/state`` → ``active`` — re-enable + scheduled fires on a paused job. Idempotent; returns the fresh + loaded handle.""" + return JobHandle( + self._client, self.job_id, self._client._post_schedule_state(self.job_id, "active") + ) + + def update(self, schedule: "Schedule | JobScheduleDict") -> "JobHandle": + """``PUT /jobs/{id}/schedule`` — replace the schedule. Accepts a + typed builder (``At`` / ``Rate`` / ``Calendar``) or a raw + ``JobScheduleDict``, same as ``submit_scheduled``. + + An in-flight run keeps running; the new schedule governs only + future fires. Returns the fresh loaded handle.""" + resolved = self._client._resolve_schedule(schedule) + return JobHandle( + self._client, self.job_id, self._client._put_schedule(self.job_id, resolved) + ) + + +# ============================ jobs ============================ + + +class JobRef: + """A reference to a job by id — job-template operations, plus the + ``run`` and ``schedule`` sub-facets. + + Minted with **no network call** by ``client.job(id)`` and returned + by the ``submit_*`` methods (with ``submit_response`` attached). + Call ``load()`` for a :class:`JobHandle` with data. Construction is + handled by the SDK — callers never build these directly. + """ + + def __init__( + self, + client: "ZenRowsBatchClient", + job_id: str, + *, + submit_response: SubmitJobResponse | None = None, + ): + self._client = client + self.job_id = job_id + # Populated only when the ref was returned by a submit_* call; + # exposes accepted_tasks + the initial status/latest_run without + # forcing a follow-up GET. + self.submit_response = submit_response + self._run_facet: CurrentRun | None = None + self._schedule_facet: ScheduleControls | None = None + + def __repr__(self) -> str: + bits = [f"job_id={self.job_id!r}"] + if self.submit_response: + bits.append(f"status={self.submit_response.status.value!r}") + bits.append(f"accepted={self.submit_response.accepted_tasks}") + return f"<{type(self).__name__} {' '.join(bits)}>" + + # ----- sub-facets ----- + + @property + def run(self) -> CurrentRun: + """Operations on the **current run** — ``pause`` / ``resume`` / + ``stop`` / ``wait`` / ``results`` / downloads / ``start_export``.""" + if self._run_facet is None: + self._run_facet = CurrentRun(self._client, self.job_id) + return self._run_facet + + @property + def schedule(self) -> ScheduleControls: + """Operations on the **schedule** — ``pause`` / ``resume`` / + ``update`` (scheduled jobs only).""" + if self._schedule_facet is None: + self._schedule_facet = ScheduleControls(self._client, self.job_id) + return self._schedule_facet + + # ----- snapshot accessors (submit-response backed) ----- + + @property + def status(self) -> JobStatus | None: + """The job status from the submit response. Only known on refs + returned by a ``submit_*`` call (no network); ``None`` otherwise + — ``load()`` for a :class:`JobHandle` whose ``.status`` reads the + fetched ``.data``.""" + return self.submit_response.status if self.submit_response else None + + @property + def accepted_tasks(self) -> int | None: + """How many tasks landed at submit. Only known on refs returned + by a ``submit_*`` call; returns ``None`` otherwise.""" + return self.submit_response.accepted_tasks if self.submit_response else None + + def load(self) -> "JobHandle": + """``GET /jobs/{id}`` — fetch the full job and return a loaded + :class:`JobHandle` whose ``.data`` is ready.""" + return JobHandle(self._client, self.job_id, self._client._get_job_data(self.job_id)) + + # ----- lifecycle ----- + + def close(self) -> "JobHandle": + """``POST /jobs/{id}/close`` — lock the job (no more ``add_tasks``). + Returns the fresh loaded handle.""" + return JobHandle(self._client, self.job_id, self._client._post_close(self.job_id)) + + def delete(self) -> None: + """``DELETE /jobs/{id}`` — async hard delete.""" + self._client._delete(self.job_id) + + def rerun( + self, + *, + status: str | list[str] | None = None, + idempotency_key: str | None = None, + ) -> "RunHandle": + """``POST /jobs/{id}/rerun[?status=...]`` — start a new run. + + Without ``status``: full rerun of the previous run's tasks + (or, for a scheduled job with no prior run, a manual fire + from the template). No source-child link. + + With ``status`` (single value like ``"failed"`` or list like + ``["failed", "pending"]``): partial retry — matching statuses + are reset to ``pending`` and re-enqueued; everything else is + inherited verbatim with ``source_run_id`` stamped. + + Returns a :class:`RunHandle` for the newly-created run.""" + resp: RerunJobResponse = self._client._post_rerun( + self.job_id, status=status, idempotency_key=idempotency_key + ) + return RunHandle(self._client, self.job_id, resp.latest_run.run_id, resp.latest_run) + + def retry_failed( + self, + *, + include_pending: bool = False, + idempotency_key: str | None = None, + ) -> "RunHandle": + """Start a new run that re-executes only the previous run's + **failed** tasks (partial retry). Successful tasks + are inherited verbatim, so the new run's totals already carry + the prior successes — you only pay to re-scrape what failed. + + Shortcut for ``rerun(status="failed")``. Set + ``include_pending=True`` to also re-enqueue tasks that never + started (``status="failed,pending"``) — the usual move after a + ``stop()`` left orphan ``pending`` rows. + + Returns a :class:`RunHandle` for the new run. Requires the + previous run to be terminal (``completed`` / ``stopped``); raises + ``BatchAPIError`` (409 ``run_not_terminal``) otherwise, and + (409 ``no_matching_tasks``) when nothing matched the filter. + """ + statuses = ["failed", "pending"] if include_pending else "failed" + return self.rerun(status=statuses, idempotency_key=idempotency_key) + + # ----- tasks (write path) ----- + + def add_tasks(self, body: AddTasksRequest | AddTasksDict) -> AddTasksResponse: + """``POST /jobs/{id}/tasks`` — append to the open initial run.""" + return self._client._post_tasks(self.job_id, body) + + # ----- runs (list) ----- + + def runs(self, *, page_size: int | None = None) -> Iterator["RunHandle"]: + """Auto-paginate runs of this job, yielding :class:`RunHandle`s. + To address a *specific* run by id, use + ``client.run(job_id, run_id)``; for the current run, ``job.run``.""" + for run in self._client._iter_runs_raw(self.job_id, page_size=page_size): + yield RunHandle(self._client, self.job_id, run.run_id, run) + + # ----- file inputs ----- + + def add_file_input( + self, + source: str | Path | IO[bytes], + *, + fields: dict[str, int | str], + header: bool = False, + delimiter: str = ",", + quote: str = '"', + ) -> str: + """Upload a CSV that *this job* would consume on a future + submission. Returns the ``file_input_id``. (File inputs are + not tied to a job at create-time, but living off the handle + keeps the call site discoverable.)""" + return self._client.upload_csv( + source, + fields=fields, + header=header, + delimiter=delimiter, + quote=quote, + ) + + # ----- webhooks ----- + + def get_webhook(self) -> WebhookConfig: + """`GET /jobs/{id}/webhook` — this job's current webhook config. + Raises `BatchAPIError` (404) when none is set.""" + return self._client.get_job_webhook(self.job_id) + + def set_webhook(self, url: str, *, signature: bool) -> WebhookConfig: + """`PUT /jobs/{id}/webhook` — replace this job's webhook config. + Both fields are required (no defaulting, so you can't silently + toggle signing); pass `signature=False` explicitly for an + unsigned receiver. Returns the persisted config.""" + return self._client.put_job_webhook(self.job_id, {"url": url, "signature": signature}) + + def delete_webhook(self) -> None: + """`DELETE /jobs/{id}/webhook` — clear this job's webhook config. + Idempotent.""" + self._client.delete_job_webhook(self.job_id) + + # ----- ingest waiter ----- + + def wait_for_ingest( + self, + *, + timeout: float = 300.0, + poll_interval: float = 2.0, + max_poll_interval: float = 15.0, + ) -> "JobHandle": + """Block until the current run's async-carrier ingestion has + finished writing task rows. + + Large submissions return ``202 Accepted`` and stream task rows + into storage off the request path; until that finishes, + results pages may be partial and ``add_tasks`` on an open job + is rejected with 409. This polls ``GET /jobs/{id}`` until + ``latest_run.ingest_status`` leaves ``pending`` (also satisfied + by a terminal run — a mid-ingest stop flips the field to + ``done``). Runs that never ingested asynchronously (any 201 + submission) are done on the first poll. + + Raises ``WaiterTimeout`` after ``timeout`` seconds. Returns the + fresh loaded handle, so chains like + ``job.wait_for_ingest().add_tasks(...)`` work. Or opt in at + submit time via ``submit_job(..., wait_for_ingest=True)``.""" + return JobHandle( + self._client, + self.job_id, + self._client._wait_for_ingest_raw( + self.job_id, + timeout=timeout, + poll_interval=poll_interval, + max_poll_interval=max_poll_interval, + ), + ) + + +class JobHandle(JobRef): + """A :class:`JobRef` plus a guaranteed, synchronous ``data`` snapshot. + + Returned by ``get_job`` / ``iter_jobs`` and the ops that echo fresh + state (``close``, ``job.schedule.pause``, …). Inherits the ``run`` + and ``schedule`` facets from :class:`JobRef`. + """ + + def __init__( + self, + client: "ZenRowsBatchClient", + job_id: str, + data: Job, + *, + submit_response: SubmitJobResponse | None = None, + ): + super().__init__(client, job_id, submit_response=submit_response) + self.data = data + + def __repr__(self) -> str: + bits = [f"job_id={self.job_id!r}", f"status={self.data.status.value!r}"] + if self.data.latest_run: + t = self.data.latest_run.stats + bits.append(f"tasks={t.successful + t.failed}/{t.total}") + return f"" + + @property + def status(self) -> JobStatus: + """This job's status. Shortcut for ``self.data.status``.""" + return self.data.status + + +__all__ = [ + "CurrentRun", + "ExportHandle", + "ExportRef", + "JobHandle", + "JobRef", + "RunHandle", + "RunRef", + "ScheduleControls", +] diff --git a/src/zenrows/batch/_schedule.py b/src/zenrows/batch/_schedule.py new file mode 100644 index 0000000..2731d17 --- /dev/null +++ b/src/zenrows/batch/_schedule.py @@ -0,0 +1,257 @@ +"""Pythonic schedule builders for `submit_scheduled`. + +The wire format is a structured dict (`JobScheduleDict`) — three +mutually exclusive shapes (`at` / `rate` / `calendar`) plus an +optional `timezone`. The dict form is fine for power users; everyone +else gets these typed classes: + + from zenrows.batch import At, Rate, Calendar, Weekly + from datetime import datetime + + client.submit_scheduled( + At(datetime(2026, 9, 1, 9, 0), timezone="Europe/Berlin"), + urls=["https://example.com/once"], + ) + client.submit_scheduled( + Rate(every=15, unit="minute"), + urls=["https://example.com/poll"], + ) + client.submit_scheduled( + Calendar( + times_of_day=["09:00", "18:00"], + cadence=Weekly(days=["mon", "wed", "fri"]), + timezone="Europe/Berlin", + ), + urls=["https://example.com/recurring"], + ) + +Each class validates its inputs in `__post_init__`. The same rules +the server enforces — naive `at`, full-hour `times_of_day`, day +name spelling, valid IANA timezone — fail fast in Python rather +than round-tripping through a 400. + +The low-level `client.submit_job({...})` path stays a pure +passthrough: callers who hand-build a dict bypass these checks and +rely on the server's response for error reporting. +""" + +from __future__ import annotations + +import zoneinfo +from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING, Literal, Union + +if TYPE_CHECKING: + from ._typed_dicts import JobScheduleDict + +# Public alias for type-checkers and docs. +Schedule = Union["At", "Rate", "Calendar"] + + +_DAYS_OF_WEEK = ("mon", "tue", "wed", "thu", "fri", "sat", "sun") + + +def _validate_timezone(tz: str, field: str) -> None: + """Reject empty or non-IANA timezones.""" + if not tz: + raise ValueError(f"{field} is required (IANA name, e.g. `Europe/Berlin`)") + try: + zoneinfo.ZoneInfo(tz) + except zoneinfo.ZoneInfoNotFoundError as e: + raise ValueError(f"{field}: {tz!r} is not a valid IANA timezone") from e + + +def _validate_full_hour(s: str) -> None: + """Accept only `HH:00` (24h, full hours).""" + if len(s) != 5 or s[2] != ":" or s[3:] != "00": + raise ValueError( + f"times_of_day entry {s!r} must be on the hour (`HH:00`); " + "minute granularity is rejected." + ) + try: + h = int(s[:2]) + except ValueError as e: + raise ValueError(f"times_of_day entry {s!r} has a non-numeric hour") from e + if not 0 <= h <= 23: + raise ValueError(f"times_of_day entry {s!r} is not a valid hour (00..23)") + + +@dataclass +class At: + """One-shot fire at a specific wall-clock time. + + `at` accepts either a tz-naive `datetime` (no tzinfo) or a + tz-naive ISO string (`"2026-09-01T09:00:00"`). Aware datetimes + and offset-bearing strings are rejected — `timezone` is the + single authoritative interpreter, which keeps DST transitions + deterministic. + """ + + at: str | datetime + timezone: str + + def __post_init__(self) -> None: + _validate_timezone(self.timezone, "At.timezone") + if isinstance(self.at, datetime): + if self.at.tzinfo is not None and self.at.utcoffset() is not None: + raise ValueError( + "At.at must be a naive datetime (no tzinfo); supply " + "timezone separately to keep DST transitions deterministic." + ) + elif isinstance(self.at, str): + if not self.at.strip(): + raise ValueError("At.at must be a non-empty ISO timestamp string.") + if _has_tz_suffix(self.at): + raise ValueError( + f"At.at {self.at!r} must be tz-naive (no `Z`, no offset). " + "Supply timezone separately to keep DST transitions deterministic." + ) + else: + raise TypeError(f"At.at must be a string or datetime, got {type(self.at).__name__}") + + def to_dict(self) -> "JobScheduleDict": + at_str = self.at.strftime("%Y-%m-%dT%H:%M:%S") if isinstance(self.at, datetime) else self.at + return {"at": at_str, "timezone": self.timezone} + + +@dataclass +class Rate: + """Interval-based fire policy — every N units, no alignment to + wall clock. Timezone is irrelevant and not accepted here.""" + + every: int + unit: Literal["minute", "hour", "day"] + + def __post_init__(self) -> None: + if not isinstance(self.every, int) or isinstance(self.every, bool): + raise TypeError(f"Rate.every must be int, got {type(self.every).__name__}") + if self.every < 1: + raise ValueError(f"Rate.every must be >= 1, got {self.every}") + if self.unit not in ("minute", "hour", "day"): + raise ValueError(f"Rate.unit must be one of `minute`, `hour`, `day`; got {self.unit!r}") + + def to_dict(self) -> "JobScheduleDict": + return {"rate": {"every": self.every, "unit": self.unit}} + + +@dataclass +class Daily: + """Fire every day. No knobs.""" + + +@dataclass +class Weekly: + """Fire on specific days of the week. + + `days` is a list of lower-case 3-letter day names — at least one, + deduplicated server-side. + """ + + days: list[str] + + def __post_init__(self) -> None: + if not self.days: + raise ValueError("Weekly.days must be non-empty") + for d in self.days: + if d not in _DAYS_OF_WEEK: + raise ValueError( + f"Weekly.days entry {d!r} is not a valid day " + f"(use one of {', '.join(_DAYS_OF_WEEK)})" + ) + + +@dataclass +class Monthly: + """Fire on specific days of the month. + + `days` is a list of integers 1-31. Days that don't exist in a + given month (e.g. 31 in April) are silently skipped by the + scheduler. + """ + + days: list[int] + + def __post_init__(self) -> None: + if not self.days: + raise ValueError("Monthly.days must be non-empty") + for d in self.days: + if not isinstance(d, int) or isinstance(d, bool): + raise TypeError(f"Monthly.days entries must be int, got {type(d).__name__}") + if not 1 <= d <= 31: + raise ValueError(f"Monthly.days entry {d} is out of range (1..31)") + + +Cadence = Daily | Weekly | Monthly + + +@dataclass +class Calendar: + """Calendar-style fire policy: a list of times-of-day on a + daily / weekly / monthly cadence. + + `times_of_day` are full-hour 24h strings (`"09:00"`, not + `"09:30"`). `cadence` is exactly one of `Daily()`, `Weekly(...)`, + or `Monthly(...)`. `timezone` is mandatory (IANA name). + """ + + times_of_day: list[str] + cadence: Cadence + timezone: str + + def __post_init__(self) -> None: + _validate_timezone(self.timezone, "Calendar.timezone") + if not self.times_of_day: + raise ValueError("Calendar.times_of_day must be non-empty") + for t in self.times_of_day: + _validate_full_hour(t) + if not isinstance(self.cadence, (Daily, Weekly, Monthly)): + raise TypeError( + f"Calendar.cadence must be Daily/Weekly/Monthly, got {type(self.cadence).__name__}" + ) + + def to_dict(self) -> "JobScheduleDict": + cadence_dict: dict + if isinstance(self.cadence, Daily): + cadence_dict = {"daily": {}} + elif isinstance(self.cadence, Weekly): + cadence_dict = {"weekly": {"days": list(self.cadence.days)}} + elif isinstance(self.cadence, Monthly): + cadence_dict = {"monthly": {"days": list(self.cadence.days)}} + else: # pragma: no cover — caught in __post_init__ + raise TypeError(f"unknown cadence: {type(self.cadence).__name__}") + return { + "calendar": { + "times_of_day": list(self.times_of_day), + "cadence": cadence_dict, + }, + "timezone": self.timezone, + } + + +def _has_tz_suffix(raw: str) -> bool: + """Return True if `raw` carries an RFC 3339-style tz tail (`Z` / + `+HH:MM` / `-HH:MM`).""" + raw = raw.strip() + # Find the time portion (after T or space). + for sep in ("T", " "): + i = raw.find(sep) + if i >= 0: + tail = raw[i + 1 :] + if tail.endswith(("Z", "z")): + return True + # `+HH:MM` or `-HH:MM` at the end (6 chars). + return bool(len(tail) >= 6 and tail[-6] in "+-") + return False + + +__all__ = [ + "At", + "Cadence", + "Calendar", + "Daily", + "Monthly", + "Rate", + "Schedule", + "Weekly", +] diff --git a/src/zenrows/batch/_transport.py b/src/zenrows/batch/_transport.py new file mode 100644 index 0000000..20e45c5 --- /dev/null +++ b/src/zenrows/batch/_transport.py @@ -0,0 +1,228 @@ +"""HTTP transport for the Batch client. + +Owns the httpx Client, the `X-API-Key` auth header, default +User-Agent, automatic retries for transient failures, and the RFC +7807 → `BatchAPIError` mapping. The facade (`client.py`) is thin glue +over this — one method per endpoint, all typed via pydantic v2 models. +""" + +import random +import time +from typing import Any, TypeVar + +import httpx +from pydantic import BaseModel + +from zenrows.batch._logging import log_error, log_request, logger +from zenrows.batch.errors import BatchAPIError + +M = TypeVar("M", bound=BaseModel) + +_log = logger("transport") + +# Transient statuses worth retrying: 429 (rate limited), 502/503/504 +# (gateway / transient upstream — the spec marks 503 "safe to retry"). +_RETRYABLE_STATUSES = frozenset({429, 502, 503, 504}) + +# Methods safe to replay without side effects. POST is added only when +# the caller supplied an Idempotency-Key (submit / rerun). +_IDEMPOTENT_METHODS = frozenset({"GET", "PUT", "DELETE", "HEAD", "OPTIONS"}) + +# Retry tuning (mirrors the Node SDK): ~250ms · 2**attempt, ±20% jitter, +# capped at 10s. +_BACKOFF_BASE_MS = 250 +_BACKOFF_CAP_MS = 10_000 +_DEFAULT_RETRIES = 3 + + +def _has_idempotency_key(headers: dict[str, str] | None) -> bool: + return bool(headers) and any(k.lower() == "idempotency-key" for k in headers) + + +def _backoff_ms(attempt: int) -> float: + """Jittered exponential backoff for retry `attempt` (0-based).""" + base = min(_BACKOFF_BASE_MS * 2**attempt, _BACKOFF_CAP_MS) + return base * (1 + (random.random() * 2 - 1) * 0.2) + + +def _retry_after_ms(response: httpx.Response) -> float | None: + """Parse a `Retry-After` header (delta-seconds only) to milliseconds.""" + raw = response.headers.get("Retry-After") + if not raw: + return None + try: + secs = float(raw) + except ValueError: + return None + return secs * 1000 if secs >= 0 else None + + +class _Transport: + """Thin wrapper over `httpx.Client` with the Batch API's conventions. + + Why not vanilla httpx? Three reasons we want centralised: + - Auth header (`X-API-Key`) is set once, not per call site. + - Every non-2xx is decoded as RFC 7807 and raised — handlers + never see a raw status code, they see `BatchAPIError.code`. + - Pydantic encode/decode lives in one place; method bodies in + `client.py` stay readable. + """ + + def __init__( + self, + *, + base_url: str, + api_key: str, + user_agent: str, + timeout: float | httpx.Timeout, + retries: int = _DEFAULT_RETRIES, + verify: bool | str = True, + httpx_args: dict[str, Any] | None = None, + ): + self._retries = max(0, retries) + self._client = httpx.Client( + base_url=base_url.rstrip("/"), + headers={ + "X-API-Key": api_key, + "User-Agent": user_agent, + "Accept": "application/json", + }, + timeout=httpx.Timeout(timeout) if isinstance(timeout, (int, float)) else timeout, + verify=verify, + **(httpx_args or {}), + ) + + # ----- retrying send ----- + + def _send( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None, + headers: dict[str, str] | None, + content: bytes | None = None, + ) -> httpx.Response: + """Issue the request, retrying transient failures on idempotent + requests. + + Retries `{429, 502, 503, 504}` and transient network errors up + to `retries` times, with jittered exponential backoff (honoring + `Retry-After` when present). Only idempotent requests are + replayed — `GET`/`PUT`/`DELETE`/`HEAD`/`OPTIONS`, plus `POST` + when the caller supplied an `Idempotency-Key`. Our own timeouts + (`httpx.TimeoutException`) are never retried: the caller set + that budget. + """ + idempotent = method.upper() in _IDEMPOTENT_METHODS or ( + method.upper() == "POST" and _has_idempotency_key(headers) + ) + attempt = 0 + while True: + try: + response = self._client.request( + method, path, params=params, headers=headers, content=content + ) + except httpx.TimeoutException: + # Our own timeout budget — do not retry. + raise + except httpx.TransportError: + # Network-level failure (DNS, connection reset, TLS). + if idempotent and attempt < self._retries: + time.sleep(_backoff_ms(attempt) / 1000) + attempt += 1 + continue + raise + + if ( + idempotent + and attempt < self._retries + and response.status_code in _RETRYABLE_STATUSES + ): + wait_ms = _retry_after_ms(response) or _backoff_ms(attempt) + response.close() + time.sleep(wait_ms / 1000) + attempt += 1 + continue + + return response + + # ----- lifecycle ----- + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> "_Transport": + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + @property + def base_url(self) -> str: + return str(self._client.base_url) + + # ----- request helpers ----- + + def request_json( + self, + method: str, + path: str, + *, + body: BaseModel | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + """Send a request, parse JSON, raise BatchAPIError on non-2xx. + + Pydantic models in `body` are serialised via + `model_dump(mode="json", exclude_unset=True)` — the spec marks + every optional field with `omitempty`, so we send exactly what + the caller passed and let server defaults fill the rest. + """ + content: bytes | None = None + if body: + payload = body.model_dump( + mode="json", exclude_unset=True, exclude_none=True, by_alias=True + ) + import json + + content = json.dumps(payload).encode("utf-8") + headers = {**(headers or {}), "Content-Type": "application/json"} + + start = time.monotonic() + response = self._send( + method, + path, + params=_drop_none(params), + headers=headers, + content=content, + ) + elapsed_ms = (time.monotonic() - start) * 1000 + + if response.status_code >= 400: + err = BatchAPIError.from_response(response) + log_error( + _log, + method=method, + path=path, + status=response.status_code, + code=err.code, + detail=err.problem.detail if err.problem else None, + ) + raise err + + log_request( + _log, method=method, path=path, status=response.status_code, elapsed_ms=elapsed_ms + ) + if response.status_code == 204 or not response.content: + return None + return response.json() + + +def _drop_none(d: dict[str, Any] | None) -> dict[str, Any] | None: + """Strip None-valued query params; httpx sends them as empty + strings otherwise, which the server then rejects as malformed.""" + if not d: + return None + return {k: v for k, v in d.items() if v is not None} diff --git a/src/zenrows/batch/_typed_dicts.py b/src/zenrows/batch/_typed_dicts.py new file mode 100644 index 0000000..98b0fbb --- /dev/null +++ b/src/zenrows/batch/_typed_dicts.py @@ -0,0 +1,138 @@ +"""TypedDict mirrors of the request models — for dict-literal callers. + +Most `client.submit_job(...)` / `add_tasks(...)` call sites pass a +plain dict (it's nicer than constructing a pydantic model with the +right kwargs). Without TypedDicts, those dicts get no autocomplete +and no type-check; users discover misspellings at runtime. + +These TypedDicts mirror the pydantic models field-for-field. The +client methods accept `Model | DictForm`; type-checkers route the +dict literal against the TypedDict and surface IDE hints. + +Keep in sync with `models.py`. When a field is added there, mirror +it here. +""" + +import sys +from datetime import datetime +from typing import Any, Literal, TypedDict + +if sys.version_info >= (3, 11): + from typing import NotRequired +else: + # `typing.NotRequired` only exists on 3.11+; the SDK floors at 3.10. + from typing_extensions import NotRequired + +# ----- task input ----- + + +class TaskInputDict(TypedDict, total=False): + """One task in a submit/AddTasks payload.""" + + url: str + external_id: NotRequired[str] + metadata: NotRequired[dict[str, str]] + # HTTP method against `url`: "GET" (default) | "POST". POST is for + # safe/idempotent requests only — tasks are retried, so the target + # may see the same POST more than once. + method: NotRequired[str] + # Request body, POST only. Any JSON value: object/array/number/bool + # → application/json; a str is sent verbatim as form-urlencoded. + body: NotRequired[Any] + zenrows_params: NotRequired[dict[str, Any]] + + +# ----- submit ----- + + +class JobScheduleDict(TypedDict, total=False): + """Structured schedule block for `type: scheduled` submits. + Exactly one of `at`, `rate`, `calendar` must be set. + + `at` accepts either a tz-naive ISO string (`"2026-09-01T09:00:00"`) + or a naive `datetime.datetime` (no `tzinfo`). The SDK normalises + to the string form before sending; aware datetimes are rejected + client-side with a ValueError.""" + + at: NotRequired[str | datetime] + rate: NotRequired[dict[str, Any]] # {"every": int, "unit": "minute"|"hour"|"day"} + calendar: NotRequired[dict[str, Any]] # times_of_day + cadence + timezone: NotRequired[str] # IANA name; mandatory for `at` and `calendar` + + +class WebhookDict(TypedDict): + """Completion-callback config for a job. Set it at `POST /jobs`, or + manage it later via `PUT`/`DELETE /jobs/{id}/webhook`. `url` must be + HTTPS. Set `signature: true` to have each delivery signed with your + org's active HMAC key (delivered as an `X-Signature` header so your + receiver can verify it); it defaults to `false` (unsigned). Manage + signing keys via the `/hmac/keys` endpoints.""" + + url: str + signature: NotRequired[bool] + + +class SubmitJobDict(TypedDict, total=False): + """Body of `POST /jobs`. Mirrors `SubmitJobRequest`.""" + + type: NotRequired[Literal["regular", "scheduled"]] + status: NotRequired[Literal["open", "closed"]] + zenrows_params: NotRequired[dict[str, Any]] + schedule: NotRequired[JobScheduleDict] + tasks: NotRequired[list[TaskInputDict]] + file_input_id: NotRequired[str] + external_id: NotRequired[str] + name: NotRequired[str] + metadata: NotRequired[dict[str, str]] + webhook: NotRequired[WebhookDict] + + +# ----- add tasks ----- + + +class AddTasksDict(TypedDict): + """Body of `POST /jobs/{id}/tasks`.""" + + tasks: list[TaskInputDict] + last_batch: NotRequired[bool] + + +# ----- file inputs ----- + + +class CSVFieldsDict(TypedDict, total=False): + """The `csv.fields` map in a CreateJobInput body.""" + + # Each value is `int` (0-based index) or `str` (column name, + # requires `header=True`). TypedDicts can't express ColumnRef + # exactly, but `int | str` covers both shapes. + url: int | str + external_id: NotRequired[int | str] + + +class CSVSpecDict(TypedDict, total=False): + """Body of `csv` block on a CreateJobInput.""" + + fields: CSVFieldsDict + header: NotRequired[bool] + delimiter: NotRequired[str] + quote: NotRequired[str] + + +class CreateJobInputDict(TypedDict, total=False): + """Body of `POST /job_inputs`.""" + + type: Literal["csv"] + csv: CSVSpecDict + + +__all__ = [ + "AddTasksDict", + "CSVFieldsDict", + "CSVSpecDict", + "CreateJobInputDict", + "JobScheduleDict", + "SubmitJobDict", + "TaskInputDict", + "WebhookDict", +] diff --git a/src/zenrows/batch/_waiters.py b/src/zenrows/batch/_waiters.py new file mode 100644 index 0000000..e75ba27 --- /dev/null +++ b/src/zenrows/batch/_waiters.py @@ -0,0 +1,69 @@ +"""Polling helpers used by `ZenRowsBatchClient.wait_for_*` methods. + +Convention: a *waiter* polls a resource until it reaches a target +state or a timeout/error fires. We default to exponential-ish +backoff (capped) so short jobs don't pay a multi-second poll +cadence and long ones don't hammer the API every two seconds. + +Errors are `WaiterTimeout` (timed out) and `WaiterError` (predicate +raised, or the resource transitioned into an unexpected state the +caller flagged as failure). Both subclass the stdlib `TimeoutError` +/ `RuntimeError` so callers can catch with the broad stdlib types +too. +""" + +import random +import time +from collections.abc import Callable +from typing import TypeVar + +T = TypeVar("T") + + +class WaiterTimeout(TimeoutError): + """Raised when a waiter's `timeout` elapsed before the target state.""" + + +class WaiterError(RuntimeError): + """Raised when the resource entered a `failure_states` value.""" + + +def poll_until( + fetch: Callable[[], T], + *, + is_done: Callable[[T], bool], + is_failure: Callable[[T], bool] | None = None, + timeout: float = 300.0, + initial_interval: float = 1.0, + max_interval: float = 15.0, + backoff: float = 1.5, + jitter: float = 0.2, +) -> T: + """Generic poll loop. + + Calls `fetch()` repeatedly until `is_done(value)` is true (returns + the value) or `is_failure(value)` is true (raises `WaiterError`), + or `timeout` seconds elapse (raises `WaiterTimeout`). + + The wait between calls starts at `initial_interval`, multiplies by + `backoff` each iteration, caps at `max_interval`, and is jittered + by ±`jitter` fraction so concurrent waiters don't synchronise into + thundering-herd patterns against the API. + """ + deadline = time.monotonic() + timeout + interval = initial_interval + while True: + value = fetch() + if is_done(value): + return value + if is_failure and is_failure(value): + raise WaiterError(f"waiter: resource entered failure state ({value!r})") + now = time.monotonic() + if now >= deadline: + raise WaiterTimeout(f"waiter: timed out after {timeout:.1f}s waiting for target state") + # Jittered sleep, but never overshoot the deadline. + sleep = min(interval, deadline - now) + sleep = sleep * (1.0 + random.uniform(-jitter, jitter)) + if sleep > 0: + time.sleep(sleep) + interval = min(interval * backoff, max_interval) diff --git a/src/zenrows/batch/client.py b/src/zenrows/batch/client.py new file mode 100644 index 0000000..dc5ea3a --- /dev/null +++ b/src/zenrows/batch/client.py @@ -0,0 +1,1292 @@ +"""ZenRowsBatchClient — the friendly, typed facade. + +The **main pattern** is resource-style, in two tiers (see +`_resources.py`): a `JobRef` / `RunRef` — id-only operations, no data — +or a loaded `JobHandle` / `RunHandle` whose `.data` snapshot is +synchronous and guaranteed. `client.job(id)` and the `submit_*` methods +give you a ref; `get_job` / `iter_jobs` and `ref.load()` give you a +loaded handle: + + ref = client.submit_regular([...]) # JobRef — no GET yet + run = ref.wait() # loaded RunHandle + ref.download_to_dir("./out", # bulk download… + concurrency=8, # …in parallel… + progress=True) # …with a progress bar + +Handles are immutable snapshots: mutating ops (`close`, `stop`, …) +return a *new* loaded handle carrying the server's fresh state rather +than updating in place. The raw, pydantic-typed wire responses are +still accessible — every loaded handle exposes a `.data` (full `Job` / +`Run`), and refs returned by a `submit_*` call carry a +`.submit_response` with the original wire-shape. + +Listing endpoints return raw pages (`ListJobsResponse`, etc.); the +scanners (`iter_jobs`, `iter_runs`, `iter_results`) yield handles +(or `TaskResult` for results, since those are terminal data — they +don't have further-actions to chain). +""" + +import os +from collections.abc import Callable, Iterator, Mapping +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import IO, Any, Literal, TypeVar + +import httpx +from pydantic import BaseModel + +from zenrows.__version__ import __version__ +from zenrows.batch._download import ( + DEFAULT_MAX_BYTES_PER_FILE, + DEFAULT_MAX_COUNT_IN_MEMORY, + DEFAULT_MAX_FILES, + DEFAULT_MAX_TOTAL_BYTES_IN_MEMORY, + DownloadedResult, + download_to_dir, + download_to_memory, +) +from zenrows.batch._estimate import CostEstimate, ParamMap, _estimate_cost +from zenrows.batch._resources import ( + ExportHandle, + ExportRef, + JobHandle, + JobRef, + RunHandle, + RunRef, +) +from zenrows.batch._schedule import At, Calendar, Rate, Schedule +from zenrows.batch._transport import _Transport +from zenrows.batch._typed_dicts import ( + AddTasksDict, + CreateJobInputDict, + JobScheduleDict, + SubmitJobDict, + TaskInputDict, + WebhookDict, +) +from zenrows.batch._waiters import poll_until +from zenrows.batch.errors import BatchAPIError +from zenrows.batch.models import ( + AddTasksRequest, + AddTasksResponse, + CreateJobInputRequest, + CreateJobInputResponse, + Csv, + Export, + ExportStatus, + Fields, + FileInputColumnRef1, + FileInputColumnRef2, + HMACKeyCreated, + HMACKeyFinalized, + HMACKeyList, + IngestStatus, + Job, + JobSchedule, + JobStatus, + JobType, + ListJobRunsResponse, + ListJobsResponse, + ListResultsResponse, + RerunJobResponse, + Run, + RunStatus, + StartExportResponse, + SubmitJobRequest, + SubmitJobResponse, + TaskHistoryResponse, + TaskResult, + TestWebhookRequest, + TestWebhookResponse, + UpdateScheduleStateRequest, + WebhookConfig, +) + +# Run states that mean "no further work will happen on this run". +# Used as the default target for waiters; a run that's `completed`, +# `stopped`, or `deleted` never transitions again. +TERMINAL_RUN_STATUSES: frozenset[str] = frozenset( + {RunStatus.COMPLETED.value, RunStatus.STOPPED.value, RunStatus.DELETED.value} +) + +# Export states that don't transition again — `completed` (zip ready) +# or `failed` (size cap exceeded, fetch error, etc.). Used as the +# waiter default. +TERMINAL_EXPORT_STATUSES: frozenset[str] = frozenset( + {ExportStatus.COMPLETED.value, ExportStatus.FAILED.value} +) + +# Production endpoint. Hardcoded so the common path needs no +# configuration. The `base_url=` kwarg + `ZENROWS_BATCH_BASE_URL` +# env var are present for advanced use only. +DEFAULT_BASE_URL = "https://async.api.zenrows.com/v1" +DEFAULT_USER_AGENT = f"zenrows-batch-python/{__version__}" + +M = TypeVar("M", bound=BaseModel) + + +class ZenRowsBatchClient: + """Synchronous, typed client for the ZenRows Batch API. + + `api_key` is required. Most users read it from a secrets store or + environment variable at the call site: + + client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"]) + + Every non-2xx raises `BatchAPIError`; `BatchAPIError.code` carries + the stable `code` from the Problem body. + """ + + def __init__( + self, + api_key: str, + *, + base_url: str | None = None, + timeout: float | httpx.Timeout = 30.0, + retries: int = 3, + verify_ssl: bool | str = True, + user_agent: str = DEFAULT_USER_AGENT, + httpx_args: dict[str, Any] | None = None, + ): + """`retries` bounds automatic retries of transient failures + (HTTP 429/502/503/504 and network errors) on idempotent + requests — GET/PUT/DELETE and POSTs carrying an + `Idempotency-Key`. Retries use jittered exponential backoff and + honor `Retry-After`; set `retries=0` to disable.""" + if not api_key: + raise ValueError("ZenRowsBatchClient: api_key is required.") + base_url = base_url or os.environ.get("ZENROWS_BATCH_BASE_URL") or DEFAULT_BASE_URL + self._t = _Transport( + base_url=base_url, + api_key=api_key, + user_agent=user_agent, + timeout=timeout, + retries=retries, + verify=verify_ssl, + httpx_args=httpx_args, + ) + + # ----- lifecycle ----- + + def __enter__(self) -> "ZenRowsBatchClient": + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + def close(self) -> None: + self._t.close() + + @property + def base_url(self) -> str: + return self._t.base_url + + # ===== jobs (resource-returning) ===== + + def submit_job( + self, + body: SubmitJobRequest | SubmitJobDict, + *, + idempotency_key: str | None = None, + wait_for_ingest: bool = False, + ) -> JobRef: + """`POST /jobs` — submit a new scraping job. The general, + low-level path; most callers prefer the type-specific + `submit_regular` / `submit_scheduled` which hide the `type=` + boilerplate and give per-type validation in the signature. + + Large submissions come back `202 Accepted` with task rows + still streaming into storage; `wait_for_ingest=True` blocks + until that ingestion finishes, so results pages are complete + and `add_tasks` won't 409 on return. It costs no extra calls + on the ordinary 201 path. For custom poll knobs, leave it + False and call `JobRef.wait_for_ingest()` yourself. + + Returns a `JobRef` whose `.submit_response` carries the + immediate wire response (job_id, accepted_tasks, etc.). Call + `.load()` for a `JobHandle` with the full `.data` — or, when + `wait_for_ingest=True` actually polled, a loaded `JobHandle` + (with `.data`) is returned directly since the GET was paid + for anyway.""" + body = _as_model(body, SubmitJobRequest) + headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None + resp = _parse( + self._t.request_json("POST", "/jobs", body=body, headers=headers), + SubmitJobResponse, + ) + ref = JobRef(self, resp.job_id, submit_response=resp) + if ( + wait_for_ingest + and resp.latest_run is not None + and resp.latest_run.ingest_status is IngestStatus.PENDING + ): + # We paid for the poll GET anyway — hand back a loaded + # JobHandle (still carrying the submit response) rather than + # a bare ref, so the caller has `.data` for free. + data = ref.wait_for_ingest().data + return JobHandle(self, resp.job_id, data, submit_response=resp) + return ref + + # ----- type-specific submits (preferred over `submit_job` for + # most callers) ----- + + def submit_regular( + self, + urls: list[str | TaskInputDict] | None = None, + *, + file_input_id: str | None = None, + zenrows_params: ParamMap | None = None, + external_id: str | None = None, + name: str | None = None, + metadata: dict[str, str] | None = None, + webhook: WebhookDict | None = None, + idempotency_key: str | None = None, + wait_for_ingest: bool = False, + ) -> JobRef: + """Submit a one-shot scraping job (closed, all tasks upfront). + + Pass `urls` as either bare strings or inline dicts carrying + per-task `external_id` / `metadata` / `params`: + + client.submit_regular(["https://a", "https://b"]) + client.submit_regular([ + {"url": "https://a", "external_id": "ord-1"}, + {"url": "https://b", "external_id": "ord-2"}, + ]) + + `urls` and `file_input_id` are mutually exclusive — exactly + one must be set. The job is created with `status=closed`, + so no further `add_tasks` calls are accepted. For the + open-and-extend pattern see `submit_open`. + """ + body = _build_submit_body( + job_type="regular", + urls=urls, + file_input_id=file_input_id, + status="closed", + zenrows_params=zenrows_params, + external_id=external_id, + name=name, + metadata=metadata, + webhook=webhook, + ) + return self.submit_job( + body, idempotency_key=idempotency_key, wait_for_ingest=wait_for_ingest + ) + + def submit_open( + self, + urls: list[str | TaskInputDict] | None = None, + *, + zenrows_params: ParamMap | None = None, + external_id: str | None = None, + name: str | None = None, + metadata: dict[str, str] | None = None, + webhook: WebhookDict | None = None, + idempotency_key: str | None = None, + wait_for_ingest: bool = False, + ) -> JobRef: + """Submit a streaming-style job that stays open for more tasks. + + Created with `status=open` — `urls` can be empty (or omitted) + and tasks are added later via `JobRef.add_tasks`. Close + the job with `job.close()` once you're done; until then the + run keeps running and accepting new work. + + File-input upload is not supported here (the server only + accepts CSV inputs for `closed` regular jobs and scheduled + jobs). + """ + body = _build_submit_body( + job_type="regular", + urls=urls, + file_input_id=None, + status="open", + zenrows_params=zenrows_params, + external_id=external_id, + name=name, + metadata=metadata, + webhook=webhook, + ) + return self.submit_job( + body, idempotency_key=idempotency_key, wait_for_ingest=wait_for_ingest + ) + + def submit_scheduled( + self, + schedule: Schedule | JobScheduleDict, + urls: list[str | TaskInputDict] | None = None, + *, + file_input_id: str | None = None, + zenrows_params: ParamMap | None = None, + external_id: str | None = None, + name: str | None = None, + metadata: dict[str, str] | None = None, + webhook: WebhookDict | None = None, + idempotency_key: str | None = None, + ) -> JobRef: + """Submit a scheduled job. + + `schedule` is one of the typed builders (`At`, `Rate`, + `Calendar`) or a raw `JobScheduleDict` for power users. The + typed builders validate their inputs in `__post_init__`; + the dict form is a passthrough — the server validates. + + Examples: + + ```python + from datetime import datetime + from zenrows.batch import At, Calendar, Rate, Weekly + + # One-shot at 09:00 Berlin local time + client.submit_scheduled( + At(datetime(2026, 9, 1, 9, 0), timezone="Europe/Berlin"), + ["https://example.com/once"], + ) + + # Every 15 minutes (no timezone needed) + client.submit_scheduled( + Rate(every=15, unit="minute"), + ["https://example.com/poll"], + ) + + # 09:00 + 18:00 Berlin time, Mon/Wed/Fri + client.submit_scheduled( + Calendar( + times_of_day=["09:00", "18:00"], + cadence=Weekly(days=["mon", "wed", "fri"]), + timezone="Europe/Berlin", + ), + ["https://example.com/recurring"], + ) + ``` + + Like `submit_regular`, `urls` is bare-strings-or-inline-dicts + and is mutually exclusive with `file_input_id`. + """ + if isinstance(schedule, (At, Rate, Calendar)): + schedule = schedule.to_dict() + else: + schedule = _normalize_schedule(schedule) + body = _build_submit_body( + job_type="scheduled", + urls=urls, + file_input_id=file_input_id, + status="closed", + zenrows_params=zenrows_params, + schedule=schedule, + external_id=external_id, + name=name, + metadata=metadata, + webhook=webhook, + ) + return self.submit_job(body, idempotency_key=idempotency_key) + + def get_job(self, job_id: str) -> JobHandle: + """`GET /jobs/{job_id}` — returns a loaded `JobHandle` with + `.data` already populated. + + **Want the full `Job` in one line?** This is it — + `client.get_job(job_id).data` gives you the complete pydantic + `Job`. It's the eager shortcut for `client.job(job_id).load()` + (same single GET, same handle); reach for the bare + `client.job(job_id)` ref only when you want to *act* on the id + without fetching it.""" + data = self._get_job_data(job_id) + return JobHandle(self, job_id, data) + + def job(self, job_id: str) -> JobRef: + """A `JobRef` for an existing job with **no network call**. + Lifecycle operations act on the id directly (`delete`, `close`, + `rerun`, `add_tasks`); current-run and schedule ops live on the + `.run` / `.schedule` facets; call `.load()` for a `JobHandle` + with `.data`. Prefer this over `get_job` when you just want to + act on a known id without fetching it first:: + + client.job(job_id).delete() # no round-trip + client.job(job_id).run.stop() # POST /jobs/{id}/stop + client.job(job_id).schedule.pause() # skip future fires + client.job(job_id).load().data.status # explicit GET + """ + return JobRef(self, job_id) + + # ----- cost estimation (local, no API call) ----- + + def estimate_cost(self, body: SubmitJobRequest | SubmitJobDict) -> CostEstimate: + """Estimate the credit cost of a job before submitting it, + assuming every task succeeds once. **Takes the same body you'd + hand `submit_job`** — a raw dict or a typed `SubmitJobRequest` — + so you estimate the exact job you're about to submit. + + Returns a `CostEstimate` with `min`/`max` credits and a per-tier + `breakdown`; `min == max` (`.exact`) when no task uses + `mode=auto`. Per-task `zenrows_params` override the job-level + params on collision (task wins), matching the worker's merge. + + `file_input` bodies estimate as zero tasks — the CSV row count + isn't known client-side; count the rows first. + + This is the single entry point for estimation. It's currently + computed client-side from the SDK's rate card (no network call); + it may move server-side in a future release, so it lives on the + client to keep call sites stable. + """ + model = _as_model(body, SubmitJobRequest) + return _estimate_cost(model.tasks or [], zenrows_params=model.zenrows_params) + + def list_jobs( + self, + *, + limit: int | None = None, + cursor: str | None = None, + job_type: JobType | str | None = None, + status: JobStatus | str | None = None, + ) -> ListJobsResponse: + """`GET /jobs` — raw page (with `next_cursor`). For most uses + prefer the auto-paginating `iter_jobs`.""" + return _parse( + self._t.request_json( + "GET", + "/jobs", + params={ + "limit": limit, + "cursor": cursor, + # The wire-level query param is `type`; the + # Python kwarg is `job_type` so it doesn't + # shadow the builtin. + "type": _enum_value(job_type), + "status": _enum_value(status), + }, + ), + ListJobsResponse, + ) + + def iter_jobs( + self, + *, + job_type: JobType | str | None = None, + status: JobStatus | str | None = None, + page_size: int | None = None, + ) -> Iterator[JobHandle]: + """Auto-paginate `list_jobs`, yielding loaded `JobHandle`s with + their `.data` pre-populated from the page.""" + for job in _scan( + lambda cursor: self.list_jobs( + limit=page_size, cursor=cursor, job_type=job_type, status=status + ), + items_attr="jobs", + ): + yield JobHandle(self, job.job_id, job) + + # ===== runs (resource-returning) ===== + + def get_run(self, job_id: str, *, run_id: str) -> RunHandle: + """`GET /jobs/{job_id}/runs/{run_id}` — returns a loaded + `RunHandle` with `.data` populated.""" + data = self._get_run_data(job_id, run_id) + return RunHandle(self, job_id, run_id, data) + + def run(self, job_id: str, run_id: str) -> RunRef: + """A `RunRef` for an existing run with **no network call** — the + run counterpart of `job`. Acts on `(job_id, run_id)` directly + (`delete`, `results`, `wait`); call `.load()` for a `RunHandle` + with `.data`:: + + client.run(job_id, run_id).delete() # scrub one run, no GET + """ + return RunRef(self, job_id, run_id) + + def list_runs( + self, + job_id: str, + *, + limit: int | None = None, + cursor: str | None = None, + ) -> ListJobRunsResponse: + """`GET /jobs/{job_id}/runs` — raw page. For most uses prefer + the auto-paginating `JobRef.runs()`.""" + return _parse( + self._t.request_json( + "GET", + f"/jobs/{job_id}/runs", + params={"limit": limit, "cursor": cursor}, + ), + ListJobRunsResponse, + ) + + def iter_runs( + self, + job_id: str, + *, + page_size: int | None = None, + ) -> Iterator[RunHandle]: + """Auto-paginate runs of a job, yielding loaded `RunHandle`s.""" + for run in self._iter_runs_raw(job_id, page_size=page_size): + yield RunHandle(self, job_id, run.run_id, run) + + # ===== results / content (terminal data) ===== + + def list_results( + self, + job_id: str, + *, + run_id: str | None = None, + status: str | None = None, + cursor: str | None = None, + ) -> ListResultsResponse: + """Raw results page. Prefer `iter_results` or + `JobRef.results()` for auto-pagination.""" + path = f"/jobs/{job_id}/runs/{run_id}/results" if run_id else f"/jobs/{job_id}/results" + return _parse( + self._t.request_json("GET", path, params={"status": status, "cursor": cursor}), + ListResultsResponse, + ) + + def iter_results( + self, + job_id: str, + *, + run_id: str | None = None, + status: str | None = None, + ) -> Iterator[TaskResult]: + """Auto-paginate results, yielding `TaskResult` per row.""" + return self._iter_results_raw(job_id, run_id=run_id, status=status) + + # ===== waiter ===== + + def wait_for_run( + self, + job_id: str, + *, + run_id: str | None = None, + target_statuses: set[str] | frozenset[str] = TERMINAL_RUN_STATUSES, + failure_statuses: set[str] | frozenset[str] | None = None, + timeout: float = 300.0, + poll_interval: float = 2.0, + max_poll_interval: float = 15.0, + progress: bool = False, + ) -> Run: + """Block until a run reaches one of `target_statuses`, polling + with jittered exponential backoff. + + `progress=True` shows a tqdm bar with totals as they advance. + `None` inherits the client-level `progress` setting (which + itself defaults to off unless `ZENROWS_BATCH_PROGRESS=true`). + """ + return self._wait_for_run_raw( + job_id, + run_id=run_id, + target_statuses=target_statuses, + failure_statuses=failure_statuses, + timeout=timeout, + poll_interval=poll_interval, + max_poll_interval=max_poll_interval, + progress=progress, + ) + + # ===== file inputs (CSV uploads) ===== + + def create_job_input( + self, + body: CreateJobInputRequest | CreateJobInputDict, + ) -> CreateJobInputResponse: + """`POST /job_inputs` — allocate a CSV upload slot. Most + callers want the higher-level `upload_csv` instead.""" + body = _as_model(body, CreateJobInputRequest) + return _parse( + self._t.request_json("POST", "/job_inputs", body=body), + CreateJobInputResponse, + ) + + def upload_csv( + self, + source: str | Path | IO[bytes], + *, + fields: dict[str, int | str], + header: bool = False, + delimiter: str = ",", + quote: str = '"', + ) -> str: + """Allocate a CSV slot + PUT the body. Returns the + `file_input_id` you then pass to `submit_job(...)`.""" + ref_fields: dict[str, Any] = {} + for key in ("url", "external_id"): + if key not in fields: + continue + v = fields[key] + ref_fields[key] = ( + FileInputColumnRef1(root=v) if isinstance(v, str) else FileInputColumnRef2(root=v) + ) + request = CreateJobInputRequest( + type="csv", + csv=Csv( + delimiter=delimiter, + quote=quote, + header=header, + fields=Fields.model_validate(ref_fields), + ), + ) + created = self.create_job_input(request) + + headers = dict(created.upload.headers or {}) + headers.setdefault("Content-Type", "text/csv") + + data = _read_csv_body(source) + + # The presigned URL lives on a different host (S3 / dev + # _local route). Use a bare httpx so our auth header doesn't + # leak there. + with httpx.Client(timeout=httpx.Timeout(60.0)) as bare: + r = bare.request( + created.upload.method, str(created.upload.url), content=data, headers=headers + ) + if r.status_code >= 400: + raise BatchAPIError(r.status_code, problem=None, raw=r.content) + + return created.file_input_id + + # ===== Webhooks ===== + + def get_job_webhook(self, job_id: str) -> WebhookConfig: + """`GET /jobs/{job_id}/webhook` — the job's current webhook + config. Raises `BatchAPIError` (404) when none is set.""" + return _parse(self._t.request_json("GET", f"/jobs/{job_id}/webhook"), WebhookConfig) + + def put_job_webhook(self, job_id: str, config: WebhookConfig | WebhookDict) -> WebhookConfig: + """`PUT /jobs/{job_id}/webhook` — replace the webhook config + wholesale. Both `url` and `signature` are required (no + defaulting at the mutate boundary, so a partial update can't + silently toggle signing). Returns the persisted config.""" + model = _as_model(config, WebhookConfig) + return _parse( + self._t.request_json("PUT", f"/jobs/{job_id}/webhook", body=model), WebhookConfig + ) + + def delete_job_webhook(self, job_id: str) -> None: + """`DELETE /jobs/{job_id}/webhook` — clear the webhook config. + Idempotent: 204 whether or not one was set.""" + self._t.request_json("DELETE", f"/jobs/{job_id}/webhook") + + def test_webhook(self, config: TestWebhookRequest | WebhookDict) -> TestWebhookResponse: + """`POST /webhook/test` — dispatch a synthetic `webhook.test` + event to a receiver URL and report the outcome, without touching + any job. Handy to verify a receiver before you wire it to a job. + + `signature` defaults to `false`; set it `true` to exercise the + HMAC path (requires an active signing key, else 400 + `webhook_signing_requires_active_key`).""" + model = _as_model(config, TestWebhookRequest) + return _parse( + self._t.request_json("POST", "/webhook/test", body=model), TestWebhookResponse + ) + + # ===== HMAC key lifecycle ===== + + def list_hmac_keys(self) -> HMACKeyList: + return _parse(self._t.request_json("GET", "/hmac/keys"), HMACKeyList) + + def rotate_hmac_key(self) -> HMACKeyCreated: + """Capture the returned `secret` HERE — it is not revealed again.""" + return _parse(self._t.request_json("POST", "/hmac/keys/rotate"), HMACKeyCreated) + + def finalize_hmac_key(self) -> HMACKeyFinalized: + return _parse( + self._t.request_json("POST", "/hmac/keys/rotate/finalize"), + HMACKeyFinalized, + ) + + def cancel_hmac_rotation(self) -> None: + self._t.request_json("DELETE", "/hmac/keys/rotate") + + # ===== Results exports ===== + + def start_results_export(self, job_id: str, run_id: str) -> ExportRef: + """`POST /jobs/{job_id}/runs/{run_id}/exports` — start an async + zip of every task body in the run. Returns an `ExportRef` + carrying the just-issued `export_id`; call `.load()` or block on + `.wait()` for the download URL. + + Failure modes: 404 if the job/run is missing or not yours. + The worker fails the export (status=failed, + error="results are larger then 1 gb") if the pre-zip total + exceeds the server's 1 GiB cap. + """ + resp = self._post_export_start(job_id, run_id) + return ExportRef(self, job_id, run_id, resp.export_id, start_response=resp) + + def get_results_export(self, job_id: str, run_id: str, export_id: str) -> ExportHandle: + """`GET /jobs/{job_id}/runs/{run_id}/exports/{export_id}` — + snapshot one export. Returns a loaded `ExportHandle` with `.data` + pre-populated. 404 covers both "no such id" and "TTL-swept".""" + data = self._get_export(job_id, run_id, export_id) + return ExportHandle(self, job_id, run_id, export_id, data) + + def wait_for_export( + self, + job_id: str, + run_id: str, + export_id: str, + *, + target_statuses: set[str] | frozenset[str] = TERMINAL_EXPORT_STATUSES, + timeout: float = 600.0, + poll_interval: float = 2.0, + max_poll_interval: float = 15.0, + ) -> Export: + """Block until an export reaches a terminal state. Defaults to + `{completed, failed}`. Returns the `Export` snapshot — callers + check `.status` and (on `completed`) `.download_url`. + + `failed` is NOT raised; the caller decides whether + `error="results are larger then 1 gb"` is fatal or expected. + """ + return self._wait_for_export_raw( + job_id, + run_id, + export_id, + target_statuses=target_statuses, + timeout=timeout, + poll_interval=poll_interval, + max_poll_interval=max_poll_interval, + ) + + # ========================================================== + # ===== "raw" methods (no resource wrapping; used by ===== + # ===== handles internally and as escape hatches). ===== + # ========================================================== + + def _get_job_data(self, job_id: str) -> Job: + return _parse(self._t.request_json("GET", f"/jobs/{job_id}"), Job) + + def _get_run_data(self, job_id: str, run_id: str) -> Run: + return _parse(self._t.request_json("GET", f"/jobs/{job_id}/runs/{run_id}"), Run) + + def _post_close(self, job_id: str) -> Job: + return _parse(self._t.request_json("POST", f"/jobs/{job_id}/close"), Job) + + def _post_stop(self, job_id: str) -> Run: + # `/stop`, `/pause`, `/resume` all act on the current run and + # echo the refreshed Run object (not the Job). + return _parse(self._t.request_json("POST", f"/jobs/{job_id}/stop"), Run) + + def _post_pause(self, job_id: str) -> Run: + return _parse(self._t.request_json("POST", f"/jobs/{job_id}/pause"), Run) + + def _post_resume(self, job_id: str) -> Run: + return _parse(self._t.request_json("POST", f"/jobs/{job_id}/resume"), Run) + + def _delete(self, job_id: str) -> None: + self._t.request_json("DELETE", f"/jobs/{job_id}") + + def _delete_run(self, job_id: str, run_id: str) -> None: + self._t.request_json("DELETE", f"/jobs/{job_id}/runs/{run_id}") + + def _post_rerun( + self, + job_id: str, + *, + status: str | list[str] | None = None, + idempotency_key: str | None = None, + ) -> RerunJobResponse: + headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None + params: dict[str, Any] | None = None + if status is not None: + if isinstance(status, list): + params = {"status": ",".join(status)} + else: + params = {"status": status} + return _parse( + self._t.request_json("POST", f"/jobs/{job_id}/rerun", params=params, headers=headers), + RerunJobResponse, + ) + + def _resolve_schedule(self, schedule: "Schedule | JobScheduleDict") -> JobSchedule: + """Normalise a schedule (typed builder, or dict with a possible + `datetime` in `at`) into a validated `JobSchedule` model — the + same resolution `submit_scheduled` does.""" + if isinstance(schedule, (At, Rate, Calendar)): + schedule = schedule.to_dict() + else: + schedule = _normalize_schedule(schedule) + return _as_model(schedule, JobSchedule) + + def _put_schedule(self, job_id: str, schedule: JobSchedule) -> Job: + return _parse( + self._t.request_json("PUT", f"/jobs/{job_id}/schedule", body=schedule), + Job, + ) + + def _post_schedule_state(self, job_id: str, state: str) -> Job: + body = UpdateScheduleStateRequest(schedule_state=state) # type: ignore + return _parse( + self._t.request_json("POST", f"/jobs/{job_id}/schedule/state", body=body), + Job, + ) + + def _post_tasks(self, job_id: str, body: AddTasksRequest | AddTasksDict) -> AddTasksResponse: + body = _as_model(body, AddTasksRequest) + return _parse( + self._t.request_json("POST", f"/jobs/{job_id}/tasks", body=body), + AddTasksResponse, + ) + + def _iter_runs_raw(self, job_id: str, *, page_size: int | None = None) -> Iterator[Run]: + yield from _scan( + lambda cursor: self.list_runs(job_id, limit=page_size, cursor=cursor), + items_attr="runs", + ) + + def _iter_results_raw( + self, + job_id: str, + *, + run_id: str | None = None, + status: str | None = None, + ) -> Iterator[TaskResult]: + yield from _scan( + lambda cursor: self.list_results(job_id, run_id=run_id, status=status, cursor=cursor), + items_attr="results", + ) + + def _get_task_history_raw( + self, job_id: str, task_id: str, *, run_id: str | None = None + ) -> TaskHistoryResponse: + path = ( + f"/jobs/{job_id}/runs/{run_id}/tasks/{task_id}/history" + if run_id + else f"/jobs/{job_id}/tasks/{task_id}/history" + ) + return _parse(self._t.request_json("GET", path), TaskHistoryResponse) + + def _post_export_start(self, job_id: str, run_id: str) -> StartExportResponse: + return _parse( + self._t.request_json("POST", f"/jobs/{job_id}/runs/{run_id}/exports"), + StartExportResponse, + ) + + def _get_export(self, job_id: str, run_id: str, export_id: str) -> Export: + return _parse( + self._t.request_json("GET", f"/jobs/{job_id}/runs/{run_id}/exports/{export_id}"), + Export, + ) + + def _wait_for_export_raw( + self, + job_id: str, + run_id: str, + export_id: str, + *, + target_statuses: set[str] | frozenset[str], + timeout: float, + poll_interval: float, + max_poll_interval: float = 15.0, + ) -> Export: + target = target_statuses or TERMINAL_EXPORT_STATUSES + + def fetch() -> Export: + return self._get_export(job_id, run_id, export_id) + + def is_done(e: Export) -> bool: + return e.status.value in target + + return poll_until( + fetch=fetch, + is_done=is_done, + timeout=timeout, + initial_interval=poll_interval, + max_interval=max_poll_interval, + ) + + def download_all_results( + self, + job_id: str, + run_id: str, + target_path: str | Path, + *, + wait_timeout: float = 600.0, + poll_interval: float = 2.0, + chunk_size: int = 1 << 20, + ) -> Path: + """End-to-end helper: start an export, wait for it, and save + the zip to `target_path`. + + Steps: + 1. `POST .../exports` (start) → `export_id`. + 2. Poll `GET .../exports/{id}` until `completed` or `failed`. + 3. On `completed`, stream the presigned URL to `target_path`. + + Raises `WaiterTimeout` if the export doesn't reach a terminal + state within `wait_timeout`. Raises `BatchAPIError` with the + server's error message on `status=failed` (e.g. + `"results are larger then 1 gb"`). Returns the `Path` written. + + The server-side export is capped at 1 GiB per run. For larger + runs (or one file per task), use `download_to_dir` instead: it + fetches each body client-side with no size limit, at the cost + of being slower (one body at a time, tunable via `concurrency=`). + """ + export = self.start_results_export(job_id, run_id) + final = self.wait_for_export( + job_id, + run_id, + export.export_id, + timeout=wait_timeout, + poll_interval=poll_interval, + ) + if final.status != ExportStatus.COMPLETED: + raise BatchAPIError( + status_code=0, + problem=None, + raw=(final.error or "export failed").encode(), + ) + if not final.download_url: + raise BatchAPIError( + status_code=0, + problem=None, + raw=b"export completed but server returned no download_url", + ) + + target = Path(target_path) + target.parent.mkdir(parents=True, exist_ok=True) + # Presigned URLs live on a different host (S3 / dev `_local`). + # Use a bare httpx so our auth header doesn't leak there. + with ( + httpx.Client(timeout=httpx.Timeout(60.0)) as bare, + bare.stream("GET", str(final.download_url)) as r, + ): + if r.status_code >= 400: + raise BatchAPIError(r.status_code, problem=None, raw=r.read()) + with target.open("wb") as f: + for chunk in r.iter_bytes(chunk_size): + f.write(chunk) + return target + + def _wait_for_run_raw( + self, + job_id: str, + *, + run_id: str | None, + target_statuses: set[str] | frozenset[str] | None, + failure_statuses: set[str] | frozenset[str] | None, + timeout: float, + poll_interval: float, + max_poll_interval: float = 15.0, + progress: bool = False, + ) -> Run: + target = target_statuses or TERMINAL_RUN_STATUSES + + def fetch() -> Run: + if not run_id: + job = self._get_job_data(job_id) + if not job.latest_run: + return _PendingRun() # type: ignore + return job.latest_run + return self._get_run_data(job_id, run_id) + + def is_done(run: Run) -> bool: + if isinstance(run, _PendingRun): + return False + return run.status.value in target + + def is_failure(run: Run) -> bool: + if not failure_statuses or isinstance(run, _PendingRun): + return False + return run.status.value in failure_statuses + + with _maybe_wait_progress(progress, job_id=job_id) as on_poll: + + def fetch_with_progress() -> Run: + run = fetch() + on_poll(run) + return run + + return poll_until( + fetch=fetch_with_progress, + is_done=is_done, + is_failure=is_failure, + timeout=timeout, + initial_interval=poll_interval, + max_interval=max_poll_interval, + ) + + def _wait_for_ingest_raw( + self, + job_id: str, + *, + timeout: float, + poll_interval: float, + max_poll_interval: float = 15.0, + ) -> Job: + """Poll `GET /jobs/{id}` until the latest run's async-carrier + ingestion is no longer `pending`. Done means: `ingest_status` + is `done` or absent (the run never ingested asynchronously), + the run is terminal (a mid-ingest stop flips the field to + `done` — SPEC §3.1), or there is no run yet (nothing is + ingesting). Returns the final `Job` snapshot.""" + + def fetch() -> Job: + return self._get_job_data(job_id) + + def is_done(job: Job) -> bool: + run = job.latest_run + if run is None or run.status.value in TERMINAL_RUN_STATUSES: + return True + return run.ingest_status is not IngestStatus.PENDING + + return poll_until( + fetch=fetch, + is_done=is_done, + timeout=timeout, + initial_interval=poll_interval, + max_interval=max_poll_interval, + ) + + def _download_dir( + self, + job_id: str, + run_id: str | None, + target_dir: str | Path, + *, + status: str | None, + name_fn: Callable[[TaskResult], str] | None, + use_external_id: bool, + concurrency: int, + progress: bool, + max_files: int | None, + max_bytes_per_file: int | None, + ) -> int: + return download_to_dir( + self, + job_id, + Path(target_dir), + run_id=run_id, + status=status, + name_fn=name_fn, + use_external_id=use_external_id, + concurrency=concurrency, + progress=progress, + max_files=max_files if max_files is not None else DEFAULT_MAX_FILES, + max_bytes_per_file=( + max_bytes_per_file if max_bytes_per_file is not None else DEFAULT_MAX_BYTES_PER_FILE + ), + ) + + def _download_memory( + self, + job_id: str, + run_id: str | None, + *, + status: str | None, + concurrency: int, + progress: bool, + max_count: int | None, + max_total_bytes: int | None, + max_bytes_per_file: int | None, + ) -> list[DownloadedResult]: + return download_to_memory( + self, + job_id, + run_id=run_id, + status=status, + concurrency=concurrency, + progress=progress, + max_count=max_count if max_count is not None else DEFAULT_MAX_COUNT_IN_MEMORY, + max_total_bytes=( + max_total_bytes + if max_total_bytes is not None + else DEFAULT_MAX_TOTAL_BYTES_IN_MEMORY + ), + max_bytes_per_file=( + max_bytes_per_file if max_bytes_per_file is not None else DEFAULT_MAX_BYTES_PER_FILE + ), + ) + + +# ----- helpers ----- + + +def _scan( + fetch_page: Any, + *, + items_attr: str, + cursor_attr: str = "next_cursor", +) -> Iterator[Any]: + """Generic cursor-pagination scanner.""" + cursor: str | None = None + while True: + page = fetch_page(cursor) + yield from getattr(page, items_attr) + cursor = getattr(page, cursor_attr, None) + if not cursor: + return + + +class _PendingRun: + """Sentinel returned by the waiter when a scheduled job has not + fired its first run yet.""" + + status = None + + +def _normalize_schedule(s: JobScheduleDict) -> JobScheduleDict: + """Convert a `datetime` in `s["at"]` to a tz-naive ISO string. + Reject tz-aware datetimes with a clear ValueError — keeping the + tz field as the single source of truth is what makes DST + transitions deterministic. Returns a shallow copy with the + normalised `at`; other fields pass through unchanged.""" + if "at" not in s: + return s + raw_at = s["at"] + if isinstance(raw_at, datetime): + if raw_at.tzinfo is not None and raw_at.utcoffset() is not None: + raise ValueError( + "schedule.at must be a naive datetime (no tzinfo); " + "supply schedule.timezone separately so DST transitions " + "stay deterministic." + ) + out: JobScheduleDict = {**s} + out["at"] = raw_at.strftime("%Y-%m-%dT%H:%M:%S") # type: ignore[typeddict-item] + return out + return s + + +def _build_submit_body( + *, + job_type: Literal["regular", "scheduled"], + urls: list[str | TaskInputDict] | None, + file_input_id: str | None, + status: Literal["open", "closed"], + zenrows_params: ParamMap | None, + schedule: JobScheduleDict | None = None, + external_id: str | None = None, + name: str | None = None, + metadata: dict[str, str] | None = None, + webhook: WebhookDict | None = None, +) -> SubmitJobDict: + """Shared bodies for the type-specific submit methods. + + Validates the urls/file_input_id mutual exclusion locally so + the API call doesn't pay for an obvious client-side mistake. + Returns a dict (not a SubmitJobRequest) so the values that + weren't supplied stay out of the wire payload — leaning on + the transport's `exclude_unset=True` semantics.""" + if urls is not None and file_input_id is not None: + raise ValueError("submit: pass `urls` OR `file_input_id`, not both.") + if urls is None and file_input_id is None and status == "closed": + raise ValueError( + "submit: closed jobs require `urls` or `file_input_id` " + "(use submit_open() for the open/extend pattern)." + ) + + body: SubmitJobDict = {"type": job_type, "status": status} + if zenrows_params: + body["zenrows_params"] = zenrows_params + if schedule: + body["schedule"] = schedule + if file_input_id: + body["file_input_id"] = file_input_id + if urls: + body["tasks"] = [_coerce_url(u) for u in urls] + if external_id: + body["external_id"] = external_id + if name: + body["name"] = name + if metadata: + body["metadata"] = metadata + if webhook: + # `signature` is optional for the caller but required on the wire; + # default it to false (unsigned), matching the server default. + body["webhook"] = {"signature": False, **webhook} + return body + + +def _coerce_url(item: str | TaskInputDict) -> TaskInputDict: + """Accept a bare URL string OR a TaskInputDict. Strings become + `{"url": "..."}`; dicts pass through untouched.""" + if isinstance(item, str): + return {"url": item} + return item + + +def _as_model(value: BaseModel | Mapping[str, Any], cls: type[M]) -> M: + if isinstance(value, cls): + return value + return cls.model_validate(value) + + +def _parse(payload: Any, cls: type[M]) -> M: + return cls.model_validate(payload) + + +def _enum_value(v: JobType | JobStatus | str | None) -> str | None: + """Flatten Enum-or-str-or-None to the wire string.""" + if not v: + return None + if isinstance(v, Enum): + return v.value + return v + + +def _read_csv_body(source: str | Path | IO[bytes]) -> bytes: + if isinstance(source, (str, Path)): + return Path(source).read_bytes() + data = source.read() + if isinstance(data, str): + return data.encode("utf-8") + return data + + +# ----- waiter progress shim ----- + + +from contextlib import contextmanager # noqa: E402 + + +@contextmanager +def _maybe_wait_progress(enabled: bool, *, job_id: str): + """Yields an `on_poll(run)` callback that updates a tqdm bar each + iteration with `(successful+failed)/total`. No-op when + `enabled=False` or `tqdm` is missing. + + The bar's `total` starts at None (indeterminate) and is set on + the first poll that returns a non-pending run — for scheduled + jobs we don't know the totals until the first fire materialises.""" + if not enabled: + yield lambda _run: None + return + try: + from tqdm.auto import tqdm + except ImportError: + yield lambda _run: None + return + + bar = tqdm(total=None, desc=f"{job_id}: pending", unit="task") + try: + last_done = [0] + + def on_poll(run: Run | _PendingRun) -> None: + if isinstance(run, _PendingRun): + # Scheduled job's first run hasn't materialised yet. + bar.set_description_str(f"{job_id}: pending") + return + if bar.total is None: + bar.total = run.stats.total + bar.refresh() + done = run.stats.successful + run.stats.failed + if done > last_done[0]: + bar.update(done - last_done[0]) + last_done[0] = done + bar.set_description_str(f"{job_id}: {run.status.value}") + + yield on_poll + finally: + bar.close() + + +__all__ = [ + "DEFAULT_BASE_URL", + "DEFAULT_USER_AGENT", + "TERMINAL_EXPORT_STATUSES", + "TERMINAL_RUN_STATUSES", + "ZenRowsBatchClient", +] diff --git a/src/zenrows/batch/errors.py b/src/zenrows/batch/errors.py new file mode 100644 index 0000000..a78dc51 --- /dev/null +++ b/src/zenrows/batch/errors.py @@ -0,0 +1,83 @@ +"""RFC 7807 Problem JSON → friendly Python exceptions. + +The Batch API returns errors as `application/problem+json`. +We surface them as `BatchAPIError` with a structured `problem` payload +plus a flat `code` shortcut so callers can branch without indexing +into a dict. +""" + +import json +from dataclasses import dataclass +from typing import Any + +import httpx + + +@dataclass(slots=True) +class ProblemDetail: + """Decoded RFC 7807 Problem body. + + `extras` keeps any non-standard members (e.g. `invalid_tasks`) + so handlers can dig in without us tracking every shape. + """ + + type: str + title: str + status: int + code: str + detail: str | None = None + instance: str | None = None + extras: dict[str, Any] | None = None + + @classmethod + def from_response(cls, response: httpx.Response) -> "ProblemDetail | None": + """Parse a Problem body. Returns None if the body isn't JSON. + + Tolerant — production servers occasionally return non-JSON + errors (e.g. ALB blocked at the edge); we degrade gracefully. + """ + try: + body = json.loads(response.content or b"{}") + except (json.JSONDecodeError, UnicodeDecodeError): + return None + if not isinstance(body, dict): + return None + standard = {"type", "title", "status", "code", "detail", "instance"} + extras = {k: v for k, v in body.items() if k not in standard} + return cls( + type=body.get("type", "about:blank"), + title=body.get("title", "Error"), + status=int(body.get("status", response.status_code)), + code=body.get("code", "internal"), + detail=body.get("detail"), + instance=body.get("instance"), + extras=extras or None, + ) + + +class BatchAPIError(Exception): + """A non-2xx response from the Batch API. + + `code` is the RFC 7807 `code` member (e.g. `file_input_not_found`, + `idempotency_key_conflict`). Stable; safe to branch on. + """ + + def __init__(self, status_code: int, problem: ProblemDetail | None, raw: bytes): + self.status_code = status_code + self.problem = problem + self.raw = raw + self.code: str = problem.code if problem else "internal" + msg = ( + f"{status_code} {problem.title}: {problem.detail or problem.code}" + if problem + else f"{status_code} (no problem body)" + ) + super().__init__(msg) + + @classmethod + def from_response(cls, response: httpx.Response) -> "BatchAPIError": + return cls( + status_code=response.status_code, + problem=ProblemDetail.from_response(response), + raw=response.content, + ) diff --git a/src/zenrows/batch/models.py b/src/zenrows/batch/models.py new file mode 100644 index 0000000..185e42a --- /dev/null +++ b/src/zenrows/batch/models.py @@ -0,0 +1,1153 @@ +# generated by datamodel-codegen: +# filename: openapi.yaml +# timestamp: 2026-07-27T07:37:28+00:00 + +from __future__ import annotations + +from enum import Enum +from typing import Annotated, Any, Literal + +from pydantic import AnyUrl, AwareDatetime, BaseModel, ConfigDict, Field, RootModel + + +class JobType(Enum): + """ + - `regular` — a run is created at submit time. + - `scheduled` — fires on a recurring or one-shot schedule. + Submit stores the task list as a template on the job row; + each scheduled fire produces a fresh Run. `schedule` + field required. Read-only template: `addTasks` and + `close` return 409. `rerun` (full or filtered) and `stop` + are allowed. + + """ + + REGULAR = "regular" + SCHEDULED = "scheduled" + + +class JobStatus(Enum): + """ + - `open` — initial run still accepting `addTasks`. Only + meaningful while `latest_run.run_sequence == 1`. + - `closed` — no more tasks accepted (created closed, or + closed via `/close` / `addTasks{last_batch}` / `/rerun`). + - `deleted` — async deletion in progress; the job disappears + once it finishes. + + """ + + OPEN = "open" + CLOSED = "closed" + DELETED = "deleted" + + +class ScheduleState(Enum): + """ + Run/pause flag on a scheduled job. While `paused`, scheduled + fires are skipped. Default at submit: `active`. Flip via + `POST /v1/jobs/{id}/schedule/state`. + + """ + + ACTIVE = "active" + PAUSED = "paused" + + +class UpdateScheduleStateRequest(BaseModel): + schedule_state: ScheduleState + + +class RunTrigger(Enum): + """ + What set this run in motion. Always set. + - `manual` — caller-initiated: `POST /jobs`, `POST /jobs/{id}/rerun` + (any job type, full or `?status=`-filtered, manual fire of + a scheduled job). + - `scheduled` — automatic, fired by the configured + schedule (recurring or one-shot). + + """ + + MANUAL = "manual" + SCHEDULED = "scheduled" + + +class RunStatus(Enum): + """ + In-flight: + - `running` — work queued / in flight. + - `pending` — initial run of an open job, idle between batches. + + Terminal: + - `completed` — natural finish. + - `stopped` — caller called `POST /jobs/{id}/stop`. No new + tasks are picked up; in-flight tasks may still finish. + Result bodies are kept. `stats.completed < stats.total` + signals "stopped early". + - `failed` — the run was auto-failed on an account-level error + (insufficient credits / inactive subscription). No new tasks + are picked up; `failure_reason` carries the cause. Re-runnable + once the account is resolved. Result bodies already produced + are kept. + - `deleted` — caller called + `DELETE /v1/jobs/{id}/runs/{run_id}`. The run's result + bodies and data are being deleted; once complete the run + disappears. + + """ + + RUNNING = "running" + PENDING = "pending" + COMPLETED = "completed" + STOPPED = "stopped" + FAILED = "failed" + DELETED = "deleted" + + +class TaskStatus(Enum): + PENDING = "pending" + PROCESSING = "processing" + SUCCESSFUL = "successful" + FAILED = "failed" + + +class ResultType(Enum): + """ + Body format of a successful task result; matches the job's `format` 1:1. + """ + + HTML = "html" + JSON = "json" + MARKDOWN = "markdown" + PLAINTEXT = "plaintext" + PDF = "pdf" + + +class Format(Enum): + """ + Derived server-side from `zenrows_params` at submit time. + Precedence: + - `response_type: markdown|plaintext|pdf` → matching format. + - `autoparse: true`, `json_response: true`, or non-empty + `css_extractor` → `json`. + - otherwise → `html`. + Stamped on every successful task result and used to set the + right `Content-Type` when you fetch the content. + + """ + + HTML = "html" + JSON = "json" + MARKDOWN = "markdown" + PLAINTEXT = "plaintext" + PDF = "pdf" + + +class Method(Enum): + """ + HTTP method used against `url`. Case-insensitive. POST is + for **safe/idempotent** requests only (GraphQL queries, + search endpoints): tasks are retried on transient failures + and reruns, so the target may see the same POST more than + once. Callers that cannot tolerate a duplicate should + disable reruns. POST rides the standard (non-headless) + scraping path — combining it with `js_render`, + `js_instructions`, or `json_response` is rejected with + 400 `method_param_conflict`. + + """ + + GET = "GET" + POST = "POST" + + +class TaskInput(BaseModel): + external_id: Annotated[ + str | None, Field(max_length=128, pattern="^[A-Za-z0-9._-]+$") + ] = None + """ + Optional caller-supplied correlation id — typically an + identifier from the caller's own system. Surfaced verbatim + in result/content responses so callers can match results + back to their records. **Not required to be unique** — + callers may reuse the same value across tasks (e.g. when + multiple scrapes correlate to the same upstream record). + Independent of the server- + assigned `task_id`. + + """ + url: AnyUrl + """ + Must be http(s). Other schemes rejected at submit. + """ + metadata: Annotated[dict[str, str] | None, Field(max_length=20)] = None + method: Method | None = "GET" + """ + HTTP method used against `url`. Case-insensitive. POST is + for **safe/idempotent** requests only (GraphQL queries, + search endpoints): tasks are retried on transient failures + and reruns, so the target may see the same POST more than + once. Callers that cannot tolerate a duplicate should + disable reruns. POST rides the standard (non-headless) + scraping path — combining it with `js_render`, + `js_instructions`, or `json_response` is rejected with + 400 `method_param_conflict`. + + """ + body: Any | None = None + """ + Request body, only with `method: POST`. Any JSON value, + 16 KiB max. An object/array/number/boolean is sent as its + JSON encoding with `Content-Type: application/json`; a + string is sent verbatim with + `Content-Type: application/x-www-form-urlencoded`. Set a + different target Content-Type via the `custom_headers` + zenrows param. Never echoed in results listings. + + """ + zenrows_params: dict[str, str | bool | int | dict[str, str]] | None = None + """ + Per-task scraper params. Override the job-level + `zenrows_params` on key collision (task wins). + + """ + + +class Status(Enum): + """ + Initial state. `open` is only allowed for `regular` jobs; + after the initial run, `open` has no meaning so the job + is auto-closed. + + """ + + OPEN = "open" + CLOSED = "closed" + + +class AddTasksRequest(BaseModel): + tasks: Annotated[list[TaskInput], Field(max_length=1000, min_length=1)] + last_batch: bool | None = False + """ + Set true on the final batch. Closes the job (status → + `closed`) and marks the run's `last_batch_received`. + + """ + + +class Spend(BaseModel): + """ + Indicative `{credits, cost}` pair — what was charged for + the scoped work. **Not billing-grade**; your account + statement is authoritative. Use as a "how much did this + cost?" indicator, not for reconciliation. + + """ + + credits: Annotated[int, Field(ge=0)] + cost: Annotated[float, Field(ge=0.0)] + + +class TaskSpend(BaseModel): + """ + Per-task indicative spend with two roll-ups: `total` + accumulates across every attempt (including retries), + `last_attempt` carries just the most recent gateway call. + On a task that succeeded on its first try the two are + equal; on a retried task they diverge. + + """ + + total: Spend + last_attempt: Spend + + +class RunStats(BaseModel): + total: int + """ + Number of tasks in this run. + """ + completed: int + """ + successful + failed. + """ + successful: int + failed: int + failure_reasons: Annotated[ + dict[str, int] | None, Field(examples=[{"blocked": 9, "gateway_error": 4}]) + ] = None + """ + Coarse rollup of terminal failures keyed by a small public + taxonomy. Lets you answer "what kinds of failures did I + get?" without paging every error blob. Best-effort, + indicative — omitted on runs with no failures yet and on + runs that predate the feature. + + Vocabulary (the only keys that appear): + - `auth_failed` — credentials or billing + - `blocked` — anti-bot / policy denials + - `bad_target` — target URL is the problem (bad host, 404, 410, too large) + - `rate_limited` — target throttled the request + - `timeout` — the scrape didn't complete in time + - `gateway_error` — ZenRows-side transport / 5xx + - `other` — anything else + + """ + spend: Spend | None = None + """ + Indicative spend summed across every task attempt in + this run. Absent on runs whose tasks predate this + field (treat as zero). + + """ + + +class PauseState(Enum): + """ + Reversible-suspend flag, orthogonal to `status`. + Omitted from responses when `active` / absent (legacy + rows). Flip via `POST /jobs/{id}/pause` and + `/resume`. + + """ + + ACTIVE = "active" + PAUSED = "paused" + + +class IngestStatus(Enum): + """ + Present only on runs created by a large (202) submission + or a large (202) rerun. `pending` — task rows are still + streaming into storage; reads may return partial pages + and `addTasks` returns `409`. `done` — every accepted + task row is visible. Omitted on runs whose tasks were + written on the request path (201 submissions and + reruns, `addTasks` batches). + + """ + + PENDING = "pending" + DONE = "done" + + +class FailureReason(Enum): + """ + Present only when `status == failed`: the account-level + cause of the auto-fail. `insufficient_credits` (out of + credits) or `subscription_inactive` (subscription not + active). Omitted otherwise. Distinct from + `stats.failure_reasons` (the per-task rollup). + + """ + + INSUFFICIENT_CREDITS = "insufficient_credits" + SUBSCRIPTION_INACTIVE = "subscription_inactive" + + +class Run(BaseModel): + run_id: str + job_id: str + run_sequence: Annotated[int, Field(ge=1)] + status: RunStatus + stats: RunStats + last_batch_received: bool | None = None + """ + Meaningful only for the initial run of an open job. + Once true, `addTasks` is rejected and the run drains + into `completed`. + + """ + pause_state: PauseState | None = None + """ + Reversible-suspend flag, orthogonal to `status`. + Omitted from responses when `active` / absent (legacy + rows). Flip via `POST /jobs/{id}/pause` and + `/resume`. + + """ + ingest_status: IngestStatus | None = None + """ + Present only on runs created by a large (202) submission + or a large (202) rerun. `pending` — task rows are still + streaming into storage; reads may return partial pages + and `addTasks` returns `409`. `done` — every accepted + task row is visible. Omitted on runs whose tasks were + written on the request path (201 submissions and + reruns, `addTasks` batches). + + """ + created_at: AwareDatetime + updated_at: AwareDatetime + failure_reason: FailureReason | None = None + """ + Present only when `status == failed`: the account-level + cause of the auto-fail. `insufficient_credits` (out of + credits) or `subscription_inactive` (subscription not + active). Omitted otherwise. Distinct from + `stats.failure_reasons` (the per-task rollup). + + """ + + +class Unit(Enum): + MINUTE = "minute" + HOUR = "hour" + DAY = "day" + + +class ScheduleRate(BaseModel): + """ + Interval-based fire policy — every N units. + """ + + every: Annotated[int, Field(examples=[15], ge=1)] + unit: Unit + + +class TimesOfDayItem(RootModel[str]): + root: Annotated[str, Field(pattern="^([01][0-9]|2[0-3]):00$")] + + +class Day(Enum): + MON = "mon" + TUE = "tue" + WED = "wed" + THU = "thu" + FRI = "fri" + SAT = "sat" + SUN = "sun" + + +class Weekly(BaseModel): + days: Annotated[list[Day], Field(examples=[["mon", "wed", "fri"]], min_length=1)] + + +class Day1(RootModel[int]): + root: Annotated[int, Field(ge=1, le=31)] + + +class Monthly(BaseModel): + days: Annotated[list[Day1], Field(examples=[[1, 15]], min_length=1)] + + +class ScheduleCadence(BaseModel): + """ + Picks which days the schedule fires on. Exactly one of + `daily`, `weekly`, `monthly` must be set. + + """ + + daily: dict[str, Any] | None = None + """ + Fire every day. No knobs. + """ + weekly: Weekly | None = None + monthly: Monthly | None = None + + +class ListJobRunsResponse(BaseModel): + runs: list[Run] + next_cursor: str | None = None + + +class Method1(Enum): + """ + The task's HTTP method. Omitted for GET (the default). + The request `body` is intentionally not part of listing + responses. + + """ + + GET = "GET" + POST = "POST" + + +class WebhookConfig(BaseModel): + """ + Webhook delivery config for `run.completed` / `run.failed`. Returned on + `GET /v1/jobs/{id}` (inline under `webhook`) and on + `GET /v1/jobs/{id}/webhook`. `PUT` requires both fields — + no defaulting at the mutate boundary (otherwise toggling + `url` would silently disable signing). Submit body accepts + the same shape with `signature` optional (defaults `false`). + + """ + + url: AnyUrl + """ + HTTPS only. Host must resolve via DNS within 1 second + (1+ A/AAAA record). + + """ + signature: bool + """ + Opt-in HMAC signing. When `true`, each delivery is signed + with the org's active HMAC key + (`POST /v1/hmac/keys/rotate`) and carries + `X-Signature: t=,v1=,kid=`. The signed + input is `t + "." + raw_body`, HMAC-SHA256. When `false`, + deliveries carry **no** `X-Signature` header — header + absence is the signal. + + """ + + +class TestWebhookRequest(BaseModel): + """ + Body for `POST /v1/webhook/test`. Same shape as the submit-time + `webhook` field; `signature` is optional and defaults `false`. + + """ + + url: AnyUrl + """ + HTTPS only. Host must resolve via DNS within 1 second. + Identical validation to the submit/PUT webhook URL. + + """ + signature: bool | None = False + """ + When `true`, the test event is signed with the org's + active HMAC key — same `X-Signature: t,v1,kid` header + real deliveries use. Returns `400 + webhook_signing_requires_active_key` when no active key + exists. + + """ + + +class TestWebhookResponse(BaseModel): + """ + Outcome of the synthetic test dispatch. HTTP status is always + `200` when the request was validated — the receiver outcome + is in the body. The synthetic envelope uses `event_type: + "webhook.test"` and stable sentinel IDs (`job_id`/`run_id` = + `"test"`) so receivers can recognise and discard test traffic. + + """ + + delivered: bool + """ + `true` iff the receiver responded with a 2xx status. `false` + on non-2xx, timeout, or transport error. + + """ + event_id: str + """ + ULID of the synthetic event. Different on every call so + receivers' dedup tables don't suppress repeated tests. + + """ + status_code: int | None = None + """ + HTTP status returned by the receiver. Absent on timeout or + transport error (no response was received). + + """ + error: str | None = None + """ + Human-readable reason when `delivered: false`. Same + vocabulary as real webhook deliveries + (`http_4xx:`, `http_5xx:`, `timeout`, + `transport_error:`). Absent on success. + + """ + elapsed_ms: int + """ + Wall-clock duration of the receiver POST in milliseconds. + """ + + +class FileInputColumnRef1(RootModel[str]): + root: Annotated[str, Field(min_length=1)] + """ + Either a column name (string) — requires `csv.header: true` — + or a 0-based column index (integer). Other shapes are + rejected at create-time. + + """ + + +class FileInputColumnRef2(RootModel[int]): + root: Annotated[int, Field(ge=0)] + """ + Either a column name (string) — requires `csv.header: true` — + or a 0-based column index (integer). Other shapes are + rejected at create-time. + + """ + + +class Fields(BaseModel): + """ + Map from canonical task field → CSV column. Only + `url` (required) and `external_id` (optional) are + accepted. Each value is a column index (int) or a + column name (string, requires `header: true`). + + """ + + model_config = ConfigDict( + extra="forbid", + ) + url: FileInputColumnRef1 | FileInputColumnRef2 + """ + Either a column name (string) — requires `csv.header: true` — + or a 0-based column index (integer). Other shapes are + rejected at create-time. + + """ + external_id: FileInputColumnRef1 | FileInputColumnRef2 | None = None + """ + Either a column name (string) — requires `csv.header: true` — + or a 0-based column index (integer). Other shapes are + rejected at create-time. + + """ + + +class Csv(BaseModel): + delimiter: Annotated[str | None, Field(max_length=1, min_length=1)] = "," + """ + Single-character field delimiter. + """ + quote: Annotated[str | None, Field(max_length=1, min_length=1)] = '"' + """ + Single-character quoting character. + """ + header: bool | None = False + """ + When true, the first CSV row is consumed as a header + row and `csv.fields.*` values may be column names. + + """ + fields: Fields + """ + Map from canonical task field → CSV column. Only + `url` (required) and `external_id` (optional) are + accepted. Each value is a column index (int) or a + column name (string, requires `header: true`). + + """ + + +class CreateJobInputRequest(BaseModel): + type: Literal["csv"] + """ + Only `csv` is supported in v1. + """ + csv: Csv | None = None + + +class FileInputUploadTarget(BaseModel): + method: Literal["PUT"] + url: AnyUrl + """ + Presigned PUT URL. Caller MUST send the body with the + exact `Content-Type` shown in `headers` — the signature + binds the content-type. + + """ + headers: Annotated[ + dict[str, str] | None, Field(examples=[{"Content-Type": "text/csv"}]) + ] = None + expires_at: AwareDatetime + """ + PUT URL TTL (~30 min). + """ + + +class CreateJobInputResponse(BaseModel): + file_input_id: str + upload: FileInputUploadTarget + expires_at: AwareDatetime + """ + 24 h slot lifetime — beyond this the slot and its uploaded + body are removed and the `file_input_id` returns 404. + + """ + + +class HMACKeyMeta(BaseModel): + """ + Public view of one HMAC key — id + creation time. Never + includes secret material; that's only returned at /rotate. + + """ + + kid: Annotated[str, Field(pattern="^[0-9A-HJKMNP-TV-Z]{26}$")] + """ + ULID identifying this key. Stable for the life of the slot; a new candidate gets a new kid. + """ + created_at: AwareDatetime + + +class HMACKeyList(BaseModel): + """ + Slots populated at the time of the call. + """ + + active: HMACKeyMeta | None = None + candidate: HMACKeyMeta | None = None + + +class HMACKeyCreated(BaseModel): + """ + Response to `/rotate`. `secret` is base64-encoded raw key + material. **This is the ONLY response that ever contains + the secret value** — capture it now or generate a new one + via another /rotate call. + + """ + + kid: Annotated[str, Field(pattern="^[0-9A-HJKMNP-TV-Z]{26}$")] + secret: str + """ + Base64-encoded 32-byte HMAC key. + """ + created_at: AwareDatetime + + +class HMACKeyFinalized(BaseModel): + """ + Response to `/rotate/finalize`. No secret. + """ + + active_kid: Annotated[str, Field(pattern="^[0-9A-HJKMNP-TV-Z]{26}$")] + created_at: AwareDatetime + + +class Reason(Enum): + MALFORMED_URL = "malformed_url" + UNSUPPORTED_SCHEME = "unsupported_scheme" + MISSING_HOST = "missing_host" + URL_TOO_LONG = "url_too_long" + METADATA_TOO_LARGE = "metadata_too_large" + METADATA_KEY_INVALID = "metadata_key_invalid" + INVALID_EXTERNAL_ID = "invalid_external_id" + UNKNOWN_PARAM = "unknown_param" + INVALID_PARAM_VALUE = "invalid_param_value" + + +class InvalidTask(BaseModel): + index: int + reason: Reason + value: str | None = None + """ + Offending input. For URL / metadata reasons this is + a redacted/truncated form of the bad value. For + `unknown_param` / `invalid_param_value` this is the + param key. + + """ + + +class Problem(BaseModel): + """ + RFC 7807 Problem Details. + + """ + + model_config = ConfigDict( + extra="allow", + ) + type: AnyUrl + title: str + status: int + detail: str | None = None + code: str + instance: str | None = None + invalid_tasks: list[InvalidTask] | None = None + """ + Present on validation errors. + """ + + +class ExportStatus(Enum): + """ + Lifecycle state of a results export. + * `pending` — export accepted, not started yet. + * `running` — the zip is being produced. + * `completed` — `download_url` will be present. + * `failed` — `error` carries the reason. + + """ + + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +class StartExportResponse(BaseModel): + """ + Returned by `startResultsExport`. + """ + + export_id: Annotated[str, Field(pattern="^[0-9A-HJKMNP-TV-Z]{26}$")] + """ + ULID identifying this export. Use it for `getResultsExport`. + """ + status: ExportStatus + created_at: AwareDatetime + expires_at: AwareDatetime + """ + 12 h after `created_at`. Past this point the export and + its download are removed and the export id 404s. + + """ + + +class Export(BaseModel): + """ + Polled view of a results export. `download_url` is presigned + fresh on every successful poll — stash the metadata, but + re-fetch the URL right before you download. + + """ + + export_id: Annotated[str, Field(pattern="^[0-9A-HJKMNP-TV-Z]{26}$")] + status: ExportStatus + error: str | None = None + """ + Non-empty only when `status = failed`. Stable strings — + e.g. `"results are larger then 1 gb"` when the combined + results exceed the 1 GiB cap. + + """ + download_url: AnyUrl | None = None + """ + Presigned download URL for the zipped run results. + Present only when `status = completed`. Short-lived — the + server mints a new one on every poll. + + """ + created_at: AwareDatetime + expires_at: AwareDatetime + """ + 12 h after `created_at`. The download is unavailable + after this point. + + """ + + +class SubmitJobResponse(BaseModel): + job_id: str + status: JobStatus + latest_run: Run | None = None + """ + Absent for `scheduled` jobs that haven't fired yet. + """ + accepted_tasks: int + webhook: WebhookConfig | None = None + """ + Echo of the webhook config persisted on the job (when + one was supplied). Omitted when no webhook was set. + + """ + + +class AddTasksResponse(BaseModel): + accepted_tasks: int + job_status: JobStatus + latest_run: Run + + +class RerunJobResponse(BaseModel): + job_id: str + status: JobStatus + latest_run: Run + rerun_of: str | None = None + """ + `run_id` of the previous run that was replayed. Empty on + the first manual fire of a scheduled job (no prior run). + + """ + retried_tasks: int + """ + Number of rows reset to `pending` and re-enqueued. Equals + `latest_run.stats.total` for a full rerun; equals the + filter-matched count for a `?status=` partial retry. + + """ + inherited_tasks: int + """ + Number of rows copied verbatim from the previous run with + `source_run_id` stamped. Zero for a full rerun; non-zero + only when `?status=` is set. + + """ + + +class ScheduleCalendar(BaseModel): + """ + Calendar-style fire policy. Fires at every `times_of_day` + entry on every day matching the cadence. + + """ + + times_of_day: Annotated[ + list[TimesOfDayItem], Field(examples=[["09:00", "18:00"]], min_length=1) + ] + """ + Wall-clock times on a 24-hour clock, full hours only + (`"09:00"`, `"18:00"`). Minute granularity is rejected + with 400. + + """ + cadence: ScheduleCadence + + +class TaskResult(BaseModel): + task_id: str + external_id: str | None = None + """ + Caller-supplied correlation id from submit/AddTasks. + Omitted when the caller did not supply one. + + """ + run_id: str + url: AnyUrl + metadata: Annotated[dict[str, str] | None, Field(max_length=20)] = None + method: Method1 | None = None + """ + The task's HTTP method. Omitted for GET (the default). + The request `body` is intentionally not part of listing + responses. + + """ + status: TaskStatus + type: ResultType | None = None + result_url: str | None = None + """ + 24-hour presigned download URL for the result body, or a + `/v1/jobs//runs//tasks//content` URL you can + fetch directly. Empty for non-successful tasks. + + """ + error: Problem | None = None + """ + Present on failed tasks. The scraping engine's error + response as Problem JSON, or a synthesised envelope with + `code: "gateway_unreachable"` when it couldn't be reached. + + """ + source_run_id: str | None = None + """ + Set when this row was copied from another run by + `/rerun?status=`. The row is terminal at creation, is + never re-executed, and its `result_url` resolves to the + source run's stored result. On chained retries, + `source_run_id` chases back to the run that actually + owns the result. Empty for normally-executed rows. + + """ + spend: TaskSpend | None = None + + +class ListResultsResponse(BaseModel): + results: list[TaskResult] + next_cursor: str | None = None + + +class TaskHistoryEvent(BaseModel): + started_at: AwareDatetime + ended_at: AwareDatetime + attempt: Annotated[int, Field(ge=1)] + """ + 1-indexed attempt within the run. + """ + error: Problem | None = None + spend: Spend | None = None + """ + Indicative spend charged for this single attempt. Zero + on attempts that didn't reach the scraping engine or + were not charged. + + """ + + +class TaskHistoryResponse(BaseModel): + events: list[TaskHistoryEvent] + + +class JobSchedule(BaseModel): + """ + Structured scheduling block attached to `type: scheduled` + jobs. Exactly one of `at`, `rate`, or `calendar` must be + set. + + """ + + at: Annotated[str | None, Field(examples=["2026-09-01T09:00:00"])] = None + """ + One-shot fire at a specific wall-clock timestamp. + Mutually exclusive with `rate` and `schedule`. + + **Must be tz-naive** — no trailing `Z`, no offset. The + sibling `timezone` field (mandatory) is the single + authoritative interpreter. This keeps DST transitions + deterministic. + + """ + rate: ScheduleRate | None = None + calendar: ScheduleCalendar | None = None + timezone: Annotated[str | None, Field(examples=["Europe/Berlin"])] = None + """ + IANA timezone name (e.g. `Europe/Berlin`, `UTC`). + **Required** when `at` or `calendar` is set; + ignored by `rate` (interval-based, no wall-clock + meaning). Anchoring wall-clock times to a named zone + (rather than a UTC offset baked into the string) keeps + DST transitions deterministic. + + """ + + +class Job(BaseModel): + job_id: str + type: JobType + status: JobStatus + format: Format | None = None + zenrows_params: dict[str, str] | None = None + """ + Stored canonical form — values are always strings even + though submit accepts any JSON scalar (see ScraperParams). + + """ + external_id: str | None = None + """ + Caller-supplied correlation id passed at submit (omitted + when the caller did not supply one). Not server-enforced + unique. + + """ + name: str | None = None + """ + Optional human label passed at submit (omitted when the + caller did not supply one). Free-form, up to 100 chars. + + """ + metadata: Annotated[dict[str, str] | None, Field(max_length=20)] = None + schedule: JobSchedule | None = None + """ + Schedule block — present only for `type: scheduled` + jobs. + + """ + next_scheduled_run: AwareDatetime | None = None + """ + Server-computed timestamp of the next expected fire, + stamped at submit and re-stamped on every fire. `null` + for non-scheduled jobs and for one-shot `at(...)` + schedules that have already fired. Stays computed when + `schedule_state == paused` — "what would fire next if + you resumed." + + """ + schedule_state: ScheduleState | None = None + """ + Set only for `type: scheduled`. Default `active` at + submit; flip via `POST /v1/jobs/{id}/schedule/state`. + + """ + webhook: WebhookConfig | None = None + """ + Webhook delivery config. Present iff a + webhook is configured. Mutable via `PUT/DELETE + /v1/jobs/{id}/webhook`; `signature` never appears + alone — the whole `webhook` key is omitted when no + URL is set. + + """ + latest_run: Run | None = None + """ + Snapshot projection of the latest run. Absent for + `scheduled` jobs that haven't fired yet. + + """ + created_at: AwareDatetime + updated_at: AwareDatetime + + +class ListJobsResponse(BaseModel): + jobs: list[Job] + next_cursor: str | None = None + + +class SubmitJobRequest(BaseModel): + type: JobType | None = "regular" + status: Status | None = "closed" + """ + Initial state. `open` is only allowed for `regular` jobs; + after the initial run, `open` has no meaning so the job + is auto-closed. + + """ + zenrows_params: dict[str, str | bool | int | dict[str, str]] | None = None + """ + Job-level scraper params, applied to every task of every + run of the job. Each task can override individual keys + via its own `zenrows_params` (task wins on collision). + + """ + schedule: JobSchedule | None = None + """ + Schedule block for `type: scheduled`. Required there, + ignored otherwise. + + """ + tasks: Annotated[list[TaskInput] | None, Field(max_length=1000, min_length=0)] = ( + None + ) + """ + Required for closed jobs (1–1000) unless `file_input_id` + is provided. Optional for open jobs — may be empty if the + caller will follow up with `addTasks`. Mutually exclusive + with `file_input_id`. + + """ + file_input_id: str | None = None + """ + Reference to a previously-uploaded CSV input (see + `POST /v1/job_inputs`). Mutually exclusive with `tasks`. + Eligible only for regular-closed and scheduled job types. + The uploaded CSV is parsed under the saved spec; its rows + become tasks (regular-closed) or template_tasks + (scheduled). + + """ + external_id: Annotated[ + str | None, Field(max_length=128, pattern="^[A-Za-z0-9._-]+$") + ] = None + """ + Optional caller-supplied correlation id for the job — + same semantics as `task.external_id`. Shape-checked, + **not** required to be unique. Surfaced verbatim in + `getJob` / `listJobs` responses. + + """ + name: Annotated[str | None, Field(max_length=100)] = None + """ + Optional human-readable label for the job. Free-form — + no shape rules. Up to 100 characters. Surfaced verbatim + in `getJob` / `listJobs` responses. No uniqueness, no + indexing; cannot be changed after submit. + + """ + metadata: Annotated[dict[str, str] | None, Field(max_length=20)] = None + webhook: WebhookConfig | None = None + """ + Optional `run.completed` / `run.failed` delivery config. + A terminal run fires `run.completed` on a natural finish, or + `run.failed` (with `failure_reason` + partial stats) when the + run auto-fails on an account-level error. `signature` + defaults to `false` here so first-time integrations + don't need an HMAC key. Config is mutable post-submit + via `PUT /v1/jobs/{id}/webhook` and `DELETE + /v1/jobs/{id}/webhook`; the current config is surfaced + on `GET /v1/jobs/{id}`. + + """ diff --git a/src/zenrows/client.py b/src/zenrows/client.py new file mode 100644 index 0000000..e9db1b8 --- /dev/null +++ b/src/zenrows/client.py @@ -0,0 +1,319 @@ +"""Synchronous Zenrows scraper client (the original SDK surface). + +Backward-compatible with the pre-1.4 API: the constructor still takes +`(apikey, retries, concurrency)` positionally, and `get`/`post`/`put` +return a `requests.Response`. What's new in this freshen-up: + + - Modern type hints (3.10+ unions, no Optional/Dict noise) + - Context-manager support that closes the underlying requests + session + the thread-pool executor + +For the async (job-based) Batch API, see `zenrows.ZenRowsBatchClient`. +""" + +import asyncio +import os +from concurrent.futures import ThreadPoolExecutor +from functools import partial +from typing import Any + +import requests +import urllib3 +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from zenrows.__version__ import __version__ + +DEFAULT_SCRAPER_URL = "https://api.zenrows.com/v1/" +DEFAULT_USER_AGENT = f"zenrows/{__version__} python" + +# Status codes the retry layer treats as transient. The set was inherited +# from the original SDK and matches the gateway's documented retry guidance. +_RETRY_STATUSES = [422, 429, 500, 502, 503, 504] + + +def _is_auth010(response: requests.Response) -> bool: + """True when a response's JSON error envelope carries the Extract + domain-not-enabled code (AUTH010).""" + try: + body = response.json() + except ValueError: + return False + code = body.get("code") if isinstance(body, dict) else None + return isinstance(code, str) and code.upper() == "AUTH010" + + +class ZenRowsClient: + """Synchronous client for the Zenrows scraping API. + + Example: + + client = ZenRowsClient("zr_...") + resp = client.get("https://example.com", params={"js_render": "true"}) + print(resp.text) + + `base_url` defaults to the public production endpoint; override via + constructor arg or `ZENROWS_SCRAPER_BASE_URL` env var. + """ + + # Kept as a class attribute for compatibility — pre-1.4 callers + # could read `ZenRowsClient.api_url` directly. + api_url = DEFAULT_SCRAPER_URL + + def __init__( + self, + apikey: str, + retries: int = 0, + concurrency: int = 5, + *, + base_url: str | None = None, + ): + if not apikey: + raise ValueError("ZenRowsClient: apikey is required.") + self.apikey = apikey + self.api_url = base_url or os.environ.get("ZENROWS_SCRAPER_BASE_URL") or DEFAULT_SCRAPER_URL + + self.executor = ThreadPoolExecutor(max_workers=concurrency) + self.requests_session = requests.Session() + if retries > 0: + max_retries = Retry( + total=retries, + backoff_factor=0.5, + status_forcelist=_RETRY_STATUSES, + raise_on_status=False, + ) + adapter = HTTPAdapter(max_retries=max_retries) + self.requests_session.mount("https://", adapter) + self.requests_session.mount("http://", adapter) + + # ---- sync HTTP verbs ---- + + def fetch( + self, + url: str, + params: dict | None = None, + headers: dict | None = None, + **kwargs: Any, + ) -> requests.Response: + """Fetch a URL through Zenrows — the main page-scraping product. This is + the primary entry point; `get()` remains as a deprecated alias. + """ + return self._worker("GET", url, params, headers, **kwargs) + + def get( + self, + url: str, + params: dict | None = None, + headers: dict | None = None, + **kwargs: Any, + ) -> requests.Response: + """Deprecated: use `fetch()` instead. Kept for backward compatibility.""" + return self.fetch(url, params, headers, **kwargs) + + def extract( + self, + url: str, + params: dict | None = None, + headers: dict | None = None, + mode: str = "auto", + fallback_to_autoparse: bool = True, + adaptive_stealth: bool = True, + **kwargs: Any, + ) -> requests.Response: + """Fetch a URL and run it through Extract — Zenrows' AI-powered structured + extraction (beta). `mode` is one of "auto" (default), "native", + or "standard". Thin wrapper over `fetch()` with the `extract` param set — + no separate endpoint or auth. + + `mode="auto"` is a domain-gated open beta: when the target domain isn't + enabled yet, the API returns a 402 with `code: "AUTH010"`. By default + this retries once with `autoparse=True` instead of returning the error + response — pass `fallback_to_autoparse=False` to disable that and get + the raw AUTH010 response back. + + `adaptive_stealth=True` (default) also sends Adaptive Stealth Mode + (`mode: "auto"` at the wire-param level - unrelated to this method's own + `mode` argument) on both the extract attempt and the Autoparse fallback, + so a target needing `js_render`/`premium_proxy` gets escalated + automatically instead of failing with REQS002. Pass + `adaptive_stealth=False` to disable that and set `js_render`/ + `premium_proxy` yourself. + """ + final_params = dict(params) if params else {} + final_params["extract"] = mode + if adaptive_stealth: + final_params["mode"] = "auto" + response = self.fetch(url, final_params, headers, **kwargs) + + if ( + response.status_code == 402 + and mode == "auto" + and fallback_to_autoparse + and _is_auth010(response) + ): + autoparse_params = dict(params) if params else {} + autoparse_params["autoparse"] = True + if adaptive_stealth: + autoparse_params["mode"] = "auto" + return self.fetch(url, autoparse_params, headers, **kwargs) + + return response + + def post( + self, + url: str, + params: dict | None = None, + headers: dict | None = None, + data: Any = None, + **kwargs: Any, + ) -> requests.Response: + return self._worker("POST", url, params, headers, data=data, **kwargs) + + def put( + self, + url: str, + params: dict | None = None, + headers: dict | None = None, + data: Any = None, + **kwargs: Any, + ) -> requests.Response: + return self._worker("PUT", url, params, headers, data=data, **kwargs) + + # ---- async-flavoured wrappers (thread-pool offload) ---- + + async def fetch_async( + self, + url: str, + params: dict | None = None, + headers: dict | None = None, + **kwargs: Any, + ) -> requests.Response: + return await self._offload("GET", url, params, headers, **kwargs) + + async def get_async( + self, + url: str, + params: dict | None = None, + headers: dict | None = None, + **kwargs: Any, + ) -> requests.Response: + """Deprecated: use `fetch_async()` instead. Kept for backward compatibility.""" + return await self.fetch_async(url, params, headers, **kwargs) + + async def extract_async( + self, + url: str, + params: dict | None = None, + headers: dict | None = None, + mode: str = "auto", + fallback_to_autoparse: bool = True, + adaptive_stealth: bool = True, + **kwargs: Any, + ) -> requests.Response: + """Async counterpart of `extract()` - see its docstring for the + AUTH010 -> Autoparse fallback behavior, `fallback_to_autoparse`, and + `adaptive_stealth`.""" + final_params = dict(params) if params else {} + final_params["extract"] = mode + if adaptive_stealth: + final_params["mode"] = "auto" + response = await self.fetch_async(url, final_params, headers, **kwargs) + + if ( + response.status_code == 402 + and mode == "auto" + and fallback_to_autoparse + and _is_auth010(response) + ): + autoparse_params = dict(params) if params else {} + autoparse_params["autoparse"] = True + if adaptive_stealth: + autoparse_params["mode"] = "auto" + return await self.fetch_async(url, autoparse_params, headers, **kwargs) + + return response + + async def post_async( + self, + url: str, + params: dict | None = None, + headers: dict | None = None, + data: Any = None, + **kwargs: Any, + ) -> requests.Response: + return await self._offload("POST", url, params, headers, data=data, **kwargs) + + async def put_async( + self, + url: str, + params: dict | None = None, + headers: dict | None = None, + data: Any = None, + **kwargs: Any, + ) -> requests.Response: + return await self._offload("PUT", url, params, headers, data=data, **kwargs) + + # ---- lifecycle ---- + + def close(self) -> None: + """Release the requests session + drain the thread-pool.""" + self.requests_session.close() + self.executor.shutdown(wait=True) + + def __enter__(self) -> "ZenRowsClient": + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + # ---- internal ---- + + async def _offload( + self, + method: str, + url: str, + params: dict | None, + headers: dict | None, + **kwargs: Any, + ) -> requests.Response: + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + self.executor, + partial(self._worker, method, url, params, headers, **kwargs), + ) + + def _worker( + self, + method: str, + url: str, + params: dict | None = None, + headers: dict | None = None, + data: Any = None, + **kwargs: Any, + ) -> requests.Response: + final_params: dict = {} + if params: + final_params.update(params) + final_params["url"] = url + final_params["apikey"] = self.apikey + + final_headers: dict = {"User-Agent": DEFAULT_USER_AGENT} + + if headers: + # Caller wants their own headers forwarded to the target — + # opt into the gateway's `custom_headers` mode and clear + # the requests defaults that would otherwise stomp them. + final_params["custom_headers"] = True + final_headers["Accept"] = None + final_headers["Accept-Encoding"] = urllib3.util.SKIP_HEADER + final_headers["Connection"] = None + final_headers.update(headers) + + return self.requests_session.request( + method, + self.api_url, + params=final_params, + headers=final_headers, + data=data, + **kwargs, + ) diff --git a/tests/test_batch_client.py b/tests/test_batch_client.py new file mode 100644 index 0000000..04c3fb3 --- /dev/null +++ b/tests/test_batch_client.py @@ -0,0 +1,1093 @@ +"""Smoke tests for ZenRowsBatchClient. + +Covers the wire-level contract: auth header, base-URL override, RFC +7807 error mapping, and a round-trip through `submit_job` + the +auto-paginating `iter_results`. We use respx to mock httpx so the +tests stay offline-deterministic. +""" + +import io +import json + +import httpx +import pytest +import respx +from httpx import Response + +from zenrows import ZenRowsBatchClient +from zenrows.batch import BatchAPIError, IngestStatus, JobStatus, JobType, TaskResult, WaiterTimeout + +BASE_URL = "http://localhost:9000/v1" +API_KEY = "test-key" + + +@pytest.fixture +def client() -> ZenRowsBatchClient: + """A client pointed at a fake local dev URL. Demonstrates the + base_url override path — same code can hit prod by dropping it.""" + return ZenRowsBatchClient(api_key=API_KEY, base_url=BASE_URL) + + +@respx.mock +def test_submit_job_sends_api_key_and_returns_typed_response(client: ZenRowsBatchClient): + """Auth header is set, body is encoded, response parses into a typed model.""" + route = respx.post(f"{BASE_URL}/jobs").mock( + return_value=Response( + 201, + json={ + "job_id": "01J0000000000000000000000", + "status": "closed", + "accepted_tasks": 1, + }, + ) + ) + + resp = client.submit_job( + { + "type": "regular", + "status": "closed", + "tasks": [{"url": "https://example.com/a"}], + } + ) + + assert resp.job_id == "01J0000000000000000000000" + assert resp.status == JobStatus.CLOSED + assert resp.accepted_tasks == 1 + + sent = route.calls.last.request + assert sent.headers["X-API-Key"] == API_KEY + assert sent.headers["Content-Type"] == "application/json" + assert json.loads(sent.content) == { + "type": "regular", + "status": "closed", + "tasks": [{"url": "https://example.com/a"}], + } + + +@respx.mock +def test_problem_response_raises_batch_api_error(client: ZenRowsBatchClient): + """RFC 7807 problem bodies surface as BatchAPIError with a stable code.""" + respx.get(f"{BASE_URL}/jobs/missing").mock( + return_value=Response( + 404, + headers={"Content-Type": "application/problem+json"}, + json={ + "type": "about:blank", + "title": "Not found", + "status": 404, + "code": "not_found", + "detail": "Job not found.", + }, + ) + ) + + with pytest.raises(BatchAPIError) as exc_info: + client.get_job("missing") + assert exc_info.value.code == "not_found" + assert exc_info.value.status_code == 404 + assert exc_info.value.problem is not None + assert exc_info.value.problem.title == "Not found" + + +@respx.mock +def test_iter_results_auto_paginates(client: ZenRowsBatchClient): + """Auto-pagination uses next_cursor; yields TaskResult instances.""" + page1 = { + "results": [ + { + "task_id": "01T000000000000000000A", + "run_id": "01R000000000000000000A", + "url": "https://example.com/a", + "status": "successful", + } + ], + "next_cursor": "abc", + } + page2 = { + "results": [ + { + "task_id": "01T000000000000000000B", + "run_id": "01R000000000000000000A", + "url": "https://example.com/b", + "status": "successful", + } + ], + "next_cursor": None, + } + + def _handler(request): + cursor = request.url.params.get("cursor") + return Response(200, json=page2 if cursor == "abc" else page1) + + respx.get(f"{BASE_URL}/jobs/J/results").mock(side_effect=_handler) + + rows = list(client.iter_results("J")) + assert [r.task_id for r in rows] == [ + "01T000000000000000000A", + "01T000000000000000000B", + ] + + +def test_missing_api_key_raises(): + """Constructor refuses to start without auth — no surprise 401s later.""" + with pytest.raises(ValueError, match="api_key is required"): + ZenRowsBatchClient(api_key=None) + + +def test_base_url_override_takes_precedence_over_env(monkeypatch): + """Explicit base_url wins over the env var which wins over the default.""" + monkeypatch.setenv("ZENROWS_BATCH_BASE_URL", "http://env-set/v1") + c = ZenRowsBatchClient(api_key=API_KEY, base_url="http://kwarg/v1") + assert c.base_url.rstrip("/") == "http://kwarg/v1" + + c2 = ZenRowsBatchClient(api_key=API_KEY) + assert c2.base_url.rstrip("/") == "http://env-set/v1" + + +@respx.mock +def test_enum_filter_serialises_to_string(client: ZenRowsBatchClient): + """`type=JobType.REGULAR` flattens to a `type=regular` query param.""" + route = respx.get(f"{BASE_URL}/jobs").mock( + return_value=Response(200, json={"jobs": [], "next_cursor": None}) + ) + + client.list_jobs(job_type=JobType.REGULAR, status=JobStatus.OPEN) + + params = route.calls.last.request.url.params + assert params.get("type") == "regular" + assert params.get("status") == "open" + + +# ----- type-specific submit shortcuts ----- + + +@respx.mock +def test_submit_regular_accepts_bare_url_strings(client: ZenRowsBatchClient): + """`submit_regular(["url1", "url2"])` builds the same wire body as + the dict form — without the caller having to spell out type/status.""" + route = respx.post(f"{BASE_URL}/jobs").mock( + return_value=Response( + 201, + json={"job_id": "J", "status": "closed", "accepted_tasks": 2}, + ) + ) + + client.submit_regular( + ["https://example.com/a", "https://example.com/b"], + zenrows_params={"js_render": "true"}, + ) + + body = json.loads(route.calls.last.request.content) + assert body == { + "type": "regular", + "status": "closed", + "zenrows_params": {"js_render": "true"}, + "tasks": [ + {"url": "https://example.com/a"}, + {"url": "https://example.com/b"}, + ], + } + + +@respx.mock +def test_submit_regular_accepts_inline_dicts_with_external_id(client: ZenRowsBatchClient): + """Inline dict form lets each URL carry its own external_id.""" + route = respx.post(f"{BASE_URL}/jobs").mock( + return_value=Response( + 201, + json={"job_id": "J", "status": "closed", "accepted_tasks": 1}, + ) + ) + + client.submit_regular( + [{"url": "https://example.com/a", "external_id": "order-1"}], + ) + + body = json.loads(route.calls.last.request.content) + assert body["tasks"] == [{"url": "https://example.com/a", "external_id": "order-1"}] + + +def test_submit_regular_rejects_urls_and_file_input_together(client: ZenRowsBatchClient): + """Local validation fires before the round-trip.""" + with pytest.raises(ValueError, match=r"urls.*file_input_id"): + client.submit_regular(["https://a"], file_input_id="FI") + + +@respx.mock +def test_submit_open_omits_tasks_for_streaming_jobs(client: ZenRowsBatchClient): + """`submit_open()` with no args creates an empty open job.""" + route = respx.post(f"{BASE_URL}/jobs").mock( + return_value=Response( + 201, + json={"job_id": "J", "status": "open", "accepted_tasks": 0}, + ) + ) + + client.submit_open() + + body = json.loads(route.calls.last.request.content) + # No `tasks` key at all when no urls supplied. + assert body == {"type": "regular", "status": "open"} + + +@respx.mock +def test_submit_scheduled_rate(client: ZenRowsBatchClient): + """rate-shape schedule round-trips on the wire.""" + from zenrows.batch import Rate + + route = respx.post(f"{BASE_URL}/jobs").mock( + return_value=Response( + 201, + json={"job_id": "J", "status": "closed", "accepted_tasks": 1}, + ) + ) + + client.submit_scheduled( + Rate(every=15, unit="minute"), + ["https://example.com/poll"], + ) + + body = json.loads(route.calls.last.request.content) + assert body["type"] == "scheduled" + assert body["schedule"] == {"rate": {"every": 15, "unit": "minute"}} + + +# ----- filename clash handling ----- + + +def test_name_allocator_appends_suffix_on_clash(): + """First claimer gets the bare name; subsequent claimers get + `_01`, `_02`, … suffixed before the extension.""" + from zenrows.batch._download import _NameAllocator + + a = _NameAllocator() + assert a.claim("order-1.html") == "order-1.html" + assert a.claim("order-1.html") == "order-1_01.html" + assert a.claim("order-1.html") == "order-1_02.html" + # Different basename — no collision. + assert a.claim("order-2.html") == "order-2.html" + # No extension — suffix still goes on the end. + assert a.claim("README") == "README" + assert a.claim("README") == "README_01" + + +@respx.mock +def test_submit_regular_passes_job_external_id_and_metadata(client: ZenRowsBatchClient): + """Job-level external_id + metadata round-trip on the wire.""" + route = respx.post(f"{BASE_URL}/jobs").mock( + return_value=Response( + 201, + json={"job_id": "J", "status": "closed", "accepted_tasks": 1}, + ) + ) + + client.submit_regular( + ["https://example.com/a"], + external_id="quarterly-crawl-42", + metadata={"owner": "growth-team", "ticket": "GROW-1234"}, + ) + + body = json.loads(route.calls.last.request.content) + assert body["external_id"] == "quarterly-crawl-42" + assert body["metadata"] == {"owner": "growth-team", "ticket": "GROW-1234"} + + +@respx.mock +def test_submit_scheduled_calendar_with_timezone(client: ZenRowsBatchClient): + """Calendar builder round-trips to the expected wire shape.""" + from zenrows.batch import Calendar, Weekly + + route = respx.post(f"{BASE_URL}/jobs").mock( + return_value=Response( + 201, + json={"job_id": "J", "status": "closed", "accepted_tasks": 1}, + ) + ) + + client.submit_scheduled( + Calendar( + times_of_day=["09:00", "18:00"], + cadence=Weekly(days=["mon", "wed", "fri"]), + timezone="Europe/Berlin", + ), + ["https://example.com/daily"], + ) + + body = json.loads(route.calls.last.request.content) + assert body["schedule"] == { + "calendar": { + "times_of_day": ["09:00", "18:00"], + "cadence": {"weekly": {"days": ["mon", "wed", "fri"]}}, + }, + "timezone": "Europe/Berlin", + } + + +@respx.mock +def test_submit_scheduled_at_accepts_naive_datetime(client: ZenRowsBatchClient): + """`At(datetime, ...)` serializes to the ISO string wire form.""" + from datetime import datetime + + from zenrows.batch import At + + route = respx.post(f"{BASE_URL}/jobs").mock( + return_value=Response( + 201, + json={"job_id": "J", "status": "closed", "accepted_tasks": 1}, + ) + ) + + client.submit_scheduled( + At(datetime(2026, 9, 1, 9, 0), timezone="Europe/Berlin"), + ["https://example.com/once"], + ) + + body = json.loads(route.calls.last.request.content) + assert body["schedule"] == { + "at": "2026-09-01T09:00:00", + "timezone": "Europe/Berlin", + } + + +def test_at_rejects_aware_datetime(): + """`At(...)` validates in `__post_init__` — aware datetime fails fast.""" + from datetime import datetime, timezone + + from zenrows.batch import At + + with pytest.raises(ValueError, match="naive datetime"): + At(datetime(2026, 9, 1, 9, 0, tzinfo=timezone.utc), timezone="Europe/Berlin") + + +def test_at_rejects_offset_string(): + """`At(str-with-offset, ...)` rejects in `__post_init__`.""" + from zenrows.batch import At + + with pytest.raises(ValueError, match="tz-naive"): + At("2026-09-01T09:00:00+02:00", timezone="Europe/Berlin") + + +def test_at_rejects_z_suffix(): + """`At('...Z', ...)` rejects in `__post_init__`.""" + from zenrows.batch import At + + with pytest.raises(ValueError, match="tz-naive"): + At("2026-09-01T09:00:00Z", timezone="UTC") + + +def test_at_requires_timezone(): + from zenrows.batch import At + + with pytest.raises(ValueError, match="timezone is required"): + At("2026-09-01T09:00:00", timezone="") + + +def test_at_rejects_bad_timezone(): + from zenrows.batch import At + + with pytest.raises(ValueError, match="not a valid IANA"): + At("2026-09-01T09:00:00", timezone="Mars/Olympus") + + +def test_rate_validates(): + from zenrows.batch import Rate + + with pytest.raises(ValueError, match=">= 1"): + Rate(every=0, unit="minute") + with pytest.raises(ValueError, match="must be one of"): + Rate(every=5, unit="week") # type: ignore[arg-type] + + +def test_calendar_validates(): + from zenrows.batch import Calendar, Daily, Monthly, Weekly + + # Half-hour rejected. + with pytest.raises(ValueError, match="on the hour"): + Calendar( + times_of_day=["09:30"], + cadence=Daily(), + timezone="UTC", + ) + + # Bad weekday name. + with pytest.raises(ValueError, match="valid day"): + Calendar( + times_of_day=["09:00"], + cadence=Weekly(days=["funday"]), + timezone="UTC", + ) + + # Monthly day out of range. + with pytest.raises(ValueError, match="out of range"): + Calendar( + times_of_day=["09:00"], + cadence=Monthly(days=[32]), + timezone="UTC", + ) + + # Timezone required. + with pytest.raises(ValueError, match="timezone is required"): + Calendar( + times_of_day=["09:00"], + cadence=Daily(), + timezone="", + ) + + +# ----- retry failed (partial rerun) ----- + + +def _rerun_response(run_id: str, *, total: int, successful: int) -> dict: + return { + "job_id": "J", + "status": "closed", + "latest_run": { + "run_id": run_id, + "job_id": "J", + "run_sequence": 2, + "status": "running", + "stats": { + "total": total, + "completed": successful, + "successful": successful, + "failed": 0, + }, + "created_at": "2026-06-05T00:00:00Z", + "updated_at": "2026-06-05T00:00:00Z", + }, + "rerun_of": "R1", + "retried_tasks": total - successful, + "inherited_tasks": successful, + } + + +@respx.mock +def test_retry_failed_sends_status_failed(client: ZenRowsBatchClient): + route = respx.post(f"{BASE_URL}/jobs/J/rerun").mock( + return_value=Response(201, json=_rerun_response("R2", total=100, successful=90)) + ) + + # Act on a ref without a GET round-trip. + run = client.job("J").retry_failed() + + assert run.run_id == "R2" + assert dict(route.calls.last.request.url.params) == {"status": "failed"} + + +@respx.mock +def test_retry_failed_include_pending_sends_both(client: ZenRowsBatchClient): + route = respx.post(f"{BASE_URL}/jobs/J/rerun").mock( + return_value=Response(201, json=_rerun_response("R2", total=100, successful=80)) + ) + + client.job("J").retry_failed(include_pending=True, idempotency_key="k1") + + req = route.calls.last.request + assert dict(req.url.params) == {"status": "failed,pending"} + assert req.headers["Idempotency-Key"] == "k1" + + +# ----- download all results (export-based zip) ----- + + +@respx.mock +def test_download_all_results_starts_polls_and_streams(client: ZenRowsBatchClient, tmp_path): + exports = f"{BASE_URL}/jobs/J/runs/R/exports" + download = "https://s3.example.test/exports/E.zip?sig=abc" + + respx.post(exports).mock( + return_value=Response( + 202, + json={ + "export_id": "01J000000000000000000000EX", + "status": "pending", + "created_at": "2026-06-05T00:00:00Z", + "expires_at": "2026-06-05T12:00:00Z", + }, + ) + ) + respx.get(f"{exports}/01J000000000000000000000EX").mock( + return_value=Response( + 200, + json={ + "export_id": "01J000000000000000000000EX", + "status": "completed", + "error": None, + "download_url": download, + "created_at": "2026-06-05T00:00:00Z", + "expires_at": "2026-06-05T12:00:00Z", + }, + ) + ) + respx.get(download).mock(return_value=Response(200, content=b"PK\x03\x04 zip-bytes")) + + out = client.download_all_results("J", "R", tmp_path / "results.zip") + + assert out.read_bytes() == b"PK\x03\x04 zip-bytes" + + +# ----- scheduled-job management ----- + + +def _scheduled_job(schedule_state: str) -> dict: + return { + "job_id": "J", + "type": "scheduled", + "status": "closed", + "schedule_state": schedule_state, + "created_at": "2026-06-05T00:00:00Z", + "updated_at": "2026-06-05T00:00:00Z", + } + + +def _run_json( + *, run_id: str = "R", status: str = "running", pause_state: str | None = None +) -> dict: + run = { + "run_id": run_id, + "job_id": "J", + "run_sequence": 1, + "status": status, + "stats": {"total": 10, "completed": 3, "successful": 3, "failed": 0}, + "created_at": "2026-07-07T00:00:00Z", + "updated_at": "2026-07-07T00:00:00Z", + } + if pause_state: + run["pause_state"] = pause_state + return run + + +@respx.mock +def test_run_pause_and_resume_suspend_current_run(client: ZenRowsBatchClient): + """`job.run.pause()` / `.resume()` hit the run-level endpoints + (distinct from `job.schedule.*`) and return a fresh RunHandle.""" + pause = respx.post(f"{BASE_URL}/jobs/J/pause").mock( + return_value=Response(200, json=_run_json(pause_state="paused")) + ) + resume = respx.post(f"{BASE_URL}/jobs/J/resume").mock( + return_value=Response(200, json=_run_json(pause_state="active")) + ) + + job = client.job("J") + paused = job.run.pause() + assert pause.call_count == 1 + assert paused.run_id == "R" + assert paused.data.pause_state.value == "paused" + + resumed = job.run.resume() + assert resume.call_count == 1 + assert resumed.data.pause_state.value == "active" + + +@respx.mock +def test_run_stop_posts_stop_and_returns_run_handle(client: ZenRowsBatchClient): + """`job.run.stop()` (and its `cancel()` alias) POST /stop and echo + the refreshed run.""" + route = respx.post(f"{BASE_URL}/jobs/J/stop").mock( + return_value=Response(200, json=_run_json(status="stopped")) + ) + + stopped = client.job("J").run.stop() + assert route.call_count == 1 + assert stopped.run_id == "R" + assert stopped.data.status.value == "stopped" + + +@respx.mock +def test_pause_and_resume_post_schedule_state(client: ZenRowsBatchClient): + route = respx.post(f"{BASE_URL}/jobs/J/schedule/state").mock( + side_effect=[ + Response(200, json=_scheduled_job("paused")), + Response(200, json=_scheduled_job("active")), + ] + ) + + job = client.job("J") + paused = job.schedule.pause() + assert json.loads(route.calls[0].request.content) == {"schedule_state": "paused"} + assert paused.data.schedule_state.value == "paused" + + resumed = job.schedule.resume() + assert json.loads(route.calls[1].request.content) == {"schedule_state": "active"} + assert resumed.data.schedule_state.value == "active" + + +@respx.mock +def test_update_schedule_puts_resolved_body(client: ZenRowsBatchClient): + from zenrows.batch import Rate + + route = respx.put(f"{BASE_URL}/jobs/J/schedule").mock( + return_value=Response(200, json=_scheduled_job("active")) + ) + + client.job("J").schedule.update(Rate(every=15, unit="minute")) + + assert json.loads(route.calls.last.request.content) == {"rate": {"every": 15, "unit": "minute"}} + + +@respx.mock +def test_submit_regular_sends_webhook_and_name(client: ZenRowsBatchClient): + route = respx.post(f"{BASE_URL}/jobs").mock( + return_value=Response(201, json={"job_id": "J", "status": "closed", "accepted_tasks": 1}) + ) + + client.submit_regular( + ["https://example.com/a"], + name="nightly-prices", + webhook={"url": "https://hooks.example.com/zr", "signature": True}, + ) + + body = json.loads(route.calls.last.request.content) + assert body["name"] == "nightly-prices" + assert body["webhook"] == {"url": "https://hooks.example.com/zr", "signature": True} + + +# ----- wait-for-ingest (async 202 submits, COR-358) ----- + + +def _ingest_run_json(ingest_status: str | None, *, status: str = "running") -> dict: + run = { + "run_id": "R", + "job_id": "J", + "run_sequence": 1, + "status": status, + "stats": {"total": 10000, "completed": 0, "successful": 0, "failed": 0}, + "created_at": "2026-07-07T00:00:00Z", + "updated_at": "2026-07-07T00:00:00Z", + } + if ingest_status: + run["ingest_status"] = ingest_status + return run + + +def _ingest_job_json(ingest_status: str | None) -> dict: + return { + "job_id": "J", + "type": "regular", + "status": "closed", + "created_at": "2026-07-07T00:00:00Z", + "updated_at": "2026-07-07T00:00:00Z", + "latest_run": _ingest_run_json(ingest_status), + } + + +@respx.mock +def test_submit_wait_for_ingest_polls_202_until_done(client: ZenRowsBatchClient): + """A 202 submit body carries `ingest_status: pending`; the flag + polls GET /jobs/{id} until it leaves `pending`.""" + respx.post(f"{BASE_URL}/jobs").mock( + return_value=Response( + 202, + json={ + "job_id": "J", + "status": "closed", + "accepted_tasks": 10000, + "latest_run": _ingest_run_json("pending"), + }, + ) + ) + get_route = respx.get(f"{BASE_URL}/jobs/J").mock( + return_value=Response(200, json=_ingest_job_json("done")) + ) + + job = client.submit_regular(["https://example.com/a"], wait_for_ingest=True) + + assert get_route.call_count == 1 + assert job.data.latest_run.ingest_status is IngestStatus.DONE + + +@respx.mock +def test_submit_wait_for_ingest_201_skips_polling(client: ZenRowsBatchClient): + """Sync (201) submits never carry `ingest_status` — the flag must + not cost a follow-up GET.""" + respx.post(f"{BASE_URL}/jobs").mock( + return_value=Response( + 201, + json={ + "job_id": "J", + "status": "closed", + "accepted_tasks": 1, + "latest_run": _ingest_run_json(None), + }, + ) + ) + get_route = respx.get(f"{BASE_URL}/jobs/J").mock( + return_value=Response(200, json=_ingest_job_json(None)) + ) + + client.submit_regular(["https://example.com/a"], wait_for_ingest=True) + + assert get_route.call_count == 0 + + +@respx.mock +def test_wait_for_ingest_standalone_polls_until_done(client: ZenRowsBatchClient): + """`JobRef.wait_for_ingest()` works on any ref — the path a caller + uses when acting on a known id. Returns a fresh loaded handle.""" + get_route = respx.get(f"{BASE_URL}/jobs/J").mock( + side_effect=[ + Response(200, json=_ingest_job_json("pending")), + Response(200, json=_ingest_job_json("pending")), + Response(200, json=_ingest_job_json("done")), + ] + ) + + out = client.job("J").wait_for_ingest(timeout=5.0, poll_interval=0.01) + + assert get_route.call_count == 3 + assert out.data.latest_run.ingest_status is IngestStatus.DONE + + +@respx.mock +def test_wait_for_ingest_timeout_raises(client: ZenRowsBatchClient): + respx.get(f"{BASE_URL}/jobs/J").mock( + return_value=Response(200, json=_ingest_job_json("pending")) + ) + + with pytest.raises(WaiterTimeout): + client.job("J").wait_for_ingest(timeout=0.05, poll_interval=0.01) + + +@respx.mock +def test_job_handle_is_get_free(client: ZenRowsBatchClient): + """`client.job(id)` mints a handle with no network call; acting on it + (delete) hits only the operation endpoint — no wasted GET.""" + get_route = respx.get(f"{BASE_URL}/jobs/J").mock(return_value=Response(200, json={})) + del_route = respx.delete(f"{BASE_URL}/jobs/J").mock(return_value=Response(202, json={})) + + client.job("J").delete() + + assert del_route.call_count == 1 + assert get_route.call_count == 0 # no GET just to delete + + +@respx.mock +def test_run_handle_is_get_free(client: ZenRowsBatchClient): + """`client.run(job, run)` — same GET-free contract, scoped to one run.""" + get_route = respx.get(f"{BASE_URL}/jobs/J/runs/R").mock(return_value=Response(200, json={})) + del_route = respx.delete(f"{BASE_URL}/jobs/J/runs/R").mock(return_value=Response(202, json={})) + + client.run("J", "R").delete() + + assert del_route.call_count == 1 + assert get_route.call_count == 0 + + +@respx.mock +def test_job_ref_load_fetches_once(client: ZenRowsBatchClient): + """A `client.job(id)` ref is GET-free and has no `.data`; `.load()` + fetches exactly once and returns a loaded `JobHandle`.""" + get_route = respx.get(f"{BASE_URL}/jobs/J").mock( + return_value=Response(200, json=_ingest_job_json("done")) + ) + + ref = client.job("J") + assert get_route.call_count == 0 # minting the ref costs nothing + assert not hasattr(ref, "data") # a ref carries no snapshot + + handle = ref.load() # explicit GET + assert get_route.call_count == 1 + assert handle.data.status is not None + + +# ===== transport retries ===== + + +@pytest.fixture +def no_sleep(monkeypatch): + """Record + swallow retry backoff sleeps so tests run instantly. + Returns the list of sleep durations (seconds) in call order.""" + slept: list[float] = [] + monkeypatch.setattr("zenrows.batch._transport.time.sleep", slept.append) + return slept + + +@respx.mock +def test_retry_transient_status_then_succeeds(client: ZenRowsBatchClient, no_sleep): + """A 503 on an idempotent GET is retried; the eventual 200 wins.""" + route = respx.get(f"{BASE_URL}/jobs/J").mock( + side_effect=[ + Response(503, json={}), + Response(503, json={}), + Response(200, json=_ingest_job_json("done")), + ] + ) + + job = client.get_job("J") + + assert route.call_count == 3 # two retries, then success + assert job.data.status is JobStatus.CLOSED + assert len(no_sleep) == 2 # slept once before each retry + + +@respx.mock +def test_retry_exhausts_and_raises(client: ZenRowsBatchClient, no_sleep): + """Default 3 retries → 4 attempts, then the last error surfaces.""" + route = respx.get(f"{BASE_URL}/jobs/J").mock(return_value=Response(503, json={})) + + with pytest.raises(BatchAPIError) as exc: + client.get_job("J") + + assert exc.value.status_code == 503 + assert route.call_count == 4 # 1 + 3 retries + + +@respx.mock +def test_no_retry_on_non_retryable_status(client: ZenRowsBatchClient, no_sleep): + """A 400 is a client error, not transient — no retry.""" + route = respx.get(f"{BASE_URL}/jobs/J").mock(return_value=Response(400, json={})) + + with pytest.raises(BatchAPIError): + client.get_job("J") + + assert route.call_count == 1 + + +@respx.mock +def test_no_retry_on_non_idempotent_post(client: ZenRowsBatchClient, no_sleep): + """A plain POST (no Idempotency-Key) is not replayed on 503.""" + route = respx.post(f"{BASE_URL}/jobs").mock(return_value=Response(503, json={})) + + with pytest.raises(BatchAPIError): + client.submit_regular(["https://a"]) + + assert route.call_count == 1 + + +@respx.mock +def test_retry_on_post_with_idempotency_key(client: ZenRowsBatchClient, no_sleep): + """A POST carrying an Idempotency-Key IS safe to replay.""" + route = respx.post(f"{BASE_URL}/jobs").mock( + side_effect=[ + Response(503, json={}), + Response(201, json={"job_id": "J", "status": "closed", "accepted_tasks": 1}), + ] + ) + + ref = client.submit_regular(["https://a"], idempotency_key="k1") + + assert route.call_count == 2 + assert ref.job_id == "J" + + +@respx.mock +def test_retry_honors_retry_after(client: ZenRowsBatchClient, no_sleep): + """`Retry-After: 2` overrides the computed backoff for that wait.""" + respx.get(f"{BASE_URL}/jobs/J").mock( + side_effect=[ + Response(429, headers={"Retry-After": "2"}, json={}), + Response(200, json=_ingest_job_json("done")), + ] + ) + + client.get_job("J") + + assert no_sleep[0] == 2.0 # 2000ms / 1000 + + +@respx.mock +def test_retry_on_network_error_then_succeeds(client: ZenRowsBatchClient, no_sleep): + """Transient network errors on idempotent requests are retried.""" + route = respx.get(f"{BASE_URL}/jobs/J").mock( + side_effect=[httpx.ConnectError("reset"), Response(200, json=_ingest_job_json("done"))] + ) + + job = client.get_job("J") + + assert route.call_count == 2 + assert job.data.status is JobStatus.CLOSED + + +@respx.mock +def test_no_retry_on_timeout(client: ZenRowsBatchClient, no_sleep): + """Our own timeout budget is not retried — it propagates.""" + route = respx.get(f"{BASE_URL}/jobs/J").mock(side_effect=httpx.ReadTimeout("slow")) + + with pytest.raises(httpx.ReadTimeout): + client.get_job("J") + + assert route.call_count == 1 + assert no_sleep == [] + + +@respx.mock +def test_retries_zero_disables(no_sleep): + """`retries=0` sends each request exactly once.""" + client = ZenRowsBatchClient(api_key=API_KEY, base_url=BASE_URL, retries=0) + route = respx.get(f"{BASE_URL}/jobs/J").mock(return_value=Response(503, json={})) + + with pytest.raises(BatchAPIError): + client.get_job("J") + + assert route.call_count == 1 + + +# ===== webhooks ===== + +_WEBHOOK_JSON = {"url": "https://hooks.example.com/zr", "signature": True} + + +@respx.mock +def test_get_job_webhook(client: ZenRowsBatchClient): + respx.get(f"{BASE_URL}/jobs/J/webhook").mock(return_value=Response(200, json=_WEBHOOK_JSON)) + + cfg = client.get_job_webhook("J") + + assert str(cfg.url) == "https://hooks.example.com/zr" + assert cfg.signature is True + + +@respx.mock +def test_put_job_webhook_sends_both_fields(client: ZenRowsBatchClient): + route = respx.put(f"{BASE_URL}/jobs/J/webhook").mock( + return_value=Response(200, json=_WEBHOOK_JSON) + ) + + client.put_job_webhook("J", {"url": "https://hooks.example.com/zr", "signature": True}) + + assert json.loads(route.calls.last.request.content) == { + "url": "https://hooks.example.com/zr", + "signature": True, + } + + +@respx.mock +def test_delete_job_webhook(client: ZenRowsBatchClient): + route = respx.delete(f"{BASE_URL}/jobs/J/webhook").mock(return_value=Response(204)) + + assert client.delete_job_webhook("J") is None + assert route.call_count == 1 + + +@respx.mock +def test_test_webhook(client: ZenRowsBatchClient): + route = respx.post(f"{BASE_URL}/webhook/test").mock( + return_value=Response( + 200, + json={"delivered": True, "event_id": "01T", "status_code": 200, "elapsed_ms": 42}, + ) + ) + + resp = client.test_webhook({"url": "https://hooks.example.com/zr"}) + + assert resp.delivered is True + assert resp.status_code == 200 + assert json.loads(route.calls.last.request.content)["url"] == "https://hooks.example.com/zr" + + +@respx.mock +def test_job_ref_webhook_facet_delegates(client: ZenRowsBatchClient): + """`job.set_webhook()` / `.get_webhook()` hit the job webhook route.""" + put = respx.put(f"{BASE_URL}/jobs/J/webhook").mock( + return_value=Response(200, json=_WEBHOOK_JSON) + ) + + cfg = client.job("J").set_webhook("https://hooks.example.com/zr", signature=True) + + assert put.call_count == 1 + assert cfg.signature is True + + +# ===== single-task download ===== + + +def _task_result( + task_id: str, + *, + external_id: str | None = None, + result_type: str | None = None, + result_url: str | None = None, + status: str = "successful", +) -> TaskResult: + data = {"task_id": task_id, "run_id": "R", "url": "https://example.com", "status": status} + if external_id: + data["external_id"] = external_id + if result_type: + data["type"] = result_type + if result_url: + data["result_url"] = result_url + return TaskResult.model_validate(data) + + +@respx.mock +def test_download_task_pulls_from_result_url(client: ZenRowsBatchClient, tmp_path): + """`run.download_task_to_*` GET the presigned `result_url` directly + (no /content endpoint); memory returns raw bytes, file writes to a + path or an open binary file object.""" + result_url = "https://storage.example.test/bodies/T1.html?sig=abc" + route = respx.get(result_url).mock(return_value=Response(200, content=b"hi")) + run = client.run("J", "R") + task = _task_result("T1", result_url=result_url) + + assert run.download_task_to_memory(task) == b"hi" + + out = tmp_path / "nested" / "body.html" # parent dirs created + run.download_task_to_file(task, out) + assert out.read_bytes() == b"hi" + + buf = io.BytesIO() # also accepts an open binary file object + run.download_task_to_file(task, buf) + assert buf.getvalue() == b"hi" + + assert route.call_count == 3 # one GET per download, straight to storage + + +@respx.mock +def test_download_task_no_result_url_raises(client: ZenRowsBatchClient): + """A task with no `result_url` (e.g. a failed task) can't be downloaded.""" + task = _task_result("T1", status="failed") + with pytest.raises(ValueError, match="no result_url"): + client.run("J", "R").download_task_to_memory(task) + + +def test_external_id_filename_coerces_to_safe_name(): + """`use_external_id=True` coerces the id into a safe filename — + unsafe chars → `_`, missing id → task_id fallback.""" + from zenrows.batch._download import _external_id_filename + + ok = _task_result("T1", external_id="order-1", result_type="html") + assert _external_id_filename(ok) == "order-1.html" + + unsafe = _task_result("T1", external_id="a/b c", result_type="html") + assert _external_id_filename(unsafe) == "a_b_c.html" # '/' and ' ' → '_' + + missing = _task_result("T1", result_type="html") + assert _external_id_filename(missing) == "T1.html" # falls back to task_id + + +@respx.mock +def test_submit_job_post_task_method_body_on_wire(client: ZenRowsBatchClient): + """A POST task's method/body ride the wire verbatim; tasks that + don't set them send neither key (exclude_unset — the server treats + absent method as GET).""" + route = respx.post(f"{BASE_URL}/jobs").mock( + return_value=Response( + 201, + json={ + "job_id": "01J0000000000000000000000", + "status": "closed", + "accepted_tasks": 2, + }, + ) + ) + + client.submit_job( + { + "type": "regular", + "status": "closed", + "tasks": [ + { + "url": "https://api.example.com/graphql", + "method": "POST", + "body": {"query": "{ products { id } }"}, + }, + {"url": "https://example.com/plain"}, + ], + } + ) + + sent = json.loads(route.calls.last.request.content) + assert sent["tasks"][0]["method"] == "POST" + assert sent["tasks"][0]["body"] == {"query": "{ products { id } }"} + assert "method" not in sent["tasks"][1] + assert "body" not in sent["tasks"][1] diff --git a/tests/test_client.py b/tests/test_client.py index 96c7686..fff8bde 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,7 @@ -from unittest import mock, TestCase -from requests import Session +from unittest import TestCase, mock + import urllib3 +from requests import Session from zenrows import ZenRowsClient from zenrows.__version__ import __version__ @@ -8,9 +9,7 @@ apikey = "APIKEY" url = "http://example.com" api_url_base = "https://api.zenrows.com/v1/" -default_headers = { - "User-Agent": f"zenrows/{__version__} python" -} +default_headers = {"User-Agent": f"zenrows/{__version__} python"} class TestZenRowsClient(TestCase): @@ -25,52 +24,71 @@ def test_get_url(self, mock_request): mock_request.assert_called_once_with( "GET", api_url_base, - params={ - "url": url, - "apikey": apikey - }, + params={"url": url, "apikey": apikey}, headers=default_headers, data=None, ) @mock.patch.object(Session, "request") def test_get_with_params(self, mock_request): - self.zenrows_client.get( - url, params={ - "premium_proxy": True, - "proxy_country": "us" - }) + self.zenrows_client.get(url, params={"premium_proxy": True, "proxy_country": "us"}) mock_request.assert_called_once_with( "GET", api_url_base, - params={ - "url": url, - "apikey": apikey, - "premium_proxy": True, - "proxy_country": "us" - }, + params={"url": url, "apikey": apikey, "premium_proxy": True, "proxy_country": "us"}, + headers=default_headers, + data=None, + ) + + @mock.patch.object(Session, "request") + def test_fetch_is_the_primary_method_get_is_a_deprecated_alias(self, mock_request): + self.zenrows_client.fetch(url) + + mock_request.assert_called_once_with( + "GET", + api_url_base, + params={"url": url, "apikey": apikey}, + headers=default_headers, + data=None, + ) + + @mock.patch.object(Session, "request") + def test_extract_sets_the_extract_param_defaulting_to_auto(self, mock_request): + self.zenrows_client.extract(url) + + mock_request.assert_called_once_with( + "GET", + api_url_base, + params={"url": url, "apikey": apikey, "extract": "auto", "mode": "auto"}, + headers=default_headers, + data=None, + ) + + @mock.patch.object(Session, "request") + def test_extract_accepts_an_explicit_mode(self, mock_request): + self.zenrows_client.extract(url, mode="native") + + mock_request.assert_called_once_with( + "GET", + api_url_base, + params={"url": url, "apikey": apikey, "extract": "native", "mode": "auto"}, headers=default_headers, data=None, ) @mock.patch.object(Session, "request") def test_get_with_headers(self, mock_request): - self.zenrows_client.get( - url, headers={"Referrer": "https://www.google.com"}) + self.zenrows_client.get(url, headers={"Referrer": "https://www.google.com"}) mock_request.assert_called_once_with( "GET", api_url_base, - params={ - "url": url, - "apikey": apikey, - "custom_headers": True - }, + params={"url": url, "apikey": apikey, "custom_headers": True}, headers={ "User-Agent": f"zenrows/{__version__} python", "Referrer": "https://www.google.com", - 'Accept-Encoding': urllib3.util.SKIP_HEADER, + "Accept-Encoding": urllib3.util.SKIP_HEADER, "Connection": None, "Accept": None, }, @@ -80,19 +98,19 @@ def test_get_with_headers(self, mock_request): @mock.patch.object(Session, "request") def test_get_overwrite_ua(self, mock_request): self.zenrows_client.get( - url, headers={"User-Agent": "MyCustomUserAgent", }) + url, + headers={ + "User-Agent": "MyCustomUserAgent", + }, + ) mock_request.assert_called_once_with( "GET", api_url_base, - params={ - "url": url, - "apikey": apikey, - "custom_headers": True - }, + params={"url": url, "apikey": apikey, "custom_headers": True}, headers={ "User-Agent": "MyCustomUserAgent", - 'Accept-Encoding': urllib3.util.SKIP_HEADER, + "Accept-Encoding": urllib3.util.SKIP_HEADER, "Connection": None, "Accept": None, }, @@ -101,8 +119,7 @@ def test_get_overwrite_ua(self, mock_request): @mock.patch.object(Session, "request") def test_post_with_data(self, mock_request): - self.zenrows_client.post( - url, data={"key1": "value1", "key2": "value2"}) + self.zenrows_client.post(url, data={"key1": "value1", "key2": "value2"}) mock_request.assert_called_once_with( "POST", @@ -120,8 +137,7 @@ def test_post_with_data(self, mock_request): @mock.patch.object(Session, "request") def test_put_with_data(self, mock_request): - self.zenrows_client.put( - url, data={"key1": "value1", "key2": "value2"}) + self.zenrows_client.put(url, data={"key1": "value1", "key2": "value2"}) mock_request.assert_called_once_with( "PUT", diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py index c9bbdde..4efe474 100644 --- a/tests/test_concurrency.py +++ b/tests/test_concurrency.py @@ -1,5 +1,6 @@ from unittest import IsolatedAsyncioTestCase from unittest.mock import patch + from requests import Session from zenrows import ZenRowsClient diff --git a/tests/test_estimate.py b/tests/test_estimate.py new file mode 100644 index 0000000..659b4da --- /dev/null +++ b/tests/test_estimate.py @@ -0,0 +1,161 @@ +"""Cost estimation (SPEC §8.1) — pure, client-side, no network.""" + +import pytest + +from zenrows.batch import ( + CostEstimate, + Tier, + ZenRowsBatchClient, +) + +# Internal pricing helpers — no public function surface; estimation is +# reached via `client.estimate_cost` / `client.estimate_job`. These tests +# reach the private primitives to cover the rate card directly. +from zenrows.batch._estimate import _cost_for_params as cost_for_params +from zenrows.batch._estimate import _estimate_cost as estimate_cost +from zenrows.batch.models import TaskInput + +# ----- single-task pricing (the rate card) ----- + + +@pytest.mark.parametrize( + ("params", "tier", "lo", "hi"), + [ + ({}, Tier.BASE, 1, 1), + ({"js_render": "true"}, Tier.JS, 5, 5), + ({"premium_proxy": "true"}, Tier.PREMIUM, 10, 10), + ({"js_render": "true", "premium_proxy": "true"}, Tier.JS_AND_PREMIUM, 25, 25), + ({"mode": "auto"}, Tier.AUTO, 1, 25), + ], +) +def test_cost_for_params_rate_card(params, tier, lo, hi): + tc = cost_for_params(params) + assert tc.tier is tier + assert (tc.min, tc.max) == (lo, hi) + assert tc.exact == (lo == hi) + + +@pytest.mark.parametrize("value", [True, "true", "True", " TRUE ", 1, "1", "yes", "on"]) +def test_truthy_spellings_turn_on_a_flag(value): + assert cost_for_params({"js_render": value}).tier is Tier.JS + + +@pytest.mark.parametrize("value", [False, "false", "0", 0, "", "no"]) +def test_falsy_spellings_keep_base(value): + assert cost_for_params({"js_render": value}).tier is Tier.BASE + + +def test_auto_wins_over_explicit_flags(): + # Server rejects this combo at submit (mutually exclusive), but if + # it slips through, auto is what the engine honors. + tc = cost_for_params({"mode": "auto", "js_render": "true", "premium_proxy": "true"}) + assert tc.tier is Tier.AUTO + assert (tc.min, tc.max) == (1, 25) + + +# ----- job aggregation ----- + + +def test_empty_job_is_zero(): + est = estimate_cost([]) + assert (est.task_count, est.min, est.max) == (0, 0, 0) + assert est.exact + assert est.breakdown == () + + +def test_all_base_is_exact(): + est = estimate_cost(["https://a", "https://b", "https://c"]) + assert (est.min, est.max) == (3, 3) + assert est.exact + assert est.auto_tasks == 0 + + +def test_job_level_params_apply_to_every_task(): + est = estimate_cost(["https://a", "https://b"], zenrows_params={"premium_proxy": True}) + assert (est.min, est.max) == (20, 20) + assert est.breakdown[0].tier is Tier.PREMIUM + assert est.breakdown[0].count == 2 + + +def test_task_params_override_job_params(): + # Job says premium (10); one task overrides to plain base (1). + est = estimate_cost( + [ + {"url": "https://a", "zenrows_params": {"premium_proxy": False}}, + {"url": "https://b"}, + ], + zenrows_params={"premium_proxy": True}, + ) + assert (est.min, est.max) == (1 + 10, 1 + 10) + tiers = {line.tier for line in est.breakdown} + assert tiers == {Tier.BASE, Tier.PREMIUM} + + +def test_auto_drives_the_range(): + est = estimate_cost([{"url": "https://a", "zenrows_params": {"mode": "auto"}}] * 50) + assert (est.min, est.max) == (50, 1250) + assert not est.exact + assert est.auto_tasks == 50 + # width is exactly 24 x auto_tasks + assert est.max - est.min == 24 * est.auto_tasks + + +def test_mixed_breakdown_sums_and_orders(): + tasks = ( + ["https://base1", "https://base2"] # 2 x base + + [{"url": "https://js", "zenrows_params": {"js_render": "true"}}] # 1 x js + + [{"url": f"https://auto{i}", "zenrows_params": {"mode": "auto"}} for i in range(3)] + ) + est = estimate_cost(tasks) + assert est.task_count == 6 + # min: 2*1 + 1*5 + 3*1 = 10 ; max: 2*1 + 1*5 + 3*25 = 82 + assert (est.min, est.max) == (10, 82) + # breakdown renders in tier order: base, js, auto + assert [line.tier for line in est.breakdown] == [Tier.BASE, Tier.JS, Tier.AUTO] + base, js, auto = est.breakdown + assert (base.count, base.subtotal_min, base.subtotal_max) == (2, 2, 2) + assert (js.count, js.subtotal_min, js.subtotal_max) == (1, 5, 5) + assert (auto.count, auto.subtotal_min, auto.subtotal_max) == (3, 3, 75) + + +def test_taskinput_model_input_supported(): + tasks = [ + TaskInput(url="https://a", zenrows_params={"js_render": True}), + TaskInput(url="https://b"), + ] + est = estimate_cost(tasks) + assert (est.min, est.max) == (6, 6) + + +# ----- presentation ----- + + +def test_str_and_format(): + est = estimate_cost(["https://a", {"url": "https://b", "zenrows_params": {"mode": "auto"}}]) + assert str(est) == "2-26 credits (2 tasks)" + out = est.format() + assert "2 tasks → 2-26 credits" in out + assert "base" in out and "auto" in out + + +# ----- client convenience (no network) ----- + + +def test_client_estimate_cost_is_offline(): + client = ZenRowsBatchClient(api_key="test-key") + est = client.estimate_cost( + { + "type": "regular", + "status": "closed", + "zenrows_params": {"js_render": "true"}, + "tasks": [{"url": "https://a"}, {"url": "https://b"}], + } + ) + assert isinstance(est, CostEstimate) + assert (est.min, est.max) == (10, 10) + + +def test_client_estimate_cost_file_input_is_zero(): + client = ZenRowsBatchClient(api_key="test-key") + est = client.estimate_cost({"type": "regular", "status": "closed", "file_input_id": "01HKE..."}) + assert (est.task_count, est.min, est.max) == (0, 0, 0) diff --git a/tests/test_fetch_extract.py b/tests/test_fetch_extract.py new file mode 100644 index 0000000..4ea18ac --- /dev/null +++ b/tests/test_fetch_extract.py @@ -0,0 +1,339 @@ +"""Real behavior tests for fetch()/extract() and the client-level gaps they share. + +Existing tests in test_client.py only assert on *call arguments* passed to +`Session.request` — none of them exercise what the client actually does with +a response, an error status, an unvalidated mode string, or the constructor/ +context-manager surface. These tests close that gap: they assert on what +`fetch()`/`extract()`/`get()` actually *return* and *raise* (or don't). +""" + +from unittest import IsolatedAsyncioTestCase, TestCase, mock + +from requests import Response, Session + +from zenrows import ZenRowsClient + +apikey = "APIKEY" +url = "http://example.com" +api_url_base = "https://api.zenrows.com/v1/" + + +def _fake_response(status_code: int, body: bytes = b"") -> Response: + response = Response() + response.status_code = status_code + response._content = body + return response + + +class TestFetchExtractErrorHandling(TestCase): + """The client never raises on a non-2xx status — it hands the caller the + real Response so they can check `.status_code`/`.text` themselves. That's + a deliberate design choice (no `raise_for_status()` anywhere in `_worker`), + not an oversight — these tests pin that behavior down so a future change + can't silently start raising (or silently start swallowing errors) without + a test failing. + """ + + def setUp(self): + self.client = ZenRowsClient(apikey) + + @mock.patch.object(Session, "request") + def test_fetch_returns_error_response_unchanged_no_raise(self, mock_request): + mock_request.return_value = _fake_response(403, b"blocked") + + response = self.client.fetch(url) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.content, b"blocked") + + @mock.patch.object(Session, "request") + def test_extract_returns_error_response_unchanged_no_raise(self, mock_request): + mock_request.return_value = _fake_response(500, b"upstream failure") + + response = self.client.extract(url) + + self.assertEqual(response.status_code, 500) + self.assertEqual(response.content, b"upstream failure") + + @mock.patch.object(Session, "request") + def test_get_and_fetch_produce_byte_identical_request_calls(self, mock_request): + """get() is documented as a deprecated alias for fetch() — assert they + are actually identical calls, not just "both work".""" + mock_request.return_value = _fake_response(200) + + self.client.get(url, params={"js_render": True}, headers={"X-Test": "1"}) + get_call = mock_request.call_args + mock_request.reset_mock() + + self.client.fetch(url, params={"js_render": True}, headers={"X-Test": "1"}) + fetch_call = mock_request.call_args + + self.assertEqual(get_call, fetch_call) + + @mock.patch.object(Session, "request") + def test_extract_accepts_standard_mode(self, mock_request): + mock_request.return_value = _fake_response(200) + + self.client.extract(url, mode="standard") + + _, kwargs = mock_request.call_args + self.assertEqual(kwargs["params"]["extract"], "standard") + + @mock.patch.object(Session, "request") + def test_extract_does_not_validate_mode_value(self, mock_request): + """There is no validation on `mode` in extract() — any string is passed + straight through as the `extract` query param. This test documents that + as current, intentional-until-decided behavior (server-side validates + instead); if that ever changes, this test should be the one that fails. + """ + mock_request.return_value = _fake_response(200) + + self.client.extract(url, mode="not-a-real-mode") + + _, kwargs = mock_request.call_args + self.assertEqual(kwargs["params"]["extract"], "not-a-real-mode") + + @mock.patch.object(Session, "request") + def test_extract_merges_extract_param_with_other_params(self, mock_request): + mock_request.return_value = _fake_response(200) + + self.client.extract(url, params={"js_render": True}, mode="native") + + _, kwargs = mock_request.call_args + self.assertEqual( + kwargs["params"], + { + "url": url, + "apikey": apikey, + "js_render": True, + "extract": "native", + "mode": "auto", + }, + ) + + @mock.patch.object(Session, "request") + def test_extract_does_not_mutate_caller_supplied_params_dict(self, mock_request): + """extract() copies `params` before adding `extract` — the caller's dict + must come back untouched, otherwise a caller reusing a params dict + across calls would leak `extract` into an unrelated fetch().""" + mock_request.return_value = _fake_response(200) + caller_params = {"js_render": True} + + self.client.extract(url, params=caller_params, mode="native") + + self.assertEqual(caller_params, {"js_render": True}) + + +class TestExtractAutoparseFallback(TestCase): + """`extract(mode="auto")` is a domain-gated open beta: AUTH010 means the + target domain isn't enabled yet. By default this retries once with + Autoparse instead of raising - same behavior as the CLI's extract + adapter.""" + + def setUp(self): + self.client = ZenRowsClient(apikey) + + @mock.patch.object(Session, "request") + def test_falls_back_to_autoparse_on_auth010(self, mock_request): + mock_request.side_effect = [ + _fake_response(402, b'{"code": "AUTH010", "title": "Domain not enabled"}'), + _fake_response(200, b'[{"found": "via autoparse"}]'), + ] + + response = self.client.extract(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(mock_request.call_count, 2) + _, fallback_kwargs = mock_request.call_args + self.assertTrue(fallback_kwargs["params"].get("autoparse")) + self.assertNotIn("extract", fallback_kwargs["params"]) + + @mock.patch.object(Session, "request") + def test_fallback_disabled_returns_error_response(self, mock_request): + mock_request.return_value = _fake_response(402, b'{"code": "AUTH010"}') + + response = self.client.extract(url, fallback_to_autoparse=False) + + self.assertEqual(response.status_code, 402) + self.assertEqual(mock_request.call_count, 1) + + @mock.patch.object(Session, "request") + def test_402_without_auth010_does_not_fall_back(self, mock_request): + """A real credits-exhausted 402 (e.g. AUTH004) must not be mistaken + for the domain-gating error.""" + mock_request.return_value = _fake_response( + 402, b'{"code": "AUTH004", "title": "No credit available"}' + ) + + response = self.client.extract(url) + + self.assertEqual(response.status_code, 402) + self.assertEqual(mock_request.call_count, 1) + + @mock.patch.object(Session, "request") + def test_no_fallback_for_non_auto_mode(self, mock_request): + """AUTH010 shouldn't trigger a fallback for native/standard modes - + only "auto" is the domain-gated beta path.""" + mock_request.return_value = _fake_response(402, b'{"code": "AUTH010"}') + + response = self.client.extract(url, mode="native") + + self.assertEqual(response.status_code, 402) + self.assertEqual(mock_request.call_count, 1) + + @mock.patch.object(Session, "request") + def test_fallback_does_not_mutate_caller_supplied_params_dict(self, mock_request): + mock_request.side_effect = [ + _fake_response(402, b'{"code": "AUTH010"}'), + _fake_response(200, b"{}"), + ] + caller_params = {"js_render": True} + + self.client.extract(url, params=caller_params) + + self.assertEqual(caller_params, {"js_render": True}) + + +class TestExtractAdaptiveStealth(TestCase): + """extract() sends Adaptive Stealth Mode (mode="auto" at the wire level) by + default, so targets needing js_render/premium_proxy (e.g. Zoopla) escalate + automatically instead of failing with REQS002.""" + + def setUp(self): + self.client = ZenRowsClient(apikey) + + @mock.patch.object(Session, "request") + def test_sends_adaptive_stealth_by_default(self, mock_request): + mock_request.return_value = _fake_response(200) + + self.client.extract(url) + + _, kwargs = mock_request.call_args + self.assertEqual(kwargs["params"]["mode"], "auto") + + @mock.patch.object(Session, "request") + def test_omits_wire_mode_when_disabled(self, mock_request): + mock_request.return_value = _fake_response(200) + + self.client.extract(url, adaptive_stealth=False) + + _, kwargs = mock_request.call_args + self.assertNotIn("mode", kwargs["params"]) + + @mock.patch.object(Session, "request") + def test_fallback_request_also_carries_adaptive_stealth(self, mock_request): + mock_request.side_effect = [ + _fake_response(402, b'{"code": "AUTH010"}'), + _fake_response(200, b"{}"), + ] + + self.client.extract(url) + + _, fallback_kwargs = mock_request.call_args + self.assertEqual(fallback_kwargs["params"]["mode"], "auto") + + +class TestExtractAsyncAutoparseFallback(IsolatedAsyncioTestCase): + """Async counterpart - same AUTH010 -> Autoparse behavior.""" + + def setUp(self): + self.client = ZenRowsClient(apikey, concurrency=2) + + @mock.patch.object(Session, "request") + async def test_falls_back_to_autoparse_on_auth010(self, mock_request): + mock_request.side_effect = [ + _fake_response(402, b'{"code": "AUTH010"}'), + _fake_response(200, b'[{"found": "via autoparse"}]'), + ] + + response = await self.client.extract_async(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(mock_request.call_count, 2) + + +class TestFetchExtractAsync(IsolatedAsyncioTestCase): + def setUp(self): + self.client = ZenRowsClient(apikey, concurrency=2) + + @mock.patch.object(Session, "request") + async def test_fetch_async_returns_error_response_unchanged(self, mock_request): + mock_request.return_value = _fake_response(429, b"rate limited") + + response = await self.client.fetch_async(url) + + self.assertEqual(response.status_code, 429) + self.assertEqual(response.content, b"rate limited") + + @mock.patch.object(Session, "request") + async def test_extract_async_sets_mode(self, mock_request): + mock_request.return_value = _fake_response(200) + + await self.client.extract_async(url, mode="native") + + _, kwargs = mock_request.call_args + self.assertEqual(kwargs["params"]["extract"], "native") + + @mock.patch.object(Session, "request") + async def test_get_async_and_fetch_async_produce_identical_calls(self, mock_request): + mock_request.return_value = _fake_response(200) + + await self.client.get_async(url, params={"premium_proxy": True}) + get_call = mock_request.call_args + mock_request.reset_mock() + + await self.client.fetch_async(url, params={"premium_proxy": True}) + fetch_call = mock_request.call_args + + self.assertEqual(get_call, fetch_call) + + +class TestClientConstructionAndLifecycle(TestCase): + """Covers the constructor validation and close()/context-manager surface + that every fetch()/extract() call depends on but that nothing exercised.""" + + def test_empty_apikey_raises(self): + with self.assertRaises(ValueError): + ZenRowsClient("") + + def test_none_apikey_raises(self): + with self.assertRaises(ValueError): + ZenRowsClient(None) # type: ignore[arg-type] + + def test_base_url_override_via_constructor(self): + client = ZenRowsClient(apikey, base_url="https://custom.example/v1/") + self.assertEqual(client.api_url, "https://custom.example/v1/") + + @mock.patch.dict("os.environ", {"ZENROWS_SCRAPER_BASE_URL": "https://env.example/v1/"}) + def test_base_url_override_via_env_var(self): + client = ZenRowsClient(apikey) + self.assertEqual(client.api_url, "https://env.example/v1/") + + def test_constructor_arg_wins_over_env_var(self): + with mock.patch.dict("os.environ", {"ZENROWS_SCRAPER_BASE_URL": "https://env.example/v1/"}): + client = ZenRowsClient(apikey, base_url="https://explicit.example/v1/") + self.assertEqual(client.api_url, "https://explicit.example/v1/") + + def test_close_shuts_down_session_and_executor(self): + client = ZenRowsClient(apikey) + + with ( + mock.patch.object(client.requests_session, "close") as mock_close, + mock.patch.object(client.executor, "shutdown") as mock_shutdown, + ): + client.close() + + mock_close.assert_called_once() + mock_shutdown.assert_called_once_with(wait=True) + + def test_context_manager_calls_close_on_exit(self): + with mock.patch.object(ZenRowsClient, "close") as mock_close: + with ZenRowsClient(apikey) as client: + self.assertIsInstance(client, ZenRowsClient) + mock_close.assert_called_once() + + def test_context_manager_calls_close_even_on_exception(self): + with mock.patch.object(ZenRowsClient, "close") as mock_close: + with self.assertRaises(RuntimeError), ZenRowsClient(apikey): + raise RuntimeError("boom") + mock_close.assert_called_once() diff --git a/tests/test_retries.py b/tests/test_retries.py index b06823f..3538238 100644 --- a/tests/test_retries.py +++ b/tests/test_retries.py @@ -1,9 +1,10 @@ from unittest import TestCase from unittest.mock import patch + from requests import Session -from urllib3.util.retry import Retry from zenrows import ZenRowsClient +from zenrows.client import _RETRY_STATUSES apikey = "APIKEY" url = "http://example.com" @@ -11,7 +12,7 @@ class TestZenRowsClientRetries(TestCase): - @patch.object(Retry, "new") + @patch("zenrows.client.Retry") @patch.object(Session, "mount") def test_custom_session_not_initiated(self, mock_mount, mock_retry): ZenRowsClient(apikey, retries=0) @@ -19,14 +20,14 @@ def test_custom_session_not_initiated(self, mock_mount, mock_retry): mock_retry.assert_not_called() self.assertEqual(mock_mount.call_count, 2) # called internally - @patch.object(Retry, "new") + @patch("zenrows.client.Retry") def test_retry_parameters(self, mock_retry): ZenRowsClient(apikey, retries=2) mock_retry.assert_called_once_with( total=2, backoff_factor=0.5, - status_forcelist=[422, 429, 500, 502, 503, 504], + status_forcelist=list(_RETRY_STATUSES), raise_on_status=False, ) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a0991bf --- /dev/null +++ b/uv.lock @@ -0,0 +1,1012 @@ +version = 1 +revision = 2 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "argcomplete" +version = "3.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "black" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" }, + { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" }, + { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" }, + { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "datamodel-code-generator" +version = "0.58.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "black" }, + { name = "genson" }, + { name = "inflect" }, + { name = "isort" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/7f/0e838be05fb64a088730bf5a3a510f4260e43ea9fbcea8b86d97aa31ca80/datamodel_code_generator-0.58.0.tar.gz", hash = "sha256:14b157b26ca85b8dfc2fdf2ada242f003937a130375892003067a4fdf046021c", size = 986860, upload-time = "2026-05-25T03:23:53.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/f6/992262b2f7845885899a6b57ff81f97f8506d443a20b7244ee8ea85247d8/datamodel_code_generator-0.58.0-py3-none-any.whl", hash = "sha256:73c4feb12bf773e280eb721b7968a5d5dbb953be2a700bf70995ba442abd0a57", size = 274532, upload-time = "2026-05-25T03:23:51.027Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "genson" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/cf/2303c8ad276dcf5ee2ad6cf69c4338fd86ef0f471a5207b069adf7a393cf/genson-1.3.0.tar.gz", hash = "sha256:e02db9ac2e3fd29e65b5286f7135762e2cd8a986537c075b06fc5f1517308e37", size = 34919, upload-time = "2024-05-15T22:08:49.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/5c/e226de133afd8bb267ec27eead9ae3d784b95b39a287ed404caab39a5f50/genson-1.3.0-py3-none-any.whl", hash = "sha256:468feccd00274cc7e4c09e84b08704270ba8d95232aa280f65b986139cec67f7", size = 21470, upload-time = "2024-05-15T22:08:47.056Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, +] + +[[package]] +name = "inflect" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197, upload-time = "2024-12-28T17:11:15.931Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-httpx" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/42/f53c58570e80d503ade9dd42ce57f2915d14bcbe25f6308138143950d1d6/pytest_httpx-0.36.2.tar.gz", hash = "sha256:05a56527484f7f4e8c856419ea379b8dc359c36801c4992fdb330f294c690356", size = 57683, upload-time = "2026-04-09T13:57:19.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/55/1fa65f8e4fceb19dd6daa867c162ad845d547f6058cd92b4b02384a44777/pytest_httpx-0.36.2-py3-none-any.whl", hash = "sha256:d42ebd5679442dc7bfb0c48e0767b6562e9bc4534d805127b0084171886a5e22", size = 20315, upload-time = "2026-04-09T13:57:18.587Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "respx" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "ty" +version = "0.0.46" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/7d/d95b5a9dea83472006be3ce5e480028c44b34138d84d0172e910f287fb69/ty-0.0.46.tar.gz", hash = "sha256:c6c2d7105b5633b49950b4c3a90d1ed2613eb9d794ad582bbbf6c4ffcb93accf", size = 5832380, upload-time = "2026-06-09T03:28:05.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/24/f9f7533c391610521f4164e6b8e37ef72d0c1ee8651bc0d9ce9e658b953b/ty-0.0.46-py3-none-linux_armv6l.whl", hash = "sha256:5e716337994699cbc1a1a7b7a3e6622306f2574c710330f9d9691c2c3d8391b0", size = 11756264, upload-time = "2026-06-09T03:28:20.112Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/ff3d13655b9b5cc8176f4c3446bf7ec2df43c8ad9e5272d4adc5d952fa45/ty-0.0.46-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:51d618dec5403635690d0e3e298cd0ad3d84ebc6a576652939ef30ce96fce4b2", size = 11492723, upload-time = "2026-06-09T03:28:13.23Z" }, + { url = "https://files.pythonhosted.org/packages/82/4a/e7e3209e353c5835c7756339bbcdfda10852407b80fbb9ed46c17241873a/ty-0.0.46-py3-none-macosx_11_0_arm64.whl", hash = "sha256:acbafd6a2351b07a6cf4c945b0b1d47f6d2826faac2526a351dfa74d3a3cc664", size = 10892822, upload-time = "2026-06-09T03:27:51.179Z" }, + { url = "https://files.pythonhosted.org/packages/6c/20/4390c90434a9ddefcecb65e8df00e4c2700e9739dc0baf58bed36d25f713/ty-0.0.46-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de5df602ffd760612ae36602bbad69b0123ff6cffd92e62aa92b7709317d69e3", size = 11408745, upload-time = "2026-06-09T03:27:58.049Z" }, + { url = "https://files.pythonhosted.org/packages/75/0c/f13a1bf9c6798530c773667095a6cf8f73ec9721db359423e7249bff7fbc/ty-0.0.46-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7abf5a10b30d8641faad90f6a19989daec941bb90261159e05cfeb04d2012046", size = 11544432, upload-time = "2026-06-09T03:27:53.519Z" }, + { url = "https://files.pythonhosted.org/packages/56/69/eb3710c13dff846a0362df04fadd8a39b64ccc244c0d02ce5285ede8eae5/ty-0.0.46-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8770404139c6ccee2ce2fc226478cfa4100915133c876c257e52197b8b92051d", size = 12031228, upload-time = "2026-06-09T03:28:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/68/5f5db9c84c1d44acdc67281089b372d9d818ee68123a60c59c66187095e2/ty-0.0.46-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f960d5a6e4860076924d2b86891d9872c4a3daa4663fb416e640b22cf3dbf68e", size = 12596073, upload-time = "2026-06-09T03:28:25.204Z" }, + { url = "https://files.pythonhosted.org/packages/14/be/cfd0bb272e6a1491f6de30c60da1f39c2b3c3524ec64a5c92b71365c9185/ty-0.0.46-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d9000a4a3ed08fc37e8a2ff0b801cde06e1c2af3bc053677744bb5a1b751030", size = 12284885, upload-time = "2026-06-09T03:28:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/2cd541f6320f5d6f70a45725c4e1016efedd5545348bb23b47ffb3e4c724/ty-0.0.46-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1160e6dc86536109ab755f7142f36f4dda5333c8330cf230d61819494d27125", size = 12079480, upload-time = "2026-06-09T03:27:55.847Z" }, + { url = "https://files.pythonhosted.org/packages/de/91/8e0075bc6568fb477e7ef4d805c67fa6902b692cb4419e0bf5ce3c04c5bc/ty-0.0.46-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b619c0efe007731f8221fa787701bfa4402da7a83eb26c61ae25e77b6ace6384", size = 12316547, upload-time = "2026-06-09T03:28:08.28Z" }, + { url = "https://files.pythonhosted.org/packages/00/28/b96cbfeda019a4044c6a8cd06ff84d08b631d4ba7d9a1e6dc0311df3563a/ty-0.0.46-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ad98fccb6a8a94c4121b993761a0deee602f5826c4162e0a91f4f8118ddadd42", size = 11392846, upload-time = "2026-06-09T03:28:00.418Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d0/4d77f699a95ac7a13b94ca1a58682667cfe974f91557d9e2a9fc0b808a7f/ty-0.0.46-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:74536b13c3cc3f5944408669c202d4c57c3d19ff154732df8e6145718aef9191", size = 11559017, upload-time = "2026-06-09T03:28:17.619Z" }, + { url = "https://files.pythonhosted.org/packages/88/62/1d6f6b51c2b132da8011c6a41ead0c1fd2a0b17ea72304bcf6ce084d581a/ty-0.0.46-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5e50b1e96ced41b609e24ed27d9e4f508584ed7f4d0bb717ca8c8d75d2fd1b7c", size = 11666509, upload-time = "2026-06-09T03:28:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9a/6643894bc12cb30c281f4c8bf37f6d30c1fbd9484ef39a12b0ea6dae3c1c/ty-0.0.46-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0a7d9f58d26d938e5d2f607481b7a412d8c00d675a1ec72004fa9d6b3b9def99", size = 12180448, upload-time = "2026-06-09T03:28:32.329Z" }, + { url = "https://files.pythonhosted.org/packages/86/68/0f3b7bb03a7da676ef51b1c0af0bde1e500d69d5f0c807ed63b6f30b66dd/ty-0.0.46-py3-none-win32.whl", hash = "sha256:26db0ce89c573e60132d14e9688c9329a1633b1a8c26fe457025c7c406f7d5e6", size = 10960002, upload-time = "2026-06-09T03:28:02.832Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f4/91ff618b2dee39d0633d23e1adac0174aa1de80df17e270acac534034dbc/ty-0.0.46-py3-none-win_amd64.whl", hash = "sha256:90e8e6d446b9cb7cb4bede9fca7b3c99fd1e2355605ecf431c131a51db2a5e93", size = 12097413, upload-time = "2026-06-09T03:28:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2e/300174fca375a27a7c28dd80e990d857d7b3e3b25980c65063f980aa2f17/ty-0.0.46-py3-none-win_arm64.whl", hash = "sha256:ebd320d82605079b901a095dc4711037a0c488b4ace79a602fef4df0d3f4cf74", size = 11439595, upload-time = "2026-06-09T03:28:15.355Z" }, +] + +[[package]] +name = "typeguard" +version = "4.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1c/dfba5c4633cafc4c701f237d2ba63b416805047fd6d96aab4cfc40969f98/typeguard-4.5.2.tar.gz", hash = "sha256:5a16dcac23502039299c97c8941651bc33d7ea8cc4b2f7d6bbb1b528f6eea423", size = 80240, upload-time = "2026-05-14T12:59:40.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl", hash = "sha256:fcf9de18bd945cdb4c7b996e12b4c51ce83f92f191314a6d7cf1739586ec98cf", size = 36748, upload-time = "2026-05-14T12:59:39.473Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "zenrows" +version = "1.4.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "datamodel-code-generator" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-httpx" }, + { name = "respx" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "pydantic", specifier = ">=2.9" }, + { name = "requests", specifier = ">=2.31" }, + { name = "tqdm", specifier = ">=4.66" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "datamodel-code-generator", specifier = ">=0.26" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "pytest-httpx", specifier = ">=0.30" }, + { name = "respx", specifier = ">=0.21" }, + { name = "ruff", specifier = ">=0.7" }, + { name = "ty" }, +] diff --git a/zenrows/__init__.py b/zenrows/__init__.py deleted file mode 100644 index a445fd5..0000000 --- a/zenrows/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from zenrows.client import ZenRowsClient - -__all__ = ['ZenRowsClient'] diff --git a/zenrows/client.py b/zenrows/client.py deleted file mode 100644 index 1be3001..0000000 --- a/zenrows/client.py +++ /dev/null @@ -1,92 +0,0 @@ -import requests -import asyncio -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry -from concurrent.futures import ThreadPoolExecutor -import urllib3 -from functools import partial - -from .__version__ import __version__ - - -class ZenRowsClient: - api_url = "https://api.zenrows.com/v1/" - - def __init__(self, apikey: str, retries: int = 0, concurrency: int = 5): - self.apikey = apikey - - self.executor = ThreadPoolExecutor(max_workers=concurrency) - - self.requests_session = requests.Session() - if (retries > 0): - max_retries = Retry().new( - total=retries, - backoff_factor=0.5, - status_forcelist=[422, 429, 500, 502, 503, 504], - raise_on_status=False, - ) - adapter = HTTPAdapter(max_retries=max_retries) - self.requests_session.mount("https://", adapter) - self.requests_session.mount("http://", adapter) - - def get( - self, url: str, params: dict = None, headers: dict = None, **kwargs - ) -> requests.Response: - return self._worker("GET", url, params, headers, **kwargs) - - async def get_async( - self, url: str, params: dict = None, headers: dict = None, **kwargs - ) -> requests.Response: - loop = asyncio.get_event_loop() - return await loop.run_in_executor(self.executor, partial(self._worker, "GET", url, params, headers, **kwargs)) - - def post( - self, url: str, params: dict = None, headers: dict = None, data: dict = None, **kwargs - ) -> requests.Response: - return self._worker("POST", url, params, headers, data, **kwargs) - - async def post_async( - self, url: str, params: dict = None, headers: dict = None, data: dict = None, **kwargs - ) -> requests.Response: - loop = asyncio.get_event_loop() - return await loop.run_in_executor( - self.executor, partial(self._worker, "POST", url, params, headers, data, **kwargs) - ) - - def put( - self, url: str, params: dict = None, headers: dict = None, data: dict = None, **kwargs - ) -> requests.Response: - return self._worker("PUT", url, params, headers, data, **kwargs) - - async def put_async( - self, url: str, params: dict = None, headers: dict = None, data: dict = None, **kwargs - ) -> requests.Response: - loop = asyncio.get_event_loop() - return await loop.run_in_executor( - self.executor, partial(self._worker, "PUT", url, params, headers, data, **kwargs) - ) - - def _worker( - self, method, url: str, params: dict = None, headers: dict = None, data: dict = None, **kwargs - ): - final_params = {} - if params: - final_params.update(params) - final_params.update({"url": url, "apikey": self.apikey}) - - final_headers = {"User-Agent": f"zenrows/{__version__} python"} - - if headers: - final_params["custom_headers"] = True - - final_headers["Accept"] = None - final_headers["Accept-Encoding"] = urllib3.util.SKIP_HEADER - final_headers["Connection"] = None - else: - headers = {} - - final_headers.update(headers) - - return self.requests_session.request( - method, self.api_url, params=final_params, headers=final_headers, data=data, **kwargs - )