diff --git a/README.md b/README.md
index 7ae11a8..70a842c 100644
--- a/README.md
+++ b/README.md
@@ -221,6 +221,105 @@ for job in response.jobs:
print(f"Job {job.job_id}: {job.status}")
```
+## V2 API
+
+`client.v2` is a new, **additive** sub-client for LandingAI's next-generation ADE gateway. It does not replace or change anything about the V1 usage above -- `client.parse`, `client.extract`, `client.parse_jobs`, `client.extract_jobs`, etc. all keep working exactly as documented. Use `client.v2.*` when you want the newer parse/extract surface.
+
+The V2 gateway lives on its own host (`api.ade.[env].landing.ai`), separate from the V1 host (`api.va.[env].landing.ai`). Select the environment the same way as V1, via the `environment` argument or the `LANDINGAI_ADE_ENVIRONMENT` env var:
+
+```python
+import os
+from landingai_ade import LandingAIADE
+
+client = LandingAIADE(
+ apikey=os.environ.get("VISION_AGENT_API_KEY"),
+ # one of "production" (default), "eu", "staging", "dev"
+ # can also be set via the LANDINGAI_ADE_ENVIRONMENT env var instead of passing it here
+ environment="staging",
+)
+```
+
+### Sync parse and extract
+
+```python
+from pathlib import Path
+from landingai_ade import LandingAIADE
+
+client = LandingAIADE()
+
+parse_result = client.v2.parse(
+ document=Path("path/to/file.pdf"),
+ save_to="./output_folder", # optional: saves as {input_file}_parse_output.json
+)
+print(parse_result.markdown)
+
+extract_result = client.v2.extract(
+ schema='{"type": "object", "properties": {"total": {"type": "string"}}}',
+ markdown=parse_result.markdown,
+ strict=False, # prune unsupported schema fields instead of raising a 422
+)
+print(extract_result.extraction)
+```
+
+### Passing a pydantic model as the extract schema
+
+`schema` accepts a pydantic `BaseModel` subclass directly -- no need to convert it yourself:
+
+```python
+from pydantic import BaseModel, Field
+from landingai_ade import LandingAIADE
+
+
+class Invoice(BaseModel):
+ total: str = Field(description="Invoice grand total")
+
+
+client = LandingAIADE()
+result = client.v2.extract(schema=Invoice, markdown_url="https://example.com/doc.md")
+print(result.extraction)
+```
+
+### Async jobs and `wait()`
+
+For large documents, create a job and poll it, or block until it finishes with `.wait()`:
+
+```python
+from pathlib import Path
+from landingai_ade import LandingAIADE
+
+client = LandingAIADE()
+
+job = client.v2.parse_jobs.create(document=Path("path/to/large_file.pdf"), priority="priority")
+print(job.job_id, job.status)
+
+# Block until the job is terminal (raises JobWaitTimeoutError / JobFailedError on failure paths)
+done = client.v2.parse_jobs.wait(job.job_id, timeout=600, raise_on_failure=True)
+if done.result is not None:
+ print(done.result.markdown[:200])
+
+# client.v2.extract_jobs.{create,get,list,wait} mirror the same shape for extract jobs.
+```
+
+`parse_jobs.create` and `extract_jobs.create` both return a normalized `Job` -- one shape shared by parse and extract jobs, even though their upstream envelopes differ. Use `job.raw` to reach any field not surfaced on the typed model.
+
+### Uploading a file for `markdown_ref`
+
+`client.v2.files.upload` stages bytes on the ADE data plane and returns a `file_ref` you can pass as `markdown_ref` to extract:
+
+```python
+from pathlib import Path
+from landingai_ade import LandingAIADE
+
+client = LandingAIADE()
+
+file_ref = client.v2.files.upload(file=Path("path/to/file.md"))
+result = client.v2.extract(schema={"type": "object", "properties": {}}, markdown_ref=file_ref)
+```
+
+`client.v2.parse`, `client.v2.extract`, and their async counterparts also accept `save_to`, with the same auto-naming behavior as the V1 methods above.
+
+The async client mirrors this entire surface: `AsyncLandingAIADE().v2.parse(...)`, `await client.v2.parse_jobs.wait(...)`, etc.
+
## Async usage
Simply import `AsyncLandingAIADE` instead of `LandingAIADE` and use `await` with each API call:
diff --git a/api.md b/api.md
index 31fd6b1..8d99aef 100644
--- a/api.md
+++ b/api.md
@@ -59,3 +59,63 @@ Methods:
- client.extract_jobs.create(\*\*params) -> ExtractJobCreateResponse
- client.extract_jobs.list(\*\*params) -> ExtractJobListResponse
- client.extract_jobs.get(job_id) -> ExtractJobGetResponse
+
+# V2
+
+The `client.v2` sub-client targets LandingAI's next-generation ADE gateway, which lives on its own host (`api.ade.[env].landing.ai`) rather than the V1 host (`api.va.[env].landing.ai`). It is **additive**: `client.v2.*` is a separate surface from the top-level `client.*` (V1) methods documented above, and using it does not change any V1 behavior. See the [README](README.md#v2-api) for environment selection and usage examples.
+
+`client.v2.parse_jobs` and `client.v2.extract_jobs` both return a single, unified `Job` shape, even though the underlying parse/extract job envelopes differ upstream -- `Job.raw` retains the full original envelope as an escape hatch for any field not surfaced on the typed model.
+
+Types:
+
+```python
+from landingai_ade.types.v2 import (
+ Job,
+ JobError,
+ JobStatus,
+ V2ExtractMetadata,
+ V2ExtractResult,
+ V2FileUploadResponse,
+ V2ParseBilling,
+ V2ParseMetadata,
+ V2ParseResponse,
+)
+```
+
+- Job -- unified job shape: `job_id`, `status` (JobStatus: `pending` / `processing` / `completed` / `failed` / `cancelled`), `created_at`, `completed_at`, `progress`, `result` (a `V2ParseResponse` for parse jobs, a `V2ExtractResult` for extract jobs, or `None` until completion), `error` (JobError), `raw` (the full original envelope as a `dict`), and the `.is_terminal` property.
+- V2ParseResponse -- `markdown`, `structure`, `grounding`, `metadata` (V2ParseMetadata, which nests V2ParseBilling). Loosely typed pending a published gateway schema; unknown fields are retained.
+- V2ExtractResult -- `extraction`, `extraction_metadata`, `markdown`, `metadata` (V2ExtractMetadata).
+- V2FileUploadResponse -- `file_ref`.
+
+Methods:
+
+- client.v2.parse(\*, document=..., document_url=..., model=..., options=..., password=..., save_to=...) -> V2ParseResponse
+
+ Synchronous parse. Provide exactly one of `document` (file) or `document_url`. Returns a `V2ParseResponse` on both full success (HTTP 200) and partial success (HTTP 206, where `result.metadata.failed_pages` lists unparsed pages). Raises `V2SyncTimeoutError` (from `landingai_ade.lib.v2_errors`) on a 504; use `parse_jobs` for long-running documents.
+
+- client.v2.parse_jobs.create(\*, document=..., document_url=..., model=..., options=..., password=..., output_save_url=..., priority=...) -> Job
+- client.v2.parse_jobs.get(job_id) -> Job
+- client.v2.parse_jobs.list(\*, page=..., page_size=..., status=...) -> JobList[Job]
+- client.v2.parse_jobs.wait(job_id, \*, timeout=600, poll_interval=None, raise_on_failure=False) -> Job
+
+ Blocks, polling `.get(job_id)` with exponential backoff, until the job reaches a terminal status. Raises `JobWaitTimeoutError` if `timeout` seconds elapse first, and `JobFailedError` if `raise_on_failure=True` and the terminal job carries an `error` (not simply every `failed`/`cancelled` status).
+
+- client.v2.extract(\*, schema, markdown=..., markdown_ref=..., markdown_url=..., model=..., strict=..., idempotency_key=..., save_to=...) -> V2ExtractResult
+
+ Synchronous extract. `schema` accepts a pydantic `BaseModel` subclass, a `dict`, or a JSON-encoded string -- all are coerced to a JSON Schema object. Provide exactly one of `markdown`, `markdown_ref` (e.g. from `client.v2.files.upload`), or `markdown_url`. `strict=True` rejects schemas with unsupported fields (HTTP 422) instead of silently pruning them. Raises `V2SyncTimeoutError` on a 504; use `extract_jobs` for long-running documents.
+
+- client.v2.extract_jobs.create(\*, schema, markdown=..., markdown_ref=..., markdown_url=..., model=..., strict=..., idempotency_key=..., priority=...) -> Job
+- client.v2.extract_jobs.get(job_id) -> Job
+- client.v2.extract_jobs.list(\*, page=..., page_size=..., status=...) -> JobList[Job]
+- client.v2.extract_jobs.wait(job_id, \*, timeout=600, poll_interval=None, raise_on_failure=False) -> Job
+
+ Same polling/timeout semantics as `parse_jobs.wait`. Extract jobs have no `cancelled` status, so `raise_on_failure` only ever triggers on `failed`.
+
+- client.v2.files.upload(\*, file) -> str
+
+ Stages a file's bytes on the ADE data plane and returns a `file_ref` string, which can be passed as `markdown_ref` to `client.v2.extract`/`client.v2.extract_jobs.create`. Served on the ADE host under `/v1/files` (not `/v2/...`). Raises `LandingAiadeError` if the response has no `file_ref`.
+
+Notes:
+
+- `parse_jobs.list` / `extract_jobs.list` both return a `JobList` (a `list[Job]` subclass) carrying pagination metadata: `.has_more`, `.org_id`, `.page`, `.page_size`.
+- All `client.v2.*` methods accept the usual `extra_headers`, `extra_query`, `extra_body`, and `timeout` overrides; sync methods additionally accept `save_to` (parse/extract only, not the job-creation methods) to write the response to disk, mirroring V1's `save_to`.
diff --git a/examples/v2_extract.py b/examples/v2_extract.py
new file mode 100644
index 0000000..957e52e
--- /dev/null
+++ b/examples/v2_extract.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env -S rye run python
+"""Runnable example: extract structured data with the V2 (client.v2) sub-client,
+using a pydantic model directly as the extraction schema.
+
+This targets the ADE gateway host (api.ade.[env].landing.ai), separate from the
+V1 host used by client.extract(). It is purely additive -- V1 usage elsewhere
+in this SDK is unaffected.
+
+Set VISION_AGENT_API_KEY (and optionally LANDINGAI_ADE_ENVIRONMENT) before running:
+
+ export VISION_AGENT_API_KEY="My Apikey"
+ export LANDINGAI_ADE_ENVIRONMENT="staging" # optional; defaults to "production"
+ ./examples/v2_extract.py
+"""
+
+from __future__ import annotations
+
+from pydantic import Field, BaseModel
+
+from landingai_ade import LandingAIADE
+from landingai_ade.types.v2 import V2ExtractResult
+
+
+class Invoice(BaseModel):
+ total: str = Field(description="Invoice grand total")
+ vendor: str = Field(description="Name of the vendor issuing the invoice")
+
+
+# apikey is read from VISION_AGENT_API_KEY; environment from LANDINGAI_ADE_ENVIRONMENT
+# (or pass apikey=..., environment=... explicitly).
+client = LandingAIADE()
+
+# `schema` accepts a pydantic BaseModel subclass directly -- it's coerced to a
+# JSON Schema dict for you. A dict or a JSON-encoded string also work.
+result = client.v2.extract(
+ schema=Invoice,
+ markdown_url="https://example.com/invoice.md",
+ strict=False, # prune unsupported schema fields instead of raising a 422
+)
+print(result.extraction)
+
+# Long-running extractions can instead go through the async jobs route:
+job = client.v2.extract_jobs.create(schema=Invoice, markdown_url="https://example.com/invoice.md")
+done = client.v2.extract_jobs.wait(job.job_id, timeout=600)
+if isinstance(done.result, V2ExtractResult):
+ print(done.result.extraction)
+else:
+ print(f"No result available (error={done.error})")
diff --git a/examples/v2_parse.py b/examples/v2_parse.py
new file mode 100644
index 0000000..8a04783
--- /dev/null
+++ b/examples/v2_parse.py
@@ -0,0 +1,43 @@
+#!/usr/bin/env -S rye run python
+"""Runnable example: parse a document with the V2 (client.v2) sub-client.
+
+This targets the ADE gateway host (api.ade.[env].landing.ai), separate from the
+V1 host used by client.parse(). It is purely additive -- V1 usage elsewhere in
+this SDK is unaffected.
+
+Set VISION_AGENT_API_KEY (and optionally LANDINGAI_ADE_ENVIRONMENT) before running:
+
+ export VISION_AGENT_API_KEY="My Apikey"
+ export LANDINGAI_ADE_ENVIRONMENT="staging" # optional; defaults to "production"
+ ./examples/v2_parse.py
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from landingai_ade import LandingAIADE
+from landingai_ade.types.v2 import V2ParseResponse
+
+# apikey is read from VISION_AGENT_API_KEY; environment from LANDINGAI_ADE_ENVIRONMENT
+# (or pass apikey=..., environment=... explicitly).
+client = LandingAIADE()
+
+document_path = Path(__file__).parent / "sample.pdf"
+
+# Create an async parse job for a (potentially large) document.
+job = client.v2.parse_jobs.create(document=document_path, priority="priority")
+print(f"Created parse job {job.job_id} (status={job.status})")
+
+# Block until the job reaches a terminal state.
+done = client.v2.parse_jobs.wait(job.job_id, timeout=600)
+print(f"Job finished with status={done.status}")
+
+if isinstance(done.result, V2ParseResponse):
+ print(done.result.markdown[:200] if done.result.markdown else None)
+else:
+ print(f"No result available (error={done.error})")
+
+# For smaller documents you can skip the job/wait dance entirely:
+sync_result = client.v2.parse(document=document_path)
+print(sync_result.markdown[:200] if sync_result.markdown else None)
diff --git a/examples/v2_smoke_test.py b/examples/v2_smoke_test.py
new file mode 100755
index 0000000..1d6b366
--- /dev/null
+++ b/examples/v2_smoke_test.py
@@ -0,0 +1,263 @@
+#!/usr/bin/env -S rye run python
+"""Manual smoke test for the V2 (`client.v2`) endpoints against a live environment.
+
+This drives every V2 surface end-to-end so you can confirm auth, routing, and the
+response shapes against a real gateway. It is a manual/QA tool — not part of the
+automated test suite (which uses mocked transports).
+
+This is a *live* test that can hit real endpoints (and consume credits), so — unlike
+the client itself, which defaults to `production` when no environment is configured —
+this script defaults to `staging` when neither `--environment` nor
+`LANDINGAI_ADE_ENVIRONMENT` is set. Pass `--environment production` (or set
+`LANDINGAI_ADE_ENVIRONMENT=production`) explicitly if you really want production.
+
+Setup
+-----
+ export VISION_AGENT_API_KEY=
+ # optional: override the target environment (script default: staging).
+ # V2 lives on api.ade..landing.ai
+ export LANDINGAI_ADE_ENVIRONMENT=staging
+
+Run
+---
+ ./examples/v2_smoke_test.py # extract + files (no document needed)
+ ./examples/v2_smoke_test.py --document ./sample.pdf # + parse (sync & job)
+ ./examples/v2_smoke_test.py --document-url https://.../sample.pdf
+ ./examples/v2_smoke_test.py --only extract,extract_jobs # run a subset
+ ./examples/v2_smoke_test.py --environment dev --async # dev env, async client
+
+Exit code is non-zero if any selected check failed, so it is CI-friendly too.
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+import asyncio
+import argparse
+import traceback
+from typing import Any, List, Callable, Optional
+from pathlib import Path
+
+from pydantic import Field, BaseModel
+
+from landingai_ade import LandingAIADE, AsyncLandingAIADE
+
+ALL_CHECKS = ["files", "extract", "extract_jobs", "parse", "parse_jobs"]
+
+# A tiny self-contained markdown document + schema so extract/files can run without any file.
+SAMPLE_MARKDOWN = "# Acme Inc. — Q1 Report\n\nTotal revenue for the quarter was **$1,250,000**.\n"
+
+
+class RevenueSchema(BaseModel):
+ """Demonstrates passing a pydantic model as the extract schema."""
+
+ revenue: str = Field(description="The total revenue figure, verbatim")
+ company: str = Field(description="The company name")
+
+
+def _short(value: Any, limit: int = 200) -> str:
+ text = repr(value)
+ return text if len(text) <= limit else text[:limit] + "…"
+
+
+def _make_parser() -> argparse.ArgumentParser:
+ p = argparse.ArgumentParser(description="Live smoke test for client.v2 endpoints.")
+ p.add_argument(
+ "--environment",
+ default=None,
+ help="production|eu|staging|dev (else LANDINGAI_ADE_ENVIRONMENT; this script defaults to staging if neither is set)",
+ )
+ p.add_argument("--document", type=Path, default=None, help="Local document (PDF/image) for the parse checks")
+ p.add_argument("--document-url", default=None, help="Public document URL for the parse checks")
+ p.add_argument("--only", default=None, help=f"Comma-separated subset of: {','.join(ALL_CHECKS)}")
+ p.add_argument("--parse-model", default=None, help="Override parse model (e.g. dpt-3-latest)")
+ p.add_argument("--extract-model", default=None, help="Override extract model (e.g. extract-latest)")
+ p.add_argument("--timeout", type=float, default=600.0, help="wait() timeout for job checks (seconds)")
+ p.add_argument("--use-async", "--async", dest="use_async", action="store_true", help="Exercise the async client")
+ return p
+
+
+def _resolve_environment(args: argparse.Namespace) -> Optional[str]:
+ """Pick the target environment, defaulting this *live* smoke test to `staging`.
+
+ The client itself defaults to `production` when no environment is configured,
+ but that's the wrong default for a script that can hit real endpoints and
+ consume credits. Only fall back to `staging` when the caller hasn't set
+ either `--environment` or `LANDINGAI_ADE_ENVIRONMENT`.
+ """
+ explicit_environment: Optional[str] = args.environment
+ if explicit_environment:
+ return explicit_environment
+ if os.environ.get("LANDINGAI_ADE_ENVIRONMENT"):
+ return None # let the client pick it up from the env var
+ return "staging"
+
+
+def _selected(only: Optional[str]) -> List[str]:
+ if not only:
+ return ALL_CHECKS
+ chosen = [c.strip() for c in only.split(",") if c.strip()]
+ bad = [c for c in chosen if c not in ALL_CHECKS]
+ if bad:
+ raise SystemExit(f"Unknown check(s): {bad}. Valid: {ALL_CHECKS}")
+ return chosen
+
+
+# --------------------------------------------------------------------------- sync
+
+def run_sync(args: argparse.Namespace, checks: List[str]) -> int:
+ kwargs: dict[str, Any] = {}
+ environment = _resolve_environment(args)
+ if environment:
+ kwargs["environment"] = environment
+ client = LandingAIADE(**kwargs)
+ print(f"V1 base: {client.base_url} | V2 base: {client._v2_base_url}\n")
+
+ results: dict[str, str] = {}
+ file_ref: Optional[str] = None
+
+ def record(name: str, fn: Callable[[], Any]) -> None:
+ print(f"── {name} ".ljust(60, "─"))
+ try:
+ out = fn()
+ results[name] = "PASS"
+ print(f" PASS {_short(out)}\n")
+ except Exception as exc: # noqa: BLE001 - this is a diagnostic harness
+ results[name] = "FAIL"
+ print(f" FAIL {type(exc).__name__}: {exc}")
+ traceback.print_exc()
+ print()
+
+ if "files" in checks:
+ def _files() -> Any:
+ nonlocal file_ref
+ file_ref = client.v2.files.upload(file=("doc.md", SAMPLE_MARKDOWN.encode(), "text/markdown"))
+ return f"file_ref={file_ref}"
+
+ record("files.upload", _files)
+
+ if "extract" in checks:
+ def _extract() -> Any:
+ res = client.v2.extract(
+ schema=RevenueSchema,
+ markdown=SAMPLE_MARKDOWN,
+ model=args.extract_model or None, # type: ignore[arg-type]
+ )
+ return f"extraction={res.extraction} version={res.metadata.version}"
+
+ record("v2.extract (sync)", _extract)
+
+ if "extract_jobs" in checks:
+ def _extract_jobs() -> Any:
+ job = client.v2.extract_jobs.create(schema=RevenueSchema, markdown=SAMPLE_MARKDOWN)
+ done = client.v2.extract_jobs.wait(job.job_id, timeout=args.timeout)
+ return f"job={done.job_id} status={done.status} result={'set' if done.result else 'none'}"
+
+ record("v2.extract_jobs (create+wait)", _extract_jobs)
+
+ doc_kwargs = _document_kwargs(args)
+ if "parse" in checks:
+ if doc_kwargs is None:
+ print("── v2.parse (sync) ".ljust(60, "─") + "\n SKIP (no --document / --document-url)\n")
+ results["v2.parse (sync)"] = "SKIP"
+ else:
+ record("v2.parse (sync)", lambda: _short(client.v2.parse(model=args.parse_model or None, **doc_kwargs).markdown)) # type: ignore[arg-type]
+
+ if "parse_jobs" in checks:
+ if doc_kwargs is None:
+ print("── v2.parse_jobs ".ljust(60, "─") + "\n SKIP (no --document / --document-url)\n")
+ results["v2.parse_jobs"] = "SKIP"
+ else:
+ def _parse_jobs() -> Any:
+ job = client.v2.parse_jobs.create(**doc_kwargs)
+ done = client.v2.parse_jobs.wait(job.job_id, timeout=args.timeout)
+ return f"job={done.job_id} status={done.status}"
+
+ record("v2.parse_jobs (create+wait)", _parse_jobs)
+
+ return _summarize(results)
+
+
+def _document_kwargs(args: argparse.Namespace) -> Optional[dict[str, Any]]:
+ if args.document is not None:
+ return {"document": args.document}
+ if args.document_url:
+ return {"document_url": args.document_url}
+ return None
+
+
+# -------------------------------------------------------------------------- async
+
+def run_async(args: argparse.Namespace, checks: List[str]) -> int:
+ async def _main() -> int:
+ kwargs: dict[str, Any] = {}
+ environment = _resolve_environment(args)
+ if environment:
+ kwargs["environment"] = environment
+ client = AsyncLandingAIADE(**kwargs)
+ print(f"[async] V1 base: {client.base_url} | V2 base: {client._v2_base_url}\n")
+ results: dict[str, str] = {}
+
+ if "files" in checks:
+ print("── [async] files.upload ".ljust(60, "─"))
+ try:
+ ref = await client.v2.files.upload(file=("doc.md", SAMPLE_MARKDOWN.encode(), "text/markdown"))
+ results["files.upload"] = "PASS"
+ print(f" PASS file_ref={ref}\n")
+ except Exception as exc: # noqa: BLE001
+ results["files.upload"] = "FAIL"
+ print(f" FAIL {type(exc).__name__}: {exc}\n")
+
+ if "extract" in checks:
+ print("── [async] v2.extract ".ljust(60, "─"))
+ try:
+ res = await client.v2.extract(schema=RevenueSchema, markdown=SAMPLE_MARKDOWN)
+ results["v2.extract"] = "PASS"
+ print(f" PASS extraction={res.extraction}\n")
+ except Exception as exc: # noqa: BLE001
+ results["v2.extract"] = "FAIL"
+ print(f" FAIL {type(exc).__name__}: {exc}\n")
+
+ if "extract_jobs" in checks:
+ print("── [async] v2.extract_jobs ".ljust(60, "─"))
+ try:
+ job = await client.v2.extract_jobs.create(schema=RevenueSchema, markdown=SAMPLE_MARKDOWN)
+ done = await client.v2.extract_jobs.wait(job.job_id, timeout=args.timeout)
+ results["v2.extract_jobs"] = "PASS"
+ print(f" PASS job={done.job_id} status={done.status}\n")
+ except Exception as exc: # noqa: BLE001
+ results["v2.extract_jobs"] = "FAIL"
+ print(f" FAIL {type(exc).__name__}: {exc}\n")
+
+ await client.close()
+ return _summarize(results)
+
+ return asyncio.run(_main())
+
+
+# ------------------------------------------------------------------------- shared
+
+def _summarize(results: dict[str, str]) -> int:
+ print("═" * 60)
+ for name, status in results.items():
+ print(f" {status:5} {name}")
+ failed = [n for n, s in results.items() if s == "FAIL"]
+ print("═" * 60)
+ print(f"{len(failed)} failed / {len(results)} run")
+ return 1 if failed else 0
+
+
+def main() -> int:
+ args = _make_parser().parse_args()
+ checks = _selected(args.only)
+ if args.use_async:
+ async_checks = [c for c in checks if c in {"files", "extract", "extract_jobs"}]
+ if async_checks != checks:
+ print("note: --async runs files/extract/extract_jobs only (parse checks are sync-only here)\n")
+ return run_async(args, async_checks)
+ return run_sync(args, checks)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/landingai_ade/_client.py b/src/landingai_ade/_client.py
index 4f51920..5c6aadc 100644
--- a/src/landingai_ade/_client.py
+++ b/src/landingai_ade/_client.py
@@ -68,6 +68,7 @@
if TYPE_CHECKING:
from .resources import parse_jobs, extract_jobs
+ from .resources.v2 import V2Resource, AsyncV2Resource
from .resources.parse_jobs import ParseJobsResource, AsyncParseJobsResource
from .resources.extract_jobs import ExtractJobsResource, AsyncExtractJobsResource
_LIB_VERSION = importlib.metadata.version("landingai-ade")
@@ -87,6 +88,15 @@
ENVIRONMENTS: Dict[str, str] = {
"production": "https://api.va.landing.ai",
"eu": "https://api.va.eu-west-1.landing.ai",
+ "staging": "https://api.va.staging.landing.ai",
+ "dev": "https://api.va.dev.landing.ai",
+}
+
+V2_ENVIRONMENTS: Dict[str, str] = {
+ "production": "https://api.ade.landing.ai",
+ "eu": "https://api.ade.eu-west-1.landing.ai",
+ "staging": "https://api.ade.staging.landing.ai",
+ "dev": "https://api.ade.dev.landing.ai",
}
@@ -148,18 +158,51 @@ def _save_response(
raise LandingAiadeError(f"Failed to save {method_name} response to {save_to}: {exc}") from exc
+def _resolve_v2_base_url(
+ environment: str | NotGiven,
+ v2_base_url: str | None | NotGiven,
+ resolved_v1_base_url: str | httpx.URL,
+ v1_base_url_was_explicit: bool,
+) -> str:
+ """Resolve the V2/ADE host, no trailing slash.
+
+ Precedence: explicit v2_base_url param > LANDINGAI_ADE_V2_BASE_URL env >
+ environment map > (if only V1 base_url was set explicitly) follow it >
+ production default.
+ """
+ if is_given(v2_base_url) and v2_base_url is not None:
+ return str(v2_base_url).rstrip("/")
+
+ env_override = os.environ.get("LANDINGAI_ADE_V2_BASE_URL")
+ if env_override:
+ return env_override.rstrip("/")
+
+ if is_given(environment):
+ try:
+ return V2_ENVIRONMENTS[environment]
+ except KeyError as exc:
+ raise ValueError(f"Unknown environment: {environment}") from exc
+
+ if v1_base_url_was_explicit:
+ # Only a V1 base_url was provided (mock/proxy) — route V2 to it too.
+ return str(resolved_v1_base_url).rstrip("/")
+
+ return V2_ENVIRONMENTS["production"]
+
+
class LandingAIADE(SyncAPIClient):
# client options
apikey: str
- _environment: Literal["production", "eu"] | NotGiven
+ _environment: Literal["production", "eu", "staging", "dev"] | NotGiven
def __init__(
self,
*,
apikey: str | None = None,
- environment: Literal["production", "eu"] | NotGiven = not_given,
+ environment: Literal["production", "eu", "staging", "dev"] | NotGiven = not_given,
base_url: str | httpx.URL | None | NotGiven = not_given,
+ v2_base_url: str | None | NotGiven = not_given,
timeout: float | Timeout | None | NotGiven = not_given,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
@@ -190,9 +233,15 @@ def __init__(
)
self.apikey = apikey
- self._environment = environment
+ if not is_given(environment):
+ env_name = os.environ.get("LANDINGAI_ADE_ENVIRONMENT")
+ if env_name:
+ environment = cast(Any, env_name)
+
+ self._environment = environment # type: ignore[assignment]
base_url_env = os.environ.get("LANDINGAI_ADE_BASE_URL")
+ v1_base_url_was_explicit = is_given(base_url) and base_url is not None
if is_given(base_url) and base_url is not None:
# cast required because mypy doesn't understand the type narrowing
base_url = cast("str | httpx.URL", base_url) # pyright: ignore[reportUnnecessaryCast]
@@ -216,6 +265,11 @@ def __init__(
except KeyError as exc:
raise ValueError(f"Unknown environment: {environment}") from exc
+ v1_base_url_was_explicit = v1_base_url_was_explicit or (
+ base_url_env is not None and not is_given(environment)
+ )
+ self._v2_base_url = _resolve_v2_base_url(environment, v2_base_url, base_url, v1_base_url_was_explicit)
+
super().__init__(
version=__version__,
base_url=base_url,
@@ -239,6 +293,12 @@ def extract_jobs(self) -> ExtractJobsResource:
return ExtractJobsResource(self)
+ @cached_property
+ def v2(self) -> V2Resource:
+ from .resources.v2 import V2Resource
+
+ return V2Resource(self)
+
@cached_property
def with_raw_response(self) -> LandingAIADEWithRawResponse:
return LandingAIADEWithRawResponse(self)
@@ -271,8 +331,9 @@ def copy(
self,
*,
apikey: str | None = None,
- environment: Literal["production", "eu"] | None = None,
+ environment: Literal["production", "eu", "staging", "dev"] | None = None,
base_url: str | httpx.URL | None = None,
+ v2_base_url: str | None = None,
timeout: float | Timeout | None | NotGiven = not_given,
http_client: httpx.Client | None = None,
max_retries: int | NotGiven = not_given,
@@ -307,6 +368,7 @@ def copy(
return self.__class__(
apikey=apikey or self.apikey,
base_url=base_url or self.base_url,
+ v2_base_url=v2_base_url or self._v2_base_url,
environment=environment or self._environment,
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
http_client=http_client,
@@ -848,14 +910,15 @@ class AsyncLandingAIADE(AsyncAPIClient):
# client options
apikey: str
- _environment: Literal["production", "eu"] | NotGiven
+ _environment: Literal["production", "eu", "staging", "dev"] | NotGiven
def __init__(
self,
*,
apikey: str | None = None,
- environment: Literal["production", "eu"] | NotGiven = not_given,
+ environment: Literal["production", "eu", "staging", "dev"] | NotGiven = not_given,
base_url: str | httpx.URL | None | NotGiven = not_given,
+ v2_base_url: str | None | NotGiven = not_given,
timeout: float | Timeout | None | NotGiven = not_given,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
@@ -886,9 +949,15 @@ def __init__(
)
self.apikey = apikey
- self._environment = environment
+ if not is_given(environment):
+ env_name = os.environ.get("LANDINGAI_ADE_ENVIRONMENT")
+ if env_name:
+ environment = cast(Any, env_name)
+
+ self._environment = environment # type: ignore[assignment]
base_url_env = os.environ.get("LANDINGAI_ADE_BASE_URL")
+ v1_base_url_was_explicit = is_given(base_url) and base_url is not None
if is_given(base_url) and base_url is not None:
# cast required because mypy doesn't understand the type narrowing
base_url = cast("str | httpx.URL", base_url) # pyright: ignore[reportUnnecessaryCast]
@@ -912,6 +981,11 @@ def __init__(
except KeyError as exc:
raise ValueError(f"Unknown environment: {environment}") from exc
+ v1_base_url_was_explicit = v1_base_url_was_explicit or (
+ base_url_env is not None and not is_given(environment)
+ )
+ self._v2_base_url = _resolve_v2_base_url(environment, v2_base_url, base_url, v1_base_url_was_explicit)
+
super().__init__(
version=__version__,
base_url=base_url,
@@ -935,6 +1009,12 @@ def extract_jobs(self) -> AsyncExtractJobsResource:
return AsyncExtractJobsResource(self)
+ @cached_property
+ def v2(self) -> AsyncV2Resource:
+ from .resources.v2 import AsyncV2Resource
+
+ return AsyncV2Resource(self)
+
@cached_property
def with_raw_response(self) -> AsyncLandingAIADEWithRawResponse:
return AsyncLandingAIADEWithRawResponse(self)
@@ -967,8 +1047,9 @@ def copy(
self,
*,
apikey: str | None = None,
- environment: Literal["production", "eu"] | None = None,
+ environment: Literal["production", "eu", "staging", "dev"] | None = None,
base_url: str | httpx.URL | None = None,
+ v2_base_url: str | None = None,
timeout: float | Timeout | None | NotGiven = not_given,
http_client: httpx.AsyncClient | None = None,
max_retries: int | NotGiven = not_given,
@@ -1003,6 +1084,7 @@ def copy(
return self.__class__(
apikey=apikey or self.apikey,
base_url=base_url or self.base_url,
+ v2_base_url=v2_base_url or self._v2_base_url,
environment=environment or self._environment,
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
http_client=http_client,
diff --git a/src/landingai_ade/_compat.py b/src/landingai_ade/_compat.py
index e6690a4..f8045aa 100644
--- a/src/landingai_ade/_compat.py
+++ b/src/landingai_ade/_compat.py
@@ -91,9 +91,16 @@ def field_is_required(field: FieldInfo) -> bool:
def field_get_default(field: FieldInfo) -> Any:
- value = field.get_default()
if PYDANTIC_V1:
- return value
+ return field.get_default()
+
+ # call_default_factory=True: this is used on the construct()/model_construct()
+ # fast path, which is exactly when pydantic v2 says the factory should be
+ # invoked (see FieldInfo.get_default's docstring). Without it, fields declared
+ # with `default_factory=...` (e.g. `Field(default_factory=dict)`) resolve to
+ # None instead of a fresh value, which both breaks the field's type and can
+ # share mutable state across instances if callers work around the `None`.
+ value = field.get_default(call_default_factory=True)
from pydantic_core import PydanticUndefined
if value == PydanticUndefined:
diff --git a/src/landingai_ade/lib/__init__.py b/src/landingai_ade/lib/__init__.py
index f84979e..6faabc3 100644
--- a/src/landingai_ade/lib/__init__.py
+++ b/src/landingai_ade/lib/__init__.py
@@ -4,8 +4,9 @@
This package contains custom functionality that extends the base Stainless-generated SDK.
"""
-from .schema_utils import pydantic_to_json_schema
+from .schema_utils import coerce_schema_to_dict, pydantic_to_json_schema
__all__ = [
"pydantic_to_json_schema",
+ "coerce_schema_to_dict",
]
diff --git a/src/landingai_ade/lib/schema_utils.py b/src/landingai_ade/lib/schema_utils.py
index df25547..546162e 100644
--- a/src/landingai_ade/lib/schema_utils.py
+++ b/src/landingai_ade/lib/schema_utils.py
@@ -7,10 +7,35 @@
import copy
import json
-from typing import Any, Dict, Type
+from typing import Any, Dict, Type, Union, Mapping, cast
from pydantic import BaseModel
+from .._compat import PYDANTIC_V1
+
+
+def _model_json_schema(model: Type[BaseModel]) -> Dict[str, Any]:
+ """Return a model's JSON schema, keyed the same way regardless of pydantic major.
+
+ Pydantic v2 exposes `model_json_schema()` and nests shared definitions under
+ `$defs`; pydantic v1 only has `.schema()` and nests them under `definitions`.
+ We normalize to `$defs` here so callers (and `_resolve_refs`) don't need to
+ know which pydantic major produced the schema.
+ """
+ if PYDANTIC_V1:
+ # pydantic v1 memoizes `.schema()` per class (`cls.__schema_cache__`) and
+ # returns the SAME shared mutable dict on every call. Callers of this
+ # function mutate the returned schema (e.g. popping "$defs"), so we must
+ # deep-copy before any mutation to avoid corrupting the class-level cache.
+ schema = copy.deepcopy(model.schema()) # type: ignore[attr-defined]
+ if "definitions" in schema:
+ schema["$defs"] = schema.pop("definitions")
+ return schema
+ # pydantic v2's `model_json_schema()` returns a fresh dict per call, but we
+ # deep-copy unconditionally to be defensive/future-proof and keep both code
+ # paths behaving identically.
+ return copy.deepcopy(model.model_json_schema())
+
def _resolve_refs(obj: Any, defs: Dict[str, Any]) -> Any:
"""
@@ -65,10 +90,42 @@ def pydantic_to_json_schema(model: Type[BaseModel]) -> str:
"""
# The type annotation already ensures model is Type[BaseModel]
# but we'll do a runtime check for safety
- if not hasattr(model, "model_json_schema"):
+ if not (
+ isinstance(model, type) # pyright: ignore[reportUnnecessaryIsInstance]
+ and issubclass(model, BaseModel) # pyright: ignore[reportUnnecessaryIsInstance]
+ ):
raise TypeError("model must be a Pydantic BaseModel subclass")
- schema = model.model_json_schema()
+ schema = _model_json_schema(model)
defs = schema.pop("$defs", {})
schema = _resolve_refs(schema, defs)
return json.dumps(schema)
+
+
+def pydantic_to_schema_dict(model: Type[BaseModel]) -> Dict[str, Any]:
+ """Like `pydantic_to_json_schema` but returns a dict with $refs resolved."""
+ if not (
+ isinstance(model, type) # pyright: ignore[reportUnnecessaryIsInstance]
+ and issubclass(model, BaseModel) # pyright: ignore[reportUnnecessaryIsInstance]
+ ):
+ raise TypeError("model must be a Pydantic BaseModel subclass")
+ schema = _model_json_schema(model)
+ defs = schema.pop("$defs", {})
+ return cast(Dict[str, Any], _resolve_refs(schema, defs))
+
+
+def coerce_schema_to_dict(schema: Union[str, Mapping[str, Any], Type[BaseModel]]) -> Dict[str, Any]:
+ """Accept a pydantic model class, a dict, or a JSON string; return a JSON-Schema dict.
+
+ The V2 extract endpoint takes `schema` as a JSON object in the request body.
+ """
+ if isinstance(schema, type) and issubclass(schema, BaseModel): # pyright: ignore[reportUnnecessaryIsInstance]
+ return pydantic_to_schema_dict(schema)
+ if isinstance(schema, Mapping):
+ return dict(schema)
+ if isinstance(schema, str): # pyright: ignore[reportUnnecessaryIsInstance]
+ parsed: Any = json.loads(schema) # raises ValueError on bad JSON
+ if not isinstance(parsed, dict):
+ raise TypeError("schema JSON string must decode to an object")
+ return cast(Dict[str, Any], parsed)
+ raise TypeError(f"Unsupported schema type: {type(schema)!r}")
diff --git a/src/landingai_ade/lib/v2_errors.py b/src/landingai_ade/lib/v2_errors.py
new file mode 100644
index 0000000..2c72747
--- /dev/null
+++ b/src/landingai_ade/lib/v2_errors.py
@@ -0,0 +1,36 @@
+# src/landingai_ade/lib/v2_errors.py
+from __future__ import annotations
+
+from .._exceptions import APIStatusError, LandingAiadeError
+
+__all__ = [
+ "V2SyncTimeoutError",
+ "JobWaitTimeoutError",
+ "JobFailedError",
+ "raise_if_sync_timeout",
+]
+
+
+class V2SyncTimeoutError(LandingAiadeError):
+ """A synchronous /v2/parse or /v2/extract call exceeded the server wait window (504).
+
+ The server cancels the work; use the async jobs route
+ (`client.v2.parse_jobs.create(...)` / `client.v2.extract_jobs.create(...)`, then
+ `.wait(...)`) for long-running documents."""
+
+
+class JobWaitTimeoutError(LandingAiadeError):
+ """`wait()` gave up before the job reached a terminal state."""
+
+
+class JobFailedError(LandingAiadeError):
+ """A job reached a terminal `failed`/`cancelled` state (raise_on_failure=True)."""
+
+
+def raise_if_sync_timeout(exc: APIStatusError) -> None:
+ if exc.response.status_code == 504:
+ raise V2SyncTimeoutError(
+ "The synchronous request timed out (HTTP 504). The server cancels the work on "
+ "timeout — use the async jobs route (`client.v2.parse_jobs.create(...)` / "
+ "`client.v2.extract_jobs.create(...)`, then `.wait(...)`) for long-running documents."
+ ) from exc
diff --git a/src/landingai_ade/resources/v2/__init__.py b/src/landingai_ade/resources/v2/__init__.py
new file mode 100644
index 0000000..4888d25
--- /dev/null
+++ b/src/landingai_ade/resources/v2/__init__.py
@@ -0,0 +1,2 @@
+# v2 resources package
+from .v2 import V2Resource as V2Resource, AsyncV2Resource as AsyncV2Resource
diff --git a/src/landingai_ade/resources/v2/_base.py b/src/landingai_ade/resources/v2/_base.py
new file mode 100644
index 0000000..0b3cef3
--- /dev/null
+++ b/src/landingai_ade/resources/v2/_base.py
@@ -0,0 +1,132 @@
+# src/landingai_ade/resources/v2/_base.py
+from __future__ import annotations
+
+from typing import Any, List, Callable, Optional, Awaitable
+
+import anyio
+
+from ...types.v2 import Job
+from ...lib.v2_errors import JobFailedError, JobWaitTimeoutError
+
+__all__ = [
+ "V2ResourceMixin",
+ "JobList",
+ "poll_until_terminal",
+ "apoll_until_terminal",
+ "DEFAULT_POLL_INITIAL",
+ "DEFAULT_POLL_MAX",
+ "DEFAULT_POLL_FACTOR",
+ "DEFAULT_WAIT_TIMEOUT",
+]
+
+DEFAULT_POLL_INITIAL = 1.0
+DEFAULT_POLL_MAX = 10.0
+DEFAULT_POLL_FACTOR = 1.5
+DEFAULT_WAIT_TIMEOUT = 600.0
+
+
+class V2ResourceMixin:
+ """Shared helpers for V2 sub-resources, which target an absolute (non-V1) host.
+
+ ``_client`` is provided by whichever of ``SyncAPIResource``/``AsyncAPIResource``
+ this mixes in alongside; it is declared here as ``Any`` only so this mixin's own
+ methods type-check in isolation.
+ """
+
+ _client: Any # LandingAIADE | AsyncLandingAIADE
+
+ def _v2_url(self, path: str) -> str:
+ return f"{self._client._v2_base_url}{path}"
+
+
+def _next_delay(current: float, poll_interval: Optional[float]) -> float:
+ if poll_interval is not None:
+ return poll_interval
+ return min(current * DEFAULT_POLL_FACTOR, DEFAULT_POLL_MAX)
+
+
+def _raise_if_failed(job: Job, *, raise_on_failure: bool) -> None:
+ if raise_on_failure and job.error is not None:
+ raise JobFailedError(
+ f"Job {job.job_id} ended {job.status.value}: "
+ f"{job.error.message or job.error.code or 'unknown error'}"
+ )
+
+
+def poll_until_terminal(
+ get_job: Callable[[], Job],
+ *,
+ monotonic: Callable[[], float],
+ sleep: Callable[[float], None],
+ timeout: float,
+ poll_interval: Optional[float],
+ raise_on_failure: bool,
+) -> Job:
+ """Poll ``get_job`` with backoff until the job reaches a terminal state.
+
+ ``monotonic``/``sleep`` are injected (rather than reading ``time`` directly) so
+ tests can drive a fake clock without real time passing; production callers pass
+ ``time.monotonic``/``time.sleep``.
+ """
+ deadline = monotonic() + timeout
+ delay = poll_interval if poll_interval is not None else DEFAULT_POLL_INITIAL
+ while True:
+ job = get_job()
+ if job.is_terminal:
+ _raise_if_failed(job, raise_on_failure=raise_on_failure)
+ return job
+ if monotonic() >= deadline:
+ raise JobWaitTimeoutError(
+ f"Job {job.job_id} did not finish within {timeout}s (last status: {job.status.value})."
+ )
+ sleep(min(delay, max(0.0, deadline - monotonic())))
+ delay = _next_delay(delay, poll_interval)
+
+
+async def apoll_until_terminal(
+ get_job: Callable[[], Awaitable[Job]],
+ *,
+ monotonic: Callable[[], float],
+ timeout: float,
+ poll_interval: Optional[float],
+ raise_on_failure: bool,
+) -> Job:
+ """Async mirror of :func:`poll_until_terminal`; sleeps via ``anyio.sleep``."""
+ deadline = monotonic() + timeout
+ delay = poll_interval if poll_interval is not None else DEFAULT_POLL_INITIAL
+ while True:
+ job = await get_job()
+ if job.is_terminal:
+ _raise_if_failed(job, raise_on_failure=raise_on_failure)
+ return job
+ if monotonic() >= deadline:
+ raise JobWaitTimeoutError(
+ f"Job {job.job_id} did not finish within {timeout}s (last status: {job.status.value})."
+ )
+ await anyio.sleep(min(delay, max(0.0, deadline - monotonic())))
+ delay = _next_delay(delay, poll_interval)
+
+
+def _cast_opt_str(value: object) -> Optional[str]:
+ return value if isinstance(value, str) else None
+
+
+class JobList(List[Job]):
+ """A list of normalized jobs plus the pagination envelope (``has_more``, ``org_id``, ...)."""
+
+ has_more: bool = False
+ org_id: Optional[str] = None
+ page: Optional[int] = None
+ page_size: Optional[int] = None
+
+ @classmethod
+ def build(cls, jobs: List[Job], **envelope: object) -> "JobList":
+ out = cls(jobs)
+ hm = envelope.get("has_more")
+ out.has_more = hm if isinstance(hm, bool) else False
+ out.org_id = _cast_opt_str(envelope.get("org_id"))
+ page = envelope.get("page")
+ page_size = envelope.get("page_size")
+ out.page = page if isinstance(page, int) else None
+ out.page_size = page_size if isinstance(page_size, int) else None
+ return out
diff --git a/src/landingai_ade/resources/v2/_normalize.py b/src/landingai_ade/resources/v2/_normalize.py
new file mode 100644
index 0000000..fcf1524
--- /dev/null
+++ b/src/landingai_ade/resources/v2/_normalize.py
@@ -0,0 +1,88 @@
+# src/landingai_ade/resources/v2/_normalize.py
+from __future__ import annotations
+
+from typing import Any, Dict, Union, Mapping, Optional, cast
+from datetime import datetime
+
+from ..._types import StrBytesIntFloat
+from ..._utils import parse_datetime
+from ...types.v2 import Job, JobError, JobStatus, V2ExtractResult, V2ParseResponse
+
+__all__ = ["normalize_parse_job", "normalize_extract_job"]
+
+
+def _ts(value: Optional[Union[datetime, StrBytesIntFloat]]) -> Optional[datetime]:
+ if value is None:
+ return None
+ try:
+ return parse_datetime(value) # handles both epoch ints and ISO strings
+ except (ValueError, TypeError):
+ return None
+
+
+def _progress(value: object) -> Optional[float]:
+ if isinstance(value, (int, float)):
+ return float(value)
+ return None
+
+
+def _status(raw: Mapping[str, Any]) -> JobStatus:
+ value = raw.get("status")
+ if value is None:
+ return JobStatus.PENDING
+ try:
+ return JobStatus(value)
+ except ValueError:
+ # Unknown/renamed status from the gateway: don't crash the whole
+ # normalizer. The original raw status is still available via job.raw.
+ return JobStatus.PENDING
+
+
+def normalize_parse_job(raw: Mapping[str, Any]) -> Job:
+ status = _status(raw)
+ data = raw.get("data")
+ result = V2ParseResponse(**cast(Dict[str, Any], data)) if isinstance(data, Mapping) else None
+
+ error = None
+ reason = raw.get("failure_reason")
+ if reason:
+ error = JobError(message=str(reason))
+
+ created = raw.get("created_at")
+ created = created if created is not None else raw.get("received_at")
+
+ return Job(
+ job_id=str(raw["job_id"]),
+ status=status,
+ created_at=_ts(created),
+ completed_at=None, # parse envelope has no completed_at
+ progress=_progress(raw.get("progress")),
+ result=result,
+ error=error,
+ raw=dict(raw),
+ )
+
+
+def normalize_extract_job(raw: Mapping[str, Any]) -> Job:
+ status = _status(raw)
+ payload = raw.get("result")
+ result = V2ExtractResult(**cast(Dict[str, Any], payload)) if isinstance(payload, Mapping) else None
+
+ error = None
+ err = raw.get("error")
+ if isinstance(err, Mapping):
+ err = cast(Dict[str, Any], err)
+ error = JobError(code=err.get("code"), message=err.get("message"))
+ elif raw.get("failure_reason"): # extract *list* uses failure_reason
+ error = JobError(message=str(raw["failure_reason"]))
+
+ return Job(
+ job_id=str(raw["job_id"]),
+ status=status,
+ created_at=_ts(raw.get("created_at")),
+ completed_at=_ts(raw.get("completed_at")),
+ progress=_progress(raw.get("progress")),
+ result=result,
+ error=error,
+ raw=dict(raw),
+ )
diff --git a/src/landingai_ade/resources/v2/extract.py b/src/landingai_ade/resources/v2/extract.py
new file mode 100644
index 0000000..e7d5590
--- /dev/null
+++ b/src/landingai_ade/resources/v2/extract.py
@@ -0,0 +1,424 @@
+from __future__ import annotations
+
+import time
+from typing import Any, Dict, Type, Union, Mapping, Callable, Optional, cast
+from pathlib import Path
+from typing_extensions import Literal
+
+import httpx
+from pydantic import BaseModel
+
+from ._base import DEFAULT_WAIT_TIMEOUT, JobList, V2ResourceMixin, poll_until_terminal, apoll_until_terminal
+from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
+from ..._utils import is_given
+from ...types.v2 import Job, V2ExtractResult
+from ._normalize import normalize_extract_job
+from ..._resource import SyncAPIResource, AsyncAPIResource
+from ..._exceptions import APIStatusError
+from ..._base_client import make_request_options
+from ...lib.v2_errors import raise_if_sync_timeout
+from ...lib.schema_utils import coerce_schema_to_dict
+
+__all__ = ["ExtractResource", "AsyncExtractResource", "ExtractJobsResource", "AsyncExtractJobsResource"]
+
+
+def _build_extract_body(
+ schema: Union[str, Mapping[str, object], Type[BaseModel]],
+ markdown: object,
+ markdown_ref: object,
+ markdown_url: object,
+ model: object,
+ strict: object,
+ idempotency_key: object,
+ priority: object = omit,
+) -> Dict[str, Any]:
+ body: Dict[str, Any] = {"schema": coerce_schema_to_dict(schema)}
+ for key, value in (
+ ("markdown", markdown),
+ ("markdown_ref", markdown_ref),
+ ("markdown_url", markdown_url),
+ ("model", model),
+ ("idempotency_key", idempotency_key),
+ ("priority", priority),
+ ):
+ if value is not omit and value is not None:
+ body[key] = value
+ if strict is not omit and strict is not None:
+ body["options"] = {"strict": bool(strict)}
+ return body
+
+
+class ExtractResource(V2ResourceMixin, SyncAPIResource):
+ def run(
+ self,
+ *,
+ schema: Union[str, Mapping[str, object], Type[BaseModel]],
+ markdown: Optional[str] | Omit = omit,
+ markdown_ref: Optional[str] | Omit = omit,
+ markdown_url: Optional[str] | Omit = omit,
+ model: Optional[str] | Omit = omit,
+ strict: Optional[bool] | Omit = omit,
+ idempotency_key: Optional[str] | Omit = omit,
+ save_to: str | Path | None = None,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> V2ExtractResult:
+ """Extract structured data from markdown synchronously against the V2 (ADE)
+ `/v2/extract` endpoint (JSON body).
+
+ Raises `V2SyncTimeoutError` when the server times out the synchronous
+ request (HTTP 504); use the async jobs route for long-running documents
+ in that case.
+
+ Args:
+ schema: JSON schema for field extraction. Accepts a pydantic `BaseModel`
+ subclass, a dict, or a JSON-encoded string; it is coerced to a JSON
+ object and sent as `schema` in the request body.
+
+ markdown: Markdown content to extract data from.
+
+ markdown_ref: A reference (e.g. from a prior parse) to markdown content to
+ extract data from.
+
+ markdown_url: The URL to the markdown file to extract data from.
+
+ model: The version of the model to use for extraction.
+
+ strict: If True, reject schemas with unsupported fields (HTTP 422). If
+ False, prune unsupported fields and continue. Sent as
+ `options.strict`.
+
+ idempotency_key: An idempotency key for the request.
+
+ save_to: Optional output path. If a directory, auto-generates the filename
+ (e.g. {input_file}_extract_output.json, or extract_output.json when no
+ input filename is available). If a full path ending in .json, saves there
+ directly. Parent directories are created automatically.
+
+ extra_headers: Send extra headers
+
+ extra_query: Add additional query parameters to the request
+
+ extra_body: Add additional JSON properties to the request
+
+ timeout: Override the client-level default timeout for this request, in seconds
+ """
+ body = _build_extract_body(schema, markdown, markdown_ref, markdown_url, model, strict, idempotency_key)
+ try:
+ result = self._post(
+ self._v2_url("/v2/extract"),
+ body=body,
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=V2ExtractResult,
+ )
+ except APIStatusError as exc:
+ raise_if_sync_timeout(exc)
+ raise
+ if save_to:
+ from ..._client import _save_response, _get_input_filename
+
+ filename = _get_input_filename(None, markdown_url if isinstance(markdown_url, str) else None)
+ _save_response(save_to, filename, "extract", result)
+ return result
+
+
+class AsyncExtractResource(V2ResourceMixin, AsyncAPIResource):
+ async def run(
+ self,
+ *,
+ schema: Union[str, Mapping[str, object], Type[BaseModel]],
+ markdown: Optional[str] | Omit = omit,
+ markdown_ref: Optional[str] | Omit = omit,
+ markdown_url: Optional[str] | Omit = omit,
+ model: Optional[str] | Omit = omit,
+ strict: Optional[bool] | Omit = omit,
+ idempotency_key: Optional[str] | Omit = omit,
+ save_to: str | Path | None = None,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> V2ExtractResult:
+ """Async mirror of `ExtractResource.run`. See there for full documentation."""
+ body = _build_extract_body(schema, markdown, markdown_ref, markdown_url, model, strict, idempotency_key)
+ try:
+ result = await self._post(
+ self._v2_url("/v2/extract"),
+ body=body,
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=V2ExtractResult,
+ )
+ except APIStatusError as exc:
+ raise_if_sync_timeout(exc)
+ raise
+ if save_to:
+ from ..._client import _save_response, _get_input_filename
+
+ filename = _get_input_filename(None, markdown_url if isinstance(markdown_url, str) else None)
+ _save_response(save_to, filename, "extract", result)
+ return result
+
+
+class ExtractJobsResource(V2ResourceMixin, SyncAPIResource):
+ def create(
+ self,
+ *,
+ schema: Union[str, Mapping[str, object], Type[BaseModel]],
+ markdown: Optional[str] | Omit = omit,
+ markdown_ref: Optional[str] | Omit = omit,
+ markdown_url: Optional[str] | Omit = omit,
+ model: Optional[str] | Omit = omit,
+ strict: Optional[bool] | Omit = omit,
+ idempotency_key: Optional[str] | Omit = omit,
+ priority: Optional[Literal["standard", "priority"]] | Omit = omit,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> Job:
+ """Create an asynchronous extract job against `/v2/extract/jobs`.
+
+ Returns a normalized `Job` immediately (typically `pending`). Poll for
+ completion via `.get(job_id)`, or block until the job is terminal with
+ `.wait(job_id)`.
+
+ Args:
+ schema: JSON schema for field extraction. Accepts a pydantic `BaseModel`
+ subclass, a dict, or a JSON-encoded string; it is coerced to a JSON
+ object and sent as `schema` in the request body.
+
+ markdown: Markdown content to extract data from.
+
+ markdown_ref: A reference (e.g. from a prior parse) to markdown content to
+ extract data from.
+
+ markdown_url: The URL to the markdown file to extract data from.
+
+ model: The version of the model to use for extraction.
+
+ strict: If True, reject schemas with unsupported fields (HTTP 422). If
+ False, prune unsupported fields and continue. Sent as
+ `options.strict`.
+
+ idempotency_key: An idempotency key for the request.
+
+ priority: Processing priority for the job.
+
+ extra_headers: Send extra headers
+
+ extra_query: Add additional query parameters to the request
+
+ extra_body: Add additional JSON properties to the request
+
+ timeout: Override the client-level default timeout for this request, in seconds
+ """
+ body = _build_extract_body(
+ schema, markdown, markdown_ref, markdown_url, model, strict, idempotency_key, priority
+ )
+ raw = self._post(
+ self._v2_url("/v2/extract/jobs"),
+ body=body,
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=cast("type[Any]", object),
+ )
+ return normalize_extract_job(cast(Mapping[str, Any], raw))
+
+ def get(
+ self,
+ job_id: str,
+ *,
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> Job:
+ """Get the current status of an async extract job by `job_id`."""
+ if not job_id:
+ raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
+ raw = self._get(
+ self._v2_url(f"/v2/extract/jobs/{job_id}"),
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=cast("type[Any]", object),
+ )
+ return normalize_extract_job(cast(Mapping[str, Any], raw))
+
+ def list(
+ self,
+ *,
+ page: int | Omit = omit,
+ page_size: int | Omit = omit,
+ status: Optional[str] | Omit = omit,
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> JobList:
+ """List async extract jobs associated with your API key, newest first."""
+ query = {
+ key: value
+ for key, value in {"page": page, "page_size": page_size, "status": status}.items()
+ if is_given(value) and value is not None
+ }
+ raw = self._get(
+ self._v2_url("/v2/extract/jobs"),
+ options=make_request_options(
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ query=query,
+ ),
+ cast_to=cast("type[Any]", object),
+ )
+ env = cast(Mapping[str, Any], raw)
+ jobs = [normalize_extract_job(cast(Mapping[str, Any], item)) for item in env.get("jobs", [])]
+ return JobList.build(jobs, has_more=env.get("has_more"), page=env.get("page"), page_size=env.get("page_size"))
+
+ def wait(
+ self,
+ job_id: str,
+ *,
+ timeout: float = DEFAULT_WAIT_TIMEOUT,
+ poll_interval: Optional[float] = None,
+ raise_on_failure: bool = False,
+ _monotonic: Optional[Callable[[], float]] = None,
+ ) -> Job:
+ """Block, polling `.get(job_id)` with backoff, until the job is terminal.
+
+ Raises `JobWaitTimeoutError` if `timeout` seconds elapse before the job
+ reaches a terminal state, and `JobFailedError` if `raise_on_failure` is
+ set and the job ends failed with an error attached. Extract jobs have no
+ `cancelled` status.
+
+ `_monotonic` is a test seam for injecting a fake clock; production
+ callers should leave it unset (defaults to `time.monotonic`).
+ """
+ return poll_until_terminal(
+ lambda: self.get(job_id),
+ monotonic=_monotonic or time.monotonic,
+ sleep=self._sleep,
+ timeout=timeout,
+ poll_interval=poll_interval,
+ raise_on_failure=raise_on_failure,
+ )
+
+
+class AsyncExtractJobsResource(V2ResourceMixin, AsyncAPIResource):
+ async def create(
+ self,
+ *,
+ schema: Union[str, Mapping[str, object], Type[BaseModel]],
+ markdown: Optional[str] | Omit = omit,
+ markdown_ref: Optional[str] | Omit = omit,
+ markdown_url: Optional[str] | Omit = omit,
+ model: Optional[str] | Omit = omit,
+ strict: Optional[bool] | Omit = omit,
+ idempotency_key: Optional[str] | Omit = omit,
+ priority: Optional[Literal["standard", "priority"]] | Omit = omit,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> Job:
+ """Async mirror of `ExtractJobsResource.create`. See there for full documentation."""
+ body = _build_extract_body(
+ schema, markdown, markdown_ref, markdown_url, model, strict, idempotency_key, priority
+ )
+ raw = await self._post(
+ self._v2_url("/v2/extract/jobs"),
+ body=body,
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=cast("type[Any]", object),
+ )
+ return normalize_extract_job(cast(Mapping[str, Any], raw))
+
+ async def get(
+ self,
+ job_id: str,
+ *,
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> Job:
+ """Async mirror of `ExtractJobsResource.get`. See there for full documentation."""
+ if not job_id:
+ raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
+ raw = await self._get(
+ self._v2_url(f"/v2/extract/jobs/{job_id}"),
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=cast("type[Any]", object),
+ )
+ return normalize_extract_job(cast(Mapping[str, Any], raw))
+
+ async def list(
+ self,
+ *,
+ page: int | Omit = omit,
+ page_size: int | Omit = omit,
+ status: Optional[str] | Omit = omit,
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> JobList:
+ """Async mirror of `ExtractJobsResource.list`. See there for full documentation."""
+ query = {
+ key: value
+ for key, value in {"page": page, "page_size": page_size, "status": status}.items()
+ if is_given(value) and value is not None
+ }
+ raw = await self._get(
+ self._v2_url("/v2/extract/jobs"),
+ options=make_request_options(
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ query=query,
+ ),
+ cast_to=cast("type[Any]", object),
+ )
+ env = cast(Mapping[str, Any], raw)
+ jobs = [normalize_extract_job(cast(Mapping[str, Any], item)) for item in env.get("jobs", [])]
+ return JobList.build(jobs, has_more=env.get("has_more"), page=env.get("page"), page_size=env.get("page_size"))
+
+ async def wait(
+ self,
+ job_id: str,
+ *,
+ timeout: float = DEFAULT_WAIT_TIMEOUT,
+ poll_interval: Optional[float] = None,
+ raise_on_failure: bool = False,
+ _monotonic: Optional[Callable[[], float]] = None,
+ ) -> Job:
+ """Async mirror of `ExtractJobsResource.wait`; sleeps via `anyio.sleep` instead of blocking."""
+ return await apoll_until_terminal(
+ lambda: self.get(job_id),
+ monotonic=_monotonic or time.monotonic,
+ timeout=timeout,
+ poll_interval=poll_interval,
+ raise_on_failure=raise_on_failure,
+ )
diff --git a/src/landingai_ade/resources/v2/files.py b/src/landingai_ade/resources/v2/files.py
new file mode 100644
index 0000000..fd25d97
--- /dev/null
+++ b/src/landingai_ade/resources/v2/files.py
@@ -0,0 +1,73 @@
+# src/landingai_ade/resources/v2/files.py
+from __future__ import annotations
+
+from typing import Mapping, cast
+
+import httpx
+
+from ._base import V2ResourceMixin
+from ..._files import deepcopy_with_paths
+from ..._types import Body, Query, Headers, NotGiven, FileTypes, not_given
+from ..._utils import extract_files
+from ...types.v2 import V2FileUploadResponse
+from ..._resource import SyncAPIResource, AsyncAPIResource
+from ..._exceptions import LandingAiadeError
+from ..._base_client import make_request_options
+
+__all__ = ["FilesResource", "AsyncFilesResource"]
+
+
+class FilesResource(V2ResourceMixin, SyncAPIResource):
+ def upload(
+ self,
+ *,
+ file: FileTypes,
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> str:
+ """Stage bytes on the data plane; returns a `file_ref` for use as extract `markdown_ref`."""
+ body = deepcopy_with_paths({"file": file}, [["file"]])
+ files = extract_files(cast(Mapping[str, object], body), paths=[["file"]])
+ extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})}
+ response = self._post(
+ self._v2_url("/v1/files"),
+ body=body,
+ files=files,
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=V2FileUploadResponse,
+ )
+ if not response.file_ref:
+ raise LandingAiadeError(f"POST /v1/files did not return a file_ref (got: {response!r}).")
+ return response.file_ref
+
+
+class AsyncFilesResource(V2ResourceMixin, AsyncAPIResource):
+ async def upload(
+ self,
+ *,
+ file: FileTypes,
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> str:
+ """Stage bytes on the data plane; returns a `file_ref` for use as extract `markdown_ref`."""
+ body = deepcopy_with_paths({"file": file}, [["file"]])
+ files = extract_files(cast(Mapping[str, object], body), paths=[["file"]])
+ extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})}
+ response = await self._post(
+ self._v2_url("/v1/files"),
+ body=body,
+ files=files,
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=V2FileUploadResponse,
+ )
+ if not response.file_ref:
+ raise LandingAiadeError(f"POST /v1/files did not return a file_ref (got: {response!r}).")
+ return response.file_ref
diff --git a/src/landingai_ade/resources/v2/parse.py b/src/landingai_ade/resources/v2/parse.py
new file mode 100644
index 0000000..66126b7
--- /dev/null
+++ b/src/landingai_ade/resources/v2/parse.py
@@ -0,0 +1,440 @@
+# src/landingai_ade/resources/v2/parse.py
+from __future__ import annotations
+
+import json
+import time
+from typing import Any, Mapping, Callable, Optional, cast
+from pathlib import Path
+from typing_extensions import Literal
+
+import httpx
+
+from ._base import DEFAULT_WAIT_TIMEOUT, JobList, V2ResourceMixin, poll_until_terminal, apoll_until_terminal
+from ..._files import deepcopy_with_paths
+from ..._types import Body, Omit, Query, Headers, NotGiven, FileTypes, omit, not_given
+from ..._utils import is_given, extract_files
+from ...types.v2 import Job, V2ParseResponse
+from ._normalize import normalize_parse_job
+from ..._resource import SyncAPIResource, AsyncAPIResource
+from ..._exceptions import APIStatusError
+from ..._base_client import make_request_options
+from ...lib.url_utils import convert_url_to_file_if_local
+from ...lib.v2_errors import raise_if_sync_timeout
+
+__all__ = ["ParseResource", "AsyncParseResource", "ParseJobsResource", "AsyncParseJobsResource"]
+
+
+def _build_parse_body(
+ document: object,
+ document_url: object,
+ model: object,
+ options: object,
+ password: object,
+) -> dict[str, Any]:
+ # `options` is a JSON-encoded string form field per the contract.
+ if is_given(options) and options is not None:
+ options = json.dumps(options) if not isinstance(options, str) else options
+ raw_body = {
+ "document": document,
+ "document_url": document_url,
+ "model": model,
+ "options": options,
+ "password": password,
+ }
+ # Multipart requests aren't run through `maybe_transform`, which is what
+ # normally strips `omit`/`not_given` sentinels from a params TypedDict --
+ # drop them here so unset fields aren't serialized as form fields. An
+ # explicit `None` is likewise treated as "unset" for these optional wire
+ # fields, since `is_given` only filters the `omit`/`not_given` sentinels
+ # and otherwise returns True for `None`.
+ return {key: value for key, value in raw_body.items() if is_given(value) and value is not None}
+
+
+class ParseResource(V2ResourceMixin, SyncAPIResource):
+ def run(
+ self,
+ *,
+ document: Optional[FileTypes] | Omit = omit,
+ document_url: Optional[str] | Omit = omit,
+ model: Optional[str] | Omit = omit,
+ options: Optional[Mapping[str, object]] | Omit = omit,
+ password: Optional[str] | Omit = omit,
+ save_to: str | Path | None = None,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> V2ParseResponse:
+ """Parse a document synchronously against the V2 (ADE) `/v2/parse` endpoint.
+
+ Returns a `V2ParseResponse` on both a full success (HTTP 200) and a partial
+ success (HTTP 206) -- in the 206 case `result.metadata.failed_pages` lists the
+ pages that could not be parsed. Raises `V2SyncTimeoutError` when the server
+ times out the synchronous request (HTTP 504); use the async jobs route for
+ long-running documents in that case.
+
+ Args:
+ document: A file to be parsed. Either this parameter or `document_url` must be provided.
+
+ document_url: The URL to the file to be parsed. Either this parameter or `document` must be
+ provided. Local file paths are automatically converted to a `document` upload.
+
+ model: The version of the model to use for parsing.
+
+ options: Additional parsing options. Sent to the server as a JSON-encoded string form
+ field.
+
+ password: Password for encrypted document files.
+
+ save_to: Optional output path. If a directory, auto-generates the filename
+ (e.g. {input_file}_parse_output.json, or parse_output.json when no
+ input filename is available). If a full path ending in .json, saves there
+ directly. Parent directories are created automatically.
+
+ extra_headers: Send extra headers
+
+ extra_query: Add additional query parameters to the request
+
+ extra_body: Add additional JSON properties to the request
+
+ timeout: Override the client-level default timeout for this request, in seconds
+ """
+ original_document, original_document_url = document, document_url
+ document, document_url = convert_url_to_file_if_local(document, document_url)
+ body = deepcopy_with_paths(
+ _build_parse_body(document, document_url, model, options, password),
+ [["document"]],
+ )
+ files = extract_files(cast(Mapping[str, object], body), paths=[["document"]])
+ # It should be noted that the actual Content-Type header that will be
+ # sent to the server will contain a `boundary` parameter, e.g.
+ # multipart/form-data; boundary=---abc--
+ extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})}
+ try:
+ result = self._post(
+ self._v2_url("/v2/parse"),
+ body=body,
+ files=files,
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=V2ParseResponse,
+ )
+ except APIStatusError as exc:
+ raise_if_sync_timeout(exc)
+ raise
+ if save_to:
+ from ..._client import _save_response, _get_input_filename
+
+ filename = _get_input_filename(original_document, original_document_url)
+ _save_response(save_to, filename, "parse", result)
+ return result
+
+
+class AsyncParseResource(V2ResourceMixin, AsyncAPIResource):
+ async def run(
+ self,
+ *,
+ document: Optional[FileTypes] | Omit = omit,
+ document_url: Optional[str] | Omit = omit,
+ model: Optional[str] | Omit = omit,
+ options: Optional[Mapping[str, object]] | Omit = omit,
+ password: Optional[str] | Omit = omit,
+ save_to: str | Path | None = None,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> V2ParseResponse:
+ """Async mirror of `ParseResource.run`. See there for full documentation."""
+ original_document, original_document_url = document, document_url
+ document, document_url = convert_url_to_file_if_local(document, document_url)
+ body = deepcopy_with_paths(
+ _build_parse_body(document, document_url, model, options, password),
+ [["document"]],
+ )
+ files = extract_files(cast(Mapping[str, object], body), paths=[["document"]])
+ extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})}
+ try:
+ result = await self._post(
+ self._v2_url("/v2/parse"),
+ body=body,
+ files=files,
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=V2ParseResponse,
+ )
+ except APIStatusError as exc:
+ raise_if_sync_timeout(exc)
+ raise
+ if save_to:
+ from ..._client import _save_response, _get_input_filename
+
+ filename = _get_input_filename(original_document, original_document_url)
+ _save_response(save_to, filename, "parse", result)
+ return result
+
+
+class ParseJobsResource(V2ResourceMixin, SyncAPIResource):
+ def create(
+ self,
+ *,
+ document: Optional[FileTypes] | Omit = omit,
+ document_url: Optional[str] | Omit = omit,
+ model: Optional[str] | Omit = omit,
+ options: Optional[Mapping[str, object]] | Omit = omit,
+ password: Optional[str] | Omit = omit,
+ output_save_url: Optional[str] | Omit = omit,
+ priority: Optional[Literal["standard", "priority"]] | Omit = omit,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> Job:
+ """Create an asynchronous parse job against `/v2/parse/jobs`.
+
+ Returns a normalized `Job` immediately (typically `pending`). Poll for
+ completion via `.get(job_id)`, or block until the job is terminal with
+ `.wait(job_id)`.
+
+ Args:
+ document: A file to be parsed. Either this parameter or `document_url` must be provided.
+
+ document_url: The URL to the file to be parsed. Either this parameter or `document` must be
+ provided.
+
+ model: The version of the model to use for parsing.
+
+ options: Additional parsing options. Sent to the server as a JSON-encoded string form
+ field.
+
+ password: Password for encrypted document files.
+
+ output_save_url: If zero data retention (ZDR) is enabled, a URL the parsed output should be
+ saved to instead of being returned in the job result.
+
+ priority: Processing priority for the job.
+
+ extra_headers: Send extra headers
+
+ extra_query: Add additional query parameters to the request
+
+ extra_body: Add additional JSON properties to the request
+
+ timeout: Override the client-level default timeout for this request, in seconds
+ """
+ body = _build_parse_body(document, document_url, model, options, password)
+ if is_given(output_save_url) and output_save_url is not None:
+ body["output_save_url"] = output_save_url
+ if is_given(priority) and priority is not None:
+ body["priority"] = priority
+ body = deepcopy_with_paths(body, [["document"]])
+ files = extract_files(cast(Mapping[str, object], body), paths=[["document"]])
+ extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})}
+ raw = self._post(
+ self._v2_url("/v2/parse/jobs"),
+ body=body,
+ files=files,
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=cast("type[Any]", object),
+ )
+ return normalize_parse_job(cast(Mapping[str, Any], raw))
+
+ def get(
+ self,
+ job_id: str,
+ *,
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> Job:
+ """Get the current status of an async parse job by `job_id`."""
+ if not job_id:
+ raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
+ raw = self._get(
+ self._v2_url(f"/v2/parse/jobs/{job_id}"),
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=cast("type[Any]", object),
+ )
+ return normalize_parse_job(cast(Mapping[str, Any], raw))
+
+ def list(
+ self,
+ *,
+ page: int | Omit = omit,
+ page_size: int | Omit = omit,
+ status: Optional[str] | Omit = omit,
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> JobList:
+ """List async parse jobs associated with your API key, newest first."""
+ query = {
+ key: value
+ for key, value in {"page": page, "page_size": page_size, "status": status}.items()
+ if is_given(value) and value is not None
+ }
+ raw = self._get(
+ self._v2_url("/v2/parse/jobs"),
+ options=make_request_options(
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ query=query,
+ ),
+ cast_to=cast("type[Any]", object),
+ )
+ env = cast(Mapping[str, Any], raw)
+ jobs = [normalize_parse_job(cast(Mapping[str, Any], item)) for item in env.get("jobs", [])]
+ return JobList.build(jobs, has_more=env.get("has_more"), org_id=env.get("org_id"))
+
+ def wait(
+ self,
+ job_id: str,
+ *,
+ timeout: float = DEFAULT_WAIT_TIMEOUT,
+ poll_interval: Optional[float] = None,
+ raise_on_failure: bool = False,
+ _monotonic: Optional[Callable[[], float]] = None,
+ ) -> Job:
+ """Block, polling `.get(job_id)` with backoff, until the job is terminal.
+
+ Raises `JobWaitTimeoutError` if `timeout` seconds elapse before the job
+ reaches a terminal state, and `JobFailedError` if `raise_on_failure` is
+ set and the job ends failed/cancelled with an error attached.
+
+ `_monotonic` is a test seam for injecting a fake clock; production
+ callers should leave it unset (defaults to `time.monotonic`).
+ """
+ return poll_until_terminal(
+ lambda: self.get(job_id),
+ monotonic=_monotonic or time.monotonic,
+ sleep=self._sleep,
+ timeout=timeout,
+ poll_interval=poll_interval,
+ raise_on_failure=raise_on_failure,
+ )
+
+
+class AsyncParseJobsResource(V2ResourceMixin, AsyncAPIResource):
+ async def create(
+ self,
+ *,
+ document: Optional[FileTypes] | Omit = omit,
+ document_url: Optional[str] | Omit = omit,
+ model: Optional[str] | Omit = omit,
+ options: Optional[Mapping[str, object]] | Omit = omit,
+ password: Optional[str] | Omit = omit,
+ output_save_url: Optional[str] | Omit = omit,
+ priority: Optional[Literal["standard", "priority"]] | Omit = omit,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> Job:
+ """Async mirror of `ParseJobsResource.create`. See there for full documentation."""
+ body = _build_parse_body(document, document_url, model, options, password)
+ if is_given(output_save_url) and output_save_url is not None:
+ body["output_save_url"] = output_save_url
+ if is_given(priority) and priority is not None:
+ body["priority"] = priority
+ body = deepcopy_with_paths(body, [["document"]])
+ files = extract_files(cast(Mapping[str, object], body), paths=[["document"]])
+ extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})}
+ raw = await self._post(
+ self._v2_url("/v2/parse/jobs"),
+ body=body,
+ files=files,
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=cast("type[Any]", object),
+ )
+ return normalize_parse_job(cast(Mapping[str, Any], raw))
+
+ async def get(
+ self,
+ job_id: str,
+ *,
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> Job:
+ """Async mirror of `ParseJobsResource.get`. See there for full documentation."""
+ if not job_id:
+ raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
+ raw = await self._get(
+ self._v2_url(f"/v2/parse/jobs/{job_id}"),
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=cast("type[Any]", object),
+ )
+ return normalize_parse_job(cast(Mapping[str, Any], raw))
+
+ async def list(
+ self,
+ *,
+ page: int | Omit = omit,
+ page_size: int | Omit = omit,
+ status: Optional[str] | Omit = omit,
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> JobList:
+ """Async mirror of `ParseJobsResource.list`. See there for full documentation."""
+ query = {
+ key: value
+ for key, value in {"page": page, "page_size": page_size, "status": status}.items()
+ if is_given(value) and value is not None
+ }
+ raw = await self._get(
+ self._v2_url("/v2/parse/jobs"),
+ options=make_request_options(
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ query=query,
+ ),
+ cast_to=cast("type[Any]", object),
+ )
+ env = cast(Mapping[str, Any], raw)
+ jobs = [normalize_parse_job(cast(Mapping[str, Any], item)) for item in env.get("jobs", [])]
+ return JobList.build(jobs, has_more=env.get("has_more"), org_id=env.get("org_id"))
+
+ async def wait(
+ self,
+ job_id: str,
+ *,
+ timeout: float = DEFAULT_WAIT_TIMEOUT,
+ poll_interval: Optional[float] = None,
+ raise_on_failure: bool = False,
+ _monotonic: Optional[Callable[[], float]] = None,
+ ) -> Job:
+ """Async mirror of `ParseJobsResource.wait`; sleeps via `anyio.sleep` instead of blocking."""
+ return await apoll_until_terminal(
+ lambda: self.get(job_id),
+ monotonic=_monotonic or time.monotonic,
+ timeout=timeout,
+ poll_interval=poll_interval,
+ raise_on_failure=raise_on_failure,
+ )
diff --git a/src/landingai_ade/resources/v2/v2.py b/src/landingai_ade/resources/v2/v2.py
new file mode 100644
index 0000000..ee20a69
--- /dev/null
+++ b/src/landingai_ade/resources/v2/v2.py
@@ -0,0 +1,224 @@
+# src/landingai_ade/resources/v2/v2.py
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Type, Union, Mapping, Optional
+from pathlib import Path
+
+import httpx
+from pydantic import BaseModel
+
+from ._base import V2ResourceMixin
+from ..._types import Body, Omit, Query, Headers, NotGiven, FileTypes, omit, not_given
+from ..._compat import cached_property
+from ...types.v2 import V2ExtractResult, V2ParseResponse
+from ..._resource import SyncAPIResource, AsyncAPIResource
+
+if TYPE_CHECKING:
+ from .files import FilesResource, AsyncFilesResource
+ from .parse import ParseResource, ParseJobsResource, AsyncParseResource, AsyncParseJobsResource
+ from .extract import ExtractResource, ExtractJobsResource, AsyncExtractResource, AsyncExtractJobsResource
+
+__all__ = ["V2Resource", "AsyncV2Resource"]
+
+
+class V2Resource(SyncAPIResource, V2ResourceMixin):
+ """Container for the V2 (ADE) surface: ``client.v2.``.
+
+ ``files``, ``parse``, and ``extract`` are wired up; each sub-resource does its
+ own lazy import inside its cached property body -- mirroring
+ ``LandingAIADE.parse_jobs`` -- so that this module keeps importing standalone
+ regardless of which sub-resources exist yet. Remaining job-polling resources
+ are attached by later tasks following the same pattern.
+ """
+
+ @cached_property
+ def files(self) -> FilesResource:
+ from .files import FilesResource
+
+ return FilesResource(self._client)
+
+ @cached_property
+ def _parse(self) -> ParseResource:
+ from .parse import ParseResource
+
+ return ParseResource(self._client)
+
+ @cached_property
+ def parse_jobs(self) -> ParseJobsResource:
+ from .parse import ParseJobsResource
+
+ return ParseJobsResource(self._client)
+
+ def parse(
+ self,
+ *,
+ document: Optional[FileTypes] | Omit = omit,
+ document_url: Optional[str] | Omit = omit,
+ model: Optional[str] | Omit = omit,
+ options: Optional[Mapping[str, object]] | Omit = omit,
+ password: Optional[str] | Omit = omit,
+ save_to: str | Path | None = None,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> V2ParseResponse:
+ """Parse a document synchronously. See ``ParseResource.run`` for full documentation."""
+ return self._parse.run(
+ document=document,
+ document_url=document_url,
+ model=model,
+ options=options,
+ password=password,
+ save_to=save_to,
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+
+ @cached_property
+ def _extract(self) -> ExtractResource:
+ from .extract import ExtractResource
+
+ return ExtractResource(self._client)
+
+ @cached_property
+ def extract_jobs(self) -> ExtractJobsResource:
+ from .extract import ExtractJobsResource
+
+ return ExtractJobsResource(self._client)
+
+ def extract(
+ self,
+ *,
+ schema: Union[str, Mapping[str, object], Type[BaseModel]],
+ markdown: Optional[str] | Omit = omit,
+ markdown_ref: Optional[str] | Omit = omit,
+ markdown_url: Optional[str] | Omit = omit,
+ model: Optional[str] | Omit = omit,
+ strict: Optional[bool] | Omit = omit,
+ idempotency_key: Optional[str] | Omit = omit,
+ save_to: str | Path | None = None,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> V2ExtractResult:
+ """Extract structured data from markdown synchronously. See ``ExtractResource.run`` for full documentation."""
+ return self._extract.run(
+ schema=schema,
+ markdown=markdown,
+ markdown_ref=markdown_ref,
+ markdown_url=markdown_url,
+ model=model,
+ strict=strict,
+ idempotency_key=idempotency_key,
+ save_to=save_to,
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+
+
+class AsyncV2Resource(AsyncAPIResource, V2ResourceMixin):
+ """Async mirror of :class:`V2Resource`."""
+
+ @cached_property
+ def files(self) -> AsyncFilesResource:
+ from .files import AsyncFilesResource
+
+ return AsyncFilesResource(self._client)
+
+ @cached_property
+ def _parse(self) -> AsyncParseResource:
+ from .parse import AsyncParseResource
+
+ return AsyncParseResource(self._client)
+
+ @cached_property
+ def parse_jobs(self) -> AsyncParseJobsResource:
+ from .parse import AsyncParseJobsResource
+
+ return AsyncParseJobsResource(self._client)
+
+ async def parse(
+ self,
+ *,
+ document: Optional[FileTypes] | Omit = omit,
+ document_url: Optional[str] | Omit = omit,
+ model: Optional[str] | Omit = omit,
+ options: Optional[Mapping[str, object]] | Omit = omit,
+ password: Optional[str] | Omit = omit,
+ save_to: str | Path | None = None,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> V2ParseResponse:
+ """Async mirror of :meth:`V2Resource.parse`."""
+ return await self._parse.run(
+ document=document,
+ document_url=document_url,
+ model=model,
+ options=options,
+ password=password,
+ save_to=save_to,
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+
+ @cached_property
+ def _extract(self) -> AsyncExtractResource:
+ from .extract import AsyncExtractResource
+
+ return AsyncExtractResource(self._client)
+
+ @cached_property
+ def extract_jobs(self) -> AsyncExtractJobsResource:
+ from .extract import AsyncExtractJobsResource
+
+ return AsyncExtractJobsResource(self._client)
+
+ async def extract(
+ self,
+ *,
+ schema: Union[str, Mapping[str, object], Type[BaseModel]],
+ markdown: Optional[str] | Omit = omit,
+ markdown_ref: Optional[str] | Omit = omit,
+ markdown_url: Optional[str] | Omit = omit,
+ model: Optional[str] | Omit = omit,
+ strict: Optional[bool] | Omit = omit,
+ idempotency_key: Optional[str] | Omit = omit,
+ save_to: str | Path | None = None,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> V2ExtractResult:
+ """Async mirror of :meth:`V2Resource.extract`."""
+ return await self._extract.run(
+ schema=schema,
+ markdown=markdown,
+ markdown_ref=markdown_ref,
+ markdown_url=markdown_url,
+ model=model,
+ strict=strict,
+ idempotency_key=idempotency_key,
+ save_to=save_to,
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
diff --git a/src/landingai_ade/types/v2/__init__.py b/src/landingai_ade/types/v2/__init__.py
new file mode 100644
index 0000000..7a18255
--- /dev/null
+++ b/src/landingai_ade/types/v2/__init__.py
@@ -0,0 +1,13 @@
+from __future__ import annotations
+
+from .job import Job as Job, JobError as JobError, JobStatus as JobStatus
+from .parse_response import (
+ V2ParseBilling as V2ParseBilling,
+ V2ParseMetadata as V2ParseMetadata,
+ V2ParseResponse as V2ParseResponse,
+)
+from .extract_response import (
+ V2ExtractResult as V2ExtractResult,
+ V2ExtractMetadata as V2ExtractMetadata,
+)
+from .file_upload_response import V2FileUploadResponse as V2FileUploadResponse
diff --git a/src/landingai_ade/types/v2/extract_response.py b/src/landingai_ade/types/v2/extract_response.py
new file mode 100644
index 0000000..02869c9
--- /dev/null
+++ b/src/landingai_ade/types/v2/extract_response.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from typing import Dict, Optional
+
+from ..._models import BaseModel
+
+__all__ = ["V2ExtractMetadata", "V2ExtractResult"]
+
+
+class V2ExtractMetadata(BaseModel):
+ job_id: str
+ version: str
+ duration_ms: int
+ doc_id: Optional[str] = None
+ credit_usage: float = 0.0
+
+
+class V2ExtractResult(BaseModel):
+ extraction: Dict[str, object]
+ extraction_metadata: Dict[str, object]
+ markdown: str
+ metadata: V2ExtractMetadata
diff --git a/src/landingai_ade/types/v2/file_upload_response.py b/src/landingai_ade/types/v2/file_upload_response.py
new file mode 100644
index 0000000..393a182
--- /dev/null
+++ b/src/landingai_ade/types/v2/file_upload_response.py
@@ -0,0 +1,13 @@
+from __future__ import annotations
+
+from typing import Optional
+
+from ..._models import BaseModel
+
+__all__ = ["V2FileUploadResponse"]
+
+
+class V2FileUploadResponse(BaseModel):
+ """`POST /v1/files` returns an open string map; `file_ref` is the key we consume."""
+
+ file_ref: Optional[str] = None
diff --git a/src/landingai_ade/types/v2/job.py b/src/landingai_ade/types/v2/job.py
new file mode 100644
index 0000000..1f98631
--- /dev/null
+++ b/src/landingai_ade/types/v2/job.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from enum import Enum
+from typing import Dict, Optional
+from datetime import datetime
+
+from pydantic import Field
+
+from ..._models import BaseModel
+
+__all__ = ["JobStatus", "JobError", "Job"]
+
+
+class JobStatus(str, Enum):
+ PENDING = "pending"
+ PROCESSING = "processing"
+ COMPLETED = "completed"
+ FAILED = "failed"
+ CANCELLED = "cancelled"
+
+
+class JobError(BaseModel):
+ code: Optional[str] = None
+ message: Optional[str] = None
+
+
+class Job(BaseModel):
+ """One normalized job shape across parse and extract (envelopes diverge upstream)."""
+
+ job_id: str
+ status: JobStatus
+ created_at: Optional[datetime] = None
+ completed_at: Optional[datetime] = None
+ progress: Optional[float] = None
+ # Populated on completion: V2ParseResponse for parse jobs, V2ExtractResult for extract jobs.
+ result: Optional[object] = None
+ error: Optional[JobError] = None
+ # Full original envelope for fields not surfaced above (org_id, output_url, version, ...).
+ raw: Dict[str, object] = Field(default_factory=dict)
+
+ @property
+ def is_terminal(self) -> bool:
+ return self.status in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED)
diff --git a/src/landingai_ade/types/v2/parse_response.py b/src/landingai_ade/types/v2/parse_response.py
new file mode 100644
index 0000000..88c3eda
--- /dev/null
+++ b/src/landingai_ade/types/v2/parse_response.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from typing import List, Optional
+
+from ..._models import BaseModel
+
+__all__ = ["V2ParseBilling", "V2ParseMetadata", "V2ParseResponse"]
+
+
+class V2ParseBilling(BaseModel):
+ service_tier: Optional[str] = None
+ total_credits: Optional[float] = None
+
+
+class V2ParseMetadata(BaseModel):
+ req_id: Optional[str] = None
+ job_id: Optional[str] = None
+ model_version: Optional[str] = None
+ page_count: Optional[int] = None
+ markdown_chars: Optional[int] = None
+ failed_pages: Optional[List[int]] = None
+ duration_ms: Optional[int] = None
+ billing: Optional[V2ParseBilling] = None
+
+
+class V2ParseResponse(BaseModel):
+ """V2 parse result. The gateway spec types this loosely; fields are permissive
+ and extra keys are retained. Re-verify against the typed schema when the gateway
+ publishes one."""
+
+ markdown: Optional[str] = None
+ structure: Optional[object] = None
+ grounding: Optional[object] = None
+ metadata: Optional[V2ParseMetadata] = None
diff --git a/tests/api_resources/v2/__init__.py b/tests/api_resources/v2/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/api_resources/v2/test_async_smoke.py b/tests/api_resources/v2/test_async_smoke.py
new file mode 100644
index 0000000..b5f64a0
--- /dev/null
+++ b/tests/api_resources/v2/test_async_smoke.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+from typing import Any, Dict
+
+import httpx
+import respx
+import pytest
+
+from landingai_ade import AsyncLandingAIADE
+from landingai_ade.types.v2 import Job, JobStatus, V2ExtractResult, V2ParseResponse
+
+APIKEY = "My Apikey"
+
+EXTRACT_BODY: Dict[str, Any] = {
+ "extraction": {},
+ "extraction_metadata": {},
+ "markdown": "m",
+ "metadata": {"job_id": "e1", "version": "v", "duration_ms": 1},
+}
+
+
+@respx.mock
+@pytest.mark.asyncio
+async def test_async_parse_and_jobs() -> None:
+ client = AsyncLandingAIADE(apikey=APIKEY)
+ respx.post("https://api.ade.landing.ai/v2/parse").mock(
+ return_value=httpx.Response(200, json={"markdown": "# a", "metadata": {"job_id": "j"}})
+ )
+ assert isinstance(await client.v2.parse(document=b"x"), V2ParseResponse)
+
+ respx.post("https://api.ade.landing.ai/v2/parse/jobs").mock(
+ return_value=httpx.Response(202, json={"job_id": "p1", "status": "pending"})
+ )
+ job = await client.v2.parse_jobs.create(document=b"x")
+ assert isinstance(job, Job) and job.status is JobStatus.PENDING
+
+
+@respx.mock
+@pytest.mark.asyncio
+async def test_async_extract() -> None:
+ client = AsyncLandingAIADE(apikey=APIKEY)
+ respx.post("https://api.ade.landing.ai/v2/extract").mock(return_value=httpx.Response(200, json=EXTRACT_BODY))
+ r = await client.v2.extract(schema={"type": "object"}, markdown="m")
+ assert isinstance(r, V2ExtractResult)
+
+
+@respx.mock
+@pytest.mark.asyncio
+async def test_async_extract_jobs_create_get_and_wait() -> None:
+ """AsyncExtractJobsResource had zero coverage prior to this test: exercise
+ create/get/wait end to end on the async client with a fake clock so no real
+ time passes while polling."""
+ client = AsyncLandingAIADE(apikey=APIKEY)
+
+ respx.post("https://api.ade.landing.ai/v2/extract/jobs").mock(
+ return_value=httpx.Response(202, json={"job_id": "e1", "status": "pending"})
+ )
+ created = await client.v2.extract_jobs.create(schema={"type": "object"}, markdown="x")
+ assert isinstance(created, Job)
+ assert created.job_id == "e1" and created.status is JobStatus.PENDING
+
+ responses = [
+ httpx.Response(200, json={"job_id": "e1", "status": "processing", "progress": 0.5}),
+ httpx.Response(200, json={"job_id": "e1", "status": "completed", "result": EXTRACT_BODY}),
+ ]
+ respx.get("https://api.ade.landing.ai/v2/extract/jobs/e1").mock(side_effect=responses)
+
+ fetched = await client.v2.extract_jobs.get("e1")
+ assert fetched.status is JobStatus.PROCESSING
+
+ ticks = iter([0.0, 0.0, 0.1, 0.2, 0.3])
+ waited = await client.v2.extract_jobs.wait(
+ "e1", timeout=30, poll_interval=0.01, _monotonic=lambda: next(ticks)
+ )
+ assert waited.status is JobStatus.COMPLETED
+ assert isinstance(waited.result, V2ExtractResult)
diff --git a/tests/api_resources/v2/test_extract.py b/tests/api_resources/v2/test_extract.py
new file mode 100644
index 0000000..9636504
--- /dev/null
+++ b/tests/api_resources/v2/test_extract.py
@@ -0,0 +1,177 @@
+from __future__ import annotations
+
+import json
+from typing import Any, Dict
+
+import httpx
+import respx
+import pytest
+from pydantic import Field, BaseModel
+
+from landingai_ade import LandingAIADE
+from landingai_ade.types.v2 import JobStatus, V2ExtractResult
+from landingai_ade.lib.v2_errors import V2SyncTimeoutError
+
+APIKEY = "My Apikey"
+EXTRACT_BODY: Dict[str, Any] = {
+ "extraction": {"revenue": "1M"},
+ "extraction_metadata": {"revenue": {"value": "1M", "spans": []}},
+ "markdown": "# doc",
+ "metadata": {"job_id": "e1", "version": "extract-1", "duration_ms": 5},
+}
+
+
+class Invoice(BaseModel):
+ revenue: str = Field(description="Q1 revenue")
+
+
+@respx.mock
+def test_extract_sync_json_body_with_pydantic_schema() -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ route = respx.post("https://api.ade.landing.ai/v2/extract").mock(
+ return_value=httpx.Response(200, json=EXTRACT_BODY)
+ )
+ result = client.v2.extract(schema=Invoice, markdown="# doc", idempotency_key="k1")
+ assert isinstance(result, V2ExtractResult) and result.metadata.version == "extract-1"
+ req = json.loads(route.calls.last.request.content)
+ assert req["schema"]["type"] == "object" and "revenue" in req["schema"]["properties"]
+ assert req["markdown"] == "# doc"
+ assert req["idempotency_key"] == "k1"
+ assert route.calls.last.request.headers["content-type"].startswith("application/json")
+
+
+@respx.mock
+def test_extract_sync_strict_option() -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ route = respx.post("https://api.ade.landing.ai/v2/extract").mock(
+ return_value=httpx.Response(200, json=EXTRACT_BODY)
+ )
+ client.v2.extract(schema={"type": "object", "properties": {}}, markdown_url="https://x/y.md", strict=True)
+ req = json.loads(route.calls.last.request.content)
+ assert req["options"]["strict"] is True
+ assert req["markdown_url"] == "https://x/y.md"
+
+
+@respx.mock
+def test_extract_sync_504() -> None:
+ client = LandingAIADE(apikey=APIKEY, max_retries=0)
+ respx.post("https://api.ade.landing.ai/v2/extract").mock(return_value=httpx.Response(504, json={"detail": "x"}))
+ with pytest.raises(V2SyncTimeoutError):
+ client.v2.extract(schema={"type": "object"}, markdown="x")
+
+
+@respx.mock
+def test_extract_job_create_and_get() -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ respx.post("https://api.ade.landing.ai/v2/extract/jobs").mock(
+ return_value=httpx.Response(202, json={"job_id": "e1", "status": "pending", "created_at": "2026-01-01T00:00:00Z"})
+ )
+ job = client.v2.extract_jobs.create(schema={"type": "object"}, markdown="x", priority="priority")
+ assert job.job_id == "e1" and job.status is JobStatus.PENDING
+
+ respx.get("https://api.ade.landing.ai/v2/extract/jobs/e1").mock(
+ return_value=httpx.Response(
+ 200,
+ json={"job_id": "e1", "status": "completed", "created_at": "2026-01-01T00:00:00Z",
+ "completed_at": "2026-01-01T00:00:09Z", "result": EXTRACT_BODY},
+ )
+ )
+ done = client.v2.extract_jobs.get("e1")
+ assert done.status is JobStatus.COMPLETED
+ assert isinstance(done.result, V2ExtractResult)
+ assert done.result.metadata.version == "extract-1"
+
+
+@respx.mock
+def test_extract_job_get_failed_maps_error_object() -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ respx.get("https://api.ade.landing.ai/v2/extract/jobs/e2").mock(
+ return_value=httpx.Response(
+ 200, json={"job_id": "e2", "status": "failed", "error": {"code": "internal_error", "message": "boom"}}
+ )
+ )
+ job = client.v2.extract_jobs.get("e2")
+ assert job.status is JobStatus.FAILED and job.error is not None and job.error.code == "internal_error"
+
+
+@respx.mock
+def test_extract_job_wait_raise_on_failure() -> None:
+ from landingai_ade.lib.v2_errors import JobFailedError
+
+ client = LandingAIADE(apikey=APIKEY)
+ respx.get("https://api.ade.landing.ai/v2/extract/jobs/e3").mock(
+ return_value=httpx.Response(200, json={"job_id": "e3", "status": "failed", "error": {"code": "x", "message": "no"}})
+ )
+ with pytest.raises(JobFailedError):
+ client.v2.extract_jobs.wait("e3", timeout=5, poll_interval=0.01, raise_on_failure=True,
+ _monotonic=lambda: 0.0)
+
+
+@respx.mock
+def test_extract_job_get_empty_job_id_raises() -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ with pytest.raises(ValueError):
+ client.v2.extract_jobs.get("")
+
+
+@respx.mock
+def test_extract_job_list_status_none_omits_query_param() -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ route = respx.get("https://api.ade.landing.ai/v2/extract/jobs").mock(
+ return_value=httpx.Response(200, json={"jobs": [], "has_more": False})
+ )
+ client.v2.extract_jobs.list(status=None)
+ assert "status" not in route.calls.last.request.url.params
+
+
+def test_extract_job_list_status_none_excluded_from_query_dict(monkeypatch: pytest.MonkeyPatch) -> None:
+ # Regression test at the query-dict level: the underlying querystring
+ # encoder happens to drop `None`-valued params when serializing to a URL,
+ # which would mask this bug in an end-to-end/respx assertion. Capture the
+ # dict handed to `options["params"]` directly so a regression is caught
+ # even before it reaches that encoder.
+ client = LandingAIADE(apikey=APIKEY)
+ captured: Dict[str, Any] = {}
+
+ def fake_get(path: str, *, cast_to: Any, options: Any = None, **kwargs: Any) -> Any: # noqa: ARG001
+ captured["params"] = dict(options or {}).get("params", {})
+ return {"jobs": [], "has_more": False}
+
+ monkeypatch.setattr(client.v2.extract_jobs, "_get", fake_get)
+
+ client.v2.extract_jobs.list(status=None)
+ assert "status" not in captured["params"]
+
+ client.v2.extract_jobs.list(status="completed")
+ assert captured["params"].get("status") == "completed"
+
+
+@respx.mock
+def test_extract_job_list_status_given_includes_query_param() -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ route = respx.get("https://api.ade.landing.ai/v2/extract/jobs").mock(
+ return_value=httpx.Response(200, json={"jobs": [], "has_more": False})
+ )
+ client.v2.extract_jobs.list(status="completed")
+ assert route.calls.last.request.url.params["status"] == "completed"
+
+
+@respx.mock
+def test_extract_job_list_carries_envelope() -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ respx.get("https://api.ade.landing.ai/v2/extract/jobs").mock(
+ return_value=httpx.Response(
+ 200,
+ json={
+ "jobs": [{"job_id": "e1", "status": "completed"}],
+ "page": 0,
+ "page_size": 10,
+ "has_more": True,
+ },
+ )
+ )
+ jobs = client.v2.extract_jobs.list()
+ assert len(jobs) == 1 and jobs[0].job_id == "e1" and jobs[0].status is JobStatus.COMPLETED
+ assert jobs.has_more is True
+ assert jobs.page == 0
+ assert jobs.page_size == 10
diff --git a/tests/api_resources/v2/test_files.py b/tests/api_resources/v2/test_files.py
new file mode 100644
index 0000000..e3e376f
--- /dev/null
+++ b/tests/api_resources/v2/test_files.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+import httpx
+import respx
+import pytest
+
+from landingai_ade import LandingAIADE, AsyncLandingAIADE
+
+APIKEY = "My Apikey"
+
+
+@respx.mock
+def test_files_upload_returns_ref_and_hits_v2_host() -> None:
+ client = LandingAIADE(apikey=APIKEY, environment="production")
+ route = respx.post("https://api.ade.landing.ai/v1/files").mock(
+ return_value=httpx.Response(200, json={"file_ref": "fr_1"})
+ )
+ ref = client.v2.files.upload(file=b"markdown bytes")
+ assert route.called
+ assert route.calls.last.request.headers["authorization"] == "Bearer My Apikey"
+ assert ref == "fr_1"
+
+
+@respx.mock
+@pytest.mark.asyncio
+async def test_async_files_upload() -> None:
+ client = AsyncLandingAIADE(apikey=APIKEY, environment="staging")
+ respx.post("https://api.ade.staging.landing.ai/v1/files").mock(
+ return_value=httpx.Response(200, json={"file_ref": "fr_2"})
+ )
+ ref = await client.v2.files.upload(file=b"bytes")
+ assert ref == "fr_2"
diff --git a/tests/api_resources/v2/test_parse.py b/tests/api_resources/v2/test_parse.py
new file mode 100644
index 0000000..e70c7f5
--- /dev/null
+++ b/tests/api_resources/v2/test_parse.py
@@ -0,0 +1,269 @@
+from __future__ import annotations
+
+import json
+from typing import Any, Dict
+from pathlib import Path
+
+import httpx
+import respx
+import pytest
+
+from landingai_ade import LandingAIADE
+from landingai_ade.types.v2 import Job, JobStatus, V2ParseResponse
+from landingai_ade.lib.v2_errors import V2SyncTimeoutError
+
+APIKEY = "My Apikey"
+PARSE_BODY: Dict[str, Any] = {
+ "markdown": "# Hello",
+ "structure": [{"type": "text"}],
+ "metadata": {"req_id": "r1", "job_id": "j1", "model_version": "dpt-3", "page_count": 1, "failed_pages": []},
+}
+
+
+@respx.mock
+def test_parse_sync_ok_routes_to_v2_and_sends_options_json() -> None:
+ client = LandingAIADE(apikey=APIKEY, environment="production")
+ route = respx.post("https://api.ade.landing.ai/v2/parse").mock(
+ return_value=httpx.Response(200, json=PARSE_BODY)
+ )
+ result = client.v2.parse(document=b"pdf", model="dpt-3-latest", options={"foo": "bar"})
+ assert isinstance(result, V2ParseResponse)
+ assert result.markdown == "# Hello"
+ # options must be sent as a JSON-encoded string form field
+ sent = route.calls.last.request.content
+ assert b'{"foo": "bar"}' in sent or b'"foo"' in sent
+
+
+@respx.mock
+def test_parse_sync_omits_unset_fields_from_multipart_body() -> None:
+ client = LandingAIADE(apikey=APIKEY, environment="production")
+ route = respx.post("https://api.ade.landing.ai/v2/parse").mock(
+ return_value=httpx.Response(200, json=PARSE_BODY)
+ )
+ client.v2.parse(document=b"pdf")
+ # Unset `Omit`/`NotGiven` sentinels must never leak into the multipart
+ # body as literal `"<...Omit object...>"` / `"NOT_GIVEN"` form fields.
+ sent = route.calls.last.request.content
+ assert b"Omit object" not in sent
+ assert b"NOT_GIVEN" not in sent
+ assert b"document_url" not in sent
+ assert b"model" not in sent
+ assert b"options" not in sent
+ assert b"password" not in sent
+
+
+@respx.mock
+def test_parse_sync_omits_explicit_none_fields_from_multipart_body() -> None:
+ # `is_given(None)` is True (it only filters the `omit`/`not_given` sentinels),
+ # so an explicit `None` for an optional field must be dropped by hand -- it
+ # must never leak into the multipart body as a literal "None"/"null" field.
+ client = LandingAIADE(apikey=APIKEY, environment="production")
+ route = respx.post("https://api.ade.landing.ai/v2/parse").mock(
+ return_value=httpx.Response(200, json=PARSE_BODY)
+ )
+ client.v2.parse(document=b"x", document_url=None, model=None, options=None, password=None)
+ sent = route.calls.last.request.content
+ assert b"document_url" not in sent
+ assert b"model" not in sent
+ assert b"options" not in sent
+ assert b"password" not in sent
+ assert b"null" not in sent
+ assert b"None" not in sent
+
+
+def test_parse_job_create_omits_explicit_none_extra_fields(monkeypatch: pytest.MonkeyPatch) -> None:
+ # Regression test at the body-dict level (before multipart encoding, which
+ # happens to drop bare `None` values and would otherwise mask this bug):
+ # `output_save_url=None`/`priority=None` must not survive into the body
+ # dict handed to the request layer.
+ client = LandingAIADE(apikey=APIKEY)
+ captured: Dict[str, Any] = {}
+
+ def fake_post(path: str, *, cast_to: Any, body: Any = None, files: Any = None, options: Any = None) -> Any: # noqa: ARG001
+ captured["body"] = body
+ return {"job_id": "p1", "status": "pending"}
+
+ monkeypatch.setattr(client.v2.parse_jobs, "_post", fake_post)
+ client.v2.parse_jobs.create(document=b"x", output_save_url=None, priority=None)
+ assert "output_save_url" not in captured["body"]
+ assert "priority" not in captured["body"]
+
+
+@respx.mock
+def test_parse_job_list_status_none_omits_query_param() -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ route = respx.get("https://api.ade.landing.ai/v2/parse/jobs").mock(
+ return_value=httpx.Response(200, json={"jobs": [], "has_more": False})
+ )
+ client.v2.parse_jobs.list(status=None)
+ assert "status" not in route.calls.last.request.url.params
+
+
+def test_parse_job_list_status_none_excluded_from_query_dict(monkeypatch: pytest.MonkeyPatch) -> None:
+ # Regression test at the query-dict level: the underlying querystring
+ # encoder happens to drop `None`-valued params when serializing to a URL,
+ # which would mask this bug in an end-to-end/respx assertion. Capture the
+ # dict handed to `options["params"]` directly so a regression is caught
+ # even before it reaches that encoder.
+ client = LandingAIADE(apikey=APIKEY)
+ captured: Dict[str, Any] = {}
+
+ def fake_get(path: str, *, cast_to: Any, options: Any = None, **kwargs: Any) -> Any: # noqa: ARG001
+ captured["params"] = dict(options or {}).get("params", {})
+ return {"jobs": [], "has_more": False}
+
+ monkeypatch.setattr(client.v2.parse_jobs, "_get", fake_get)
+
+ client.v2.parse_jobs.list(status=None)
+ assert "status" not in captured["params"]
+
+ client.v2.parse_jobs.list(status="completed")
+ assert captured["params"].get("status") == "completed"
+
+
+@respx.mock
+def test_parse_job_list_status_given_includes_query_param() -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ route = respx.get("https://api.ade.landing.ai/v2/parse/jobs").mock(
+ return_value=httpx.Response(200, json={"jobs": [], "has_more": False})
+ )
+ client.v2.parse_jobs.list(status="completed")
+ assert route.calls.last.request.url.params["status"] == "completed"
+
+
+@respx.mock
+def test_parse_sync_206_returns_response_with_failed_pages() -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ body: Dict[str, Any] = dict(PARSE_BODY)
+ metadata: Dict[str, Any] = dict(PARSE_BODY["metadata"])
+ metadata["failed_pages"] = [3]
+ body["metadata"] = metadata
+ respx.post("https://api.ade.landing.ai/v2/parse").mock(return_value=httpx.Response(206, json=body))
+ result = client.v2.parse(document=b"pdf")
+ assert result.metadata is not None and result.metadata.failed_pages == [3]
+
+
+@respx.mock
+def test_parse_sync_504_raises_v2_sync_timeout() -> None:
+ client = LandingAIADE(apikey=APIKEY, max_retries=0)
+ respx.post("https://api.ade.landing.ai/v2/parse").mock(return_value=httpx.Response(504, json={"detail": "x"}))
+ with pytest.raises(V2SyncTimeoutError):
+ client.v2.parse(document=b"pdf")
+
+
+@respx.mock
+def test_parse_save_to_writes_file(tmp_path: Path) -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ respx.post("https://api.ade.landing.ai/v2/parse").mock(return_value=httpx.Response(200, json=PARSE_BODY))
+ client.v2.parse(document=b"pdf", save_to=str(tmp_path))
+ written = list(tmp_path.glob("*.json"))
+ assert written and json.loads(written[0].read_text())["markdown"] == "# Hello"
+
+
+@respx.mock
+@pytest.mark.asyncio
+async def test_async_parse_sync_ok() -> None:
+ from landingai_ade import AsyncLandingAIADE
+
+ client = AsyncLandingAIADE(apikey=APIKEY, environment="production")
+ respx.post("https://api.ade.landing.ai/v2/parse").mock(return_value=httpx.Response(200, json=PARSE_BODY))
+ result = await client.v2.parse(document=b"pdf")
+ assert isinstance(result, V2ParseResponse)
+ assert result.markdown == "# Hello"
+
+
+@respx.mock
+def test_parse_job_create_normalizes_envelope() -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ respx.post("https://api.ade.landing.ai/v2/parse/jobs").mock(
+ return_value=httpx.Response(202, json={"job_id": "p1", "status": "pending", "received_at": 1700000000})
+ )
+ job = client.v2.parse_jobs.create(document=b"pdf", priority="priority")
+ assert isinstance(job, Job)
+ assert job.job_id == "p1" and job.status is JobStatus.PENDING
+
+
+@respx.mock
+def test_parse_job_get_completed_has_typed_result() -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ respx.get("https://api.ade.landing.ai/v2/parse/jobs/p1").mock(
+ return_value=httpx.Response(
+ 200,
+ json={"job_id": "p1", "status": "completed", "created_at": 1700000000, "data": PARSE_BODY},
+ )
+ )
+ job = client.v2.parse_jobs.get("p1")
+ assert job.status is JobStatus.COMPLETED
+ assert isinstance(job.result, V2ParseResponse)
+ assert job.result.markdown == "# Hello"
+
+
+@respx.mock
+def test_parse_job_get_empty_job_id_raises() -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ with pytest.raises(ValueError):
+ client.v2.parse_jobs.get("")
+
+
+@respx.mock
+def test_parse_job_list_carries_envelope() -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ respx.get("https://api.ade.landing.ai/v2/parse/jobs").mock(
+ return_value=httpx.Response(
+ 200,
+ json={"jobs": [{"job_id": "p1", "status": "completed"}], "org_id": "o1", "has_more": True},
+ )
+ )
+ jobs = client.v2.parse_jobs.list(page=0)
+ assert len(jobs) == 1 and jobs[0].job_id == "p1"
+ assert jobs.has_more is True and jobs.org_id == "o1"
+
+
+@respx.mock
+def test_parse_job_wait_polls_until_completed() -> None:
+ client = LandingAIADE(apikey=APIKEY)
+ responses = [
+ httpx.Response(200, json={"job_id": "p1", "status": "processing", "progress": 0.5}),
+ httpx.Response(200, json={"job_id": "p1", "status": "completed", "data": PARSE_BODY}),
+ ]
+ respx.get("https://api.ade.landing.ai/v2/parse/jobs/p1").mock(side_effect=responses)
+ # inject fake clock so no real time passes
+ ticks = iter([0.0, 0.0, 0.1, 0.2, 0.3])
+ job = client.v2.parse_jobs.wait("p1", timeout=30, poll_interval=0.01, _monotonic=lambda: next(ticks))
+ assert job.status is JobStatus.COMPLETED
+
+
+@respx.mock
+@pytest.mark.asyncio
+async def test_async_parse_job_create_and_get() -> None:
+ from landingai_ade import AsyncLandingAIADE
+
+ client = AsyncLandingAIADE(apikey=APIKEY)
+ respx.post("https://api.ade.landing.ai/v2/parse/jobs").mock(
+ return_value=httpx.Response(202, json={"job_id": "p1", "status": "pending"})
+ )
+ respx.get("https://api.ade.landing.ai/v2/parse/jobs/p1").mock(
+ return_value=httpx.Response(200, json={"job_id": "p1", "status": "completed", "data": PARSE_BODY})
+ )
+ created = await client.v2.parse_jobs.create(document=b"pdf")
+ assert created.status is JobStatus.PENDING
+ fetched = await client.v2.parse_jobs.get("p1")
+ assert fetched.status is JobStatus.COMPLETED
+ assert isinstance(fetched.result, V2ParseResponse)
+ assert fetched.result.markdown == "# Hello"
+
+
+@respx.mock
+@pytest.mark.asyncio
+async def test_async_parse_job_wait_polls_until_completed() -> None:
+ from landingai_ade import AsyncLandingAIADE
+
+ client = AsyncLandingAIADE(apikey=APIKEY)
+ responses = [
+ httpx.Response(200, json={"job_id": "p1", "status": "processing", "progress": 0.5}),
+ httpx.Response(200, json={"job_id": "p1", "status": "completed", "data": PARSE_BODY}),
+ ]
+ respx.get("https://api.ade.landing.ai/v2/parse/jobs/p1").mock(side_effect=responses)
+ ticks = iter([0.0, 0.0, 0.1, 0.2, 0.3])
+ job = await client.v2.parse_jobs.wait("p1", timeout=30, poll_interval=0.01, _monotonic=lambda: next(ticks))
+ assert job.status is JobStatus.COMPLETED
diff --git a/tests/test_v2_environment.py b/tests/test_v2_environment.py
new file mode 100644
index 0000000..0dcc2c9
--- /dev/null
+++ b/tests/test_v2_environment.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+from typing_extensions import Literal
+
+import httpx
+import respx
+import pytest
+
+from landingai_ade import LandingAIADE
+from landingai_ade.resources.v2 import V2Resource
+
+APIKEY = "My Apikey"
+
+
+def test_default_production_pair() -> None:
+ c = LandingAIADE(apikey=APIKEY)
+ assert str(c.base_url).rstrip("/") == "https://api.va.landing.ai"
+ assert c._v2_base_url == "https://api.ade.landing.ai"
+
+
+@pytest.mark.parametrize(
+ "env,v1,v2",
+ [
+ ("production", "https://api.va.landing.ai", "https://api.ade.landing.ai"),
+ ("eu", "https://api.va.eu-west-1.landing.ai", "https://api.ade.eu-west-1.landing.ai"),
+ ("staging", "https://api.va.staging.landing.ai", "https://api.ade.staging.landing.ai"),
+ ("dev", "https://api.va.dev.landing.ai", "https://api.ade.dev.landing.ai"),
+ ],
+)
+def test_environment_pairs(env: Literal["production", "eu", "staging", "dev"], v1: str, v2: str) -> None:
+ c = LandingAIADE(apikey=APIKEY, environment=env)
+ assert str(c.base_url).rstrip("/") == v1
+ assert c._v2_base_url == v2
+
+
+def test_environment_from_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("LANDINGAI_ADE_ENVIRONMENT", "staging")
+ c = LandingAIADE(apikey=APIKEY)
+ assert c._v2_base_url == "https://api.ade.staging.landing.ai"
+
+
+def test_explicit_v2_base_url_wins() -> None:
+ c = LandingAIADE(apikey=APIKEY, v2_base_url="https://mock.local")
+ assert c._v2_base_url == "https://mock.local"
+
+
+def test_v2_follows_base_url_when_only_base_url_set() -> None:
+ c = LandingAIADE(apikey=APIKEY, base_url="http://127.0.0.1:4010")
+ assert str(c.base_url).rstrip("/") == "http://127.0.0.1:4010"
+ assert c._v2_base_url == "http://127.0.0.1:4010"
+
+
+def test_v2_base_url_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("LANDINGAI_ADE_V2_BASE_URL", "https://v2.mock.local")
+ c = LandingAIADE(apikey=APIKEY)
+ assert c._v2_base_url == "https://v2.mock.local"
+
+
+def test_v2_attribute_exists() -> None:
+ # Only the `v2` container exists after this task -- sub-resources (`files`,
+ # `parse_jobs`, `extract_jobs`, ...) land in Tasks 7-11, so this must not touch them.
+ c = LandingAIADE(apikey=APIKEY)
+ assert c.v2 is not None
+ assert isinstance(c.v2, V2Resource)
+
+
+@respx.mock
+def test_v2_subclient_routes_to_v2_host() -> None:
+ c = LandingAIADE(apikey=APIKEY, environment="production")
+ route = respx.post("https://api.ade.landing.ai/v1/files").mock(
+ return_value=httpx.Response(200, json={"file_ref": "ref-123"})
+ )
+ ref = c.v2.files.upload(file=b"hello")
+ assert route.called
+ assert ref == "ref-123"
diff --git a/tests/test_v2_errors.py b/tests/test_v2_errors.py
new file mode 100644
index 0000000..1ded801
--- /dev/null
+++ b/tests/test_v2_errors.py
@@ -0,0 +1,36 @@
+# tests/test_v2_errors.py
+from __future__ import annotations
+
+import httpx
+import pytest
+
+from landingai_ade._exceptions import APIStatusError
+from landingai_ade.lib.v2_errors import (
+ JobFailedError,
+ V2SyncTimeoutError,
+ JobWaitTimeoutError,
+ raise_if_sync_timeout,
+)
+
+
+def _status_error(code: int) -> APIStatusError:
+ request = httpx.Request("POST", "https://api.ade.landing.ai/v2/extract")
+ response = httpx.Response(code, request=request)
+ return APIStatusError("err", response=response, body=None)
+
+
+def test_raise_if_sync_timeout_converts_504() -> None:
+ with pytest.raises(V2SyncTimeoutError):
+ raise_if_sync_timeout(_status_error(504))
+
+
+def test_raise_if_sync_timeout_ignores_other_codes() -> None:
+ raise_if_sync_timeout(_status_error(500)) # returns without raising
+
+
+def test_error_hierarchy() -> None:
+ from landingai_ade._exceptions import LandingAiadeError
+
+ assert issubclass(V2SyncTimeoutError, LandingAiadeError)
+ assert issubclass(JobWaitTimeoutError, LandingAiadeError)
+ assert issubclass(JobFailedError, LandingAiadeError)
diff --git a/tests/test_v2_normalize.py b/tests/test_v2_normalize.py
new file mode 100644
index 0000000..e5981ec
--- /dev/null
+++ b/tests/test_v2_normalize.py
@@ -0,0 +1,112 @@
+# tests/test_v2_normalize.py
+from __future__ import annotations
+
+from typing import Any, Dict
+from datetime import datetime, timezone
+
+from landingai_ade.types.v2 import JobStatus, V2ExtractResult, V2ParseResponse
+from landingai_ade.resources.v2._normalize import normalize_parse_job, normalize_extract_job
+
+
+def test_normalize_parse_job_epoch_and_data() -> None:
+ raw = {
+ "job_id": "p1",
+ "status": "completed",
+ "received_at": 1_700_000_000,
+ "created_at": 1_700_000_005,
+ "progress": 1.0,
+ "org_id": "o1",
+ "output_url": None,
+ "data": {"markdown": "# hi", "metadata": {"job_id": "p1", "page_count": 1}},
+ }
+ job = normalize_parse_job(raw)
+ assert job.job_id == "p1"
+ assert job.status is JobStatus.COMPLETED
+ assert job.created_at is not None and job.created_at.year == 2023
+ assert isinstance(job.result, V2ParseResponse)
+ assert job.result.markdown == "# hi"
+ assert job.error is None
+ assert job.raw["org_id"] == "o1" # envelope-only fields preserved
+
+
+def test_normalize_parse_job_preserves_epoch_zero_created_at() -> None:
+ # created_at == 0 (epoch) is falsy but must NOT be treated as missing;
+ # it must not fall back to received_at. received_at=123 is also within
+ # 1970, so we assert the exact instant (not just the year) to ensure the
+ # received_at fallback (00:02:03) wasn't used instead of epoch (00:00:00).
+ raw = {"job_id": "p", "status": "pending", "created_at": 0, "received_at": 123}
+ job = normalize_parse_job(raw)
+ assert job.created_at is not None
+ assert job.created_at.year == 1970
+ assert job.created_at == datetime(1970, 1, 1, tzinfo=timezone.utc)
+
+
+def test_normalize_parse_job_failure_reason() -> None:
+ raw = {"job_id": "p2", "status": "failed", "failure_reason": "bad pdf", "created_at": 1_700_000_000}
+ job = normalize_parse_job(raw)
+ assert job.status is JobStatus.FAILED
+ assert job.error is not None and job.error.message == "bad pdf"
+ assert job.result is None
+
+
+def test_normalize_extract_job_iso_and_result() -> None:
+ raw: Dict[str, Any] = {
+ "job_id": "e1",
+ "status": "completed",
+ "created_at": "2026-01-02T03:04:05Z",
+ "completed_at": "2026-01-02T03:04:09Z",
+ "result": {
+ "extraction": {"revenue": "1M"},
+ "extraction_metadata": {"revenue": {"value": "1M", "spans": []}},
+ "markdown": "# doc",
+ "metadata": {"job_id": "e1", "version": "extract-1", "duration_ms": 10},
+ },
+ }
+ job = normalize_extract_job(raw)
+ assert job.status is JobStatus.COMPLETED
+ assert job.created_at is not None and job.created_at.year == 2026
+ assert job.completed_at is not None
+ assert isinstance(job.result, V2ExtractResult)
+ assert job.result.metadata.version == "extract-1"
+
+
+def test_normalize_extract_job_error_object() -> None:
+ raw = {"job_id": "e2", "status": "failed", "error": {"code": "internal_error", "message": "boom"}}
+ job = normalize_extract_job(raw)
+ assert job.status is JobStatus.FAILED
+ assert job.error is not None and job.error.code == "internal_error"
+
+
+def test_normalize_parse_job_minimal_create_envelope_defaults_to_pending() -> None:
+ # Live /v2/parse/jobs create (202) response is minimal: only job_id, no status.
+ raw = {"job_id": "parse-api-x"}
+ job = normalize_parse_job(raw)
+ assert job.job_id == "parse-api-x"
+ assert job.status is JobStatus.PENDING
+ assert job.result is None
+ assert job.error is None
+
+
+def test_normalize_extract_job_minimal_create_envelope_defaults_to_pending() -> None:
+ raw = {"job_id": "v2-extract-x"}
+ job = normalize_extract_job(raw)
+ assert job.job_id == "v2-extract-x"
+ assert job.status is JobStatus.PENDING
+ assert job.result is None
+ assert job.error is None
+
+
+def test_normalize_parse_job_unknown_status_defaults_to_pending() -> None:
+ # A new/renamed status from the gateway must not crash the normalizer;
+ # the original raw status is preserved in job.raw for inspection.
+ raw = {"job_id": "p", "status": "some_brand_new_status"}
+ job = normalize_parse_job(raw)
+ assert job.status is JobStatus.PENDING
+ assert job.raw["status"] == "some_brand_new_status"
+
+
+def test_normalize_extract_job_unknown_status_defaults_to_pending() -> None:
+ raw = {"job_id": "e", "status": "some_brand_new_status"}
+ job = normalize_extract_job(raw)
+ assert job.status is JobStatus.PENDING
+ assert job.raw["status"] == "some_brand_new_status"
diff --git a/tests/test_v2_schema.py b/tests/test_v2_schema.py
new file mode 100644
index 0000000..4692d7a
--- /dev/null
+++ b/tests/test_v2_schema.py
@@ -0,0 +1,93 @@
+# tests/test_v2_schema.py
+from __future__ import annotations
+
+from typing import Any, Dict, List, cast
+
+import pytest
+from pydantic import Field, BaseModel
+
+from landingai_ade.lib.schema_utils import coerce_schema_to_dict
+
+
+class Invoice(BaseModel):
+ revenue: str = Field(description="Q1 revenue")
+
+
+class LineItem(BaseModel):
+ sku: str = Field(description="Item SKU")
+ quantity: int = Field(description="Quantity ordered")
+
+
+class PurchaseOrder(BaseModel):
+ """Nested model: has a sub-model field, so its schema gets a
+ `definitions`/`$defs` section that must be resolved."""
+
+ order_id: str = Field(description="Order identifier")
+ item: LineItem = Field(description="The line item")
+
+
+def _find_all_keys(obj: Any, key: str) -> List[Any]:
+ """Recursively collect all values for `key` anywhere in a nested dict/list."""
+ found: List[Any] = []
+ if isinstance(obj, dict):
+ for k, v in cast(Dict[Any, Any], obj).items():
+ if k == key:
+ found.append(v)
+ found.extend(_find_all_keys(v, key))
+ elif isinstance(obj, list):
+ for item in cast(List[Any], obj):
+ found.extend(_find_all_keys(item, key))
+ return found
+
+
+def test_coerce_nested_model_is_idempotent_and_fully_resolved() -> None:
+ """Regression test: pydantic v1's `.schema()` is memoized per class and
+ returns the SAME shared mutable dict every call. If `coerce_schema_to_dict`
+ (via `_model_json_schema`) mutates that dict in place instead of deep-copying
+ it first, the first call strips "definitions"/"$defs" from the cached schema,
+ and any subsequent call on the same class blows up with a KeyError in
+ `_resolve_refs` (or leaves an unresolved "$ref"). Calling this twice on the
+ same class is the intended usage pattern (e.g. reusing a schema class across
+ multiple `client.v2.extract` / `extract_jobs.create` calls), so both calls
+ must succeed and be fully resolved.
+ """
+ for _ in range(2):
+ out = coerce_schema_to_dict(PurchaseOrder)
+ assert out["type"] == "object"
+ assert "order_id" in out["properties"]
+ assert "item" in out["properties"]
+
+ # No dangling $ref anywhere (nested sub-schema must be fully inlined),
+ # and the nested model's own fields must actually be present.
+ assert _find_all_keys(out, "$ref") == []
+ all_property_maps = _find_all_keys(out, "properties")
+ assert any("sku" in props for props in all_property_maps)
+ assert any("quantity" in props for props in all_property_maps)
+
+ # No leftover definitions/$defs anywhere in the resolved output.
+ assert "$defs" not in out
+ assert "definitions" not in out
+
+
+def test_coerce_from_pydantic_model() -> None:
+ out = coerce_schema_to_dict(Invoice)
+ assert out["type"] == "object"
+ assert "revenue" in out["properties"]
+ assert "$defs" not in out # refs resolved & stripped
+
+
+def test_coerce_from_dict_passthrough() -> None:
+ d = {"type": "object", "properties": {"a": {"type": "string"}}}
+ assert coerce_schema_to_dict(d) == d
+
+
+def test_coerce_from_json_string() -> None:
+ out = coerce_schema_to_dict('{"type": "object", "properties": {}}')
+ assert out == {"type": "object", "properties": {}}
+
+
+def test_coerce_rejects_garbage() -> None:
+ with pytest.raises((ValueError, TypeError)):
+ coerce_schema_to_dict("not json")
+ with pytest.raises(TypeError):
+ coerce_schema_to_dict(123) # type: ignore[arg-type]
diff --git a/tests/test_v2_types.py b/tests/test_v2_types.py
new file mode 100644
index 0000000..1ea4cb0
--- /dev/null
+++ b/tests/test_v2_types.py
@@ -0,0 +1,112 @@
+# tests/test_v2_types.py
+from __future__ import annotations
+
+from datetime import datetime
+
+from landingai_ade.types.v2 import (
+ Job,
+ JobError,
+ JobStatus,
+ V2ExtractResult,
+ V2ParseResponse,
+ V2FileUploadResponse,
+)
+
+
+def test_job_status_enum_values() -> None:
+ assert JobStatus.COMPLETED.value == "completed"
+ assert set(JobStatus) >= {
+ JobStatus.PENDING,
+ JobStatus.PROCESSING,
+ JobStatus.COMPLETED,
+ JobStatus.FAILED,
+ JobStatus.CANCELLED,
+ }
+
+
+def test_job_is_terminal() -> None:
+ running = Job(job_id="j1", status=JobStatus.PROCESSING)
+ done = Job(job_id="j1", status=JobStatus.COMPLETED)
+ failed = Job(job_id="j1", status=JobStatus.FAILED)
+ assert running.is_terminal is False
+ assert done.is_terminal is True
+ assert failed.is_terminal is True
+
+
+def test_job_holds_typed_result_and_error() -> None:
+ job = Job(
+ job_id="j1",
+ status=JobStatus.FAILED,
+ created_at=datetime(2026, 1, 1),
+ error=JobError(code="internal_error", message="boom"),
+ raw={"org_id": "o1"},
+ )
+ assert job.error is not None and job.error.code == "internal_error"
+ assert job.raw["org_id"] == "o1"
+
+
+def test_job_raw_default_is_independent_per_instance() -> None:
+ job_a = Job(job_id="j1", status=JobStatus.PENDING)
+ job_b = Job(job_id="j2", status=JobStatus.PENDING)
+ job_a.raw["org_id"] = "o1"
+ assert job_b.raw == {}
+ assert job_a.raw is not job_b.raw
+
+
+def test_job_raw_default_is_independent_per_instance_via_construct() -> None:
+ # BaseModel.construct()/model_construct() fills in missing fields via
+ # field_get_default() without deep-copying. Prove that the raw dict's
+ # default_factory=dict still produces a fresh, independent dict per
+ # instance on this fast/unvalidated construction path too.
+ job_a = Job.construct(job_id="j1", status=JobStatus.PENDING)
+ job_b = Job.construct(job_id="j2", status=JobStatus.PENDING)
+ job_a.raw["org_id"] = "o1"
+ assert job_b.raw == {}
+ assert job_a.raw is not job_b.raw
+
+
+def test_extract_result_parses_nested_metadata() -> None:
+ r = V2ExtractResult(
+ extraction={"revenue": "1M"},
+ extraction_metadata={"revenue": {"value": "1M", "spans": []}},
+ markdown="# doc",
+ metadata={"job_id": "j1", "version": "extract-1", "duration_ms": 12}, # type: ignore[arg-type]
+ )
+ assert r.metadata.job_id == "j1"
+ assert r.metadata.credit_usage == 0.0 # default
+
+
+def test_parse_response_tolerates_loose_shape() -> None:
+ r = V2ParseResponse(
+ markdown="# hi",
+ structure=[{"type": "text"}],
+ metadata={ # type: ignore[arg-type]
+ "req_id": "r1",
+ "job_id": "j1",
+ "model_version": "dpt-3",
+ "page_count": 2,
+ "failed_pages": [],
+ "billing": {"service_tier": "standard", "total_credits": 3.0},
+ },
+ )
+ assert r.markdown == "# hi"
+ assert r.metadata is not None and r.metadata.billing is not None
+ assert r.metadata.billing.total_credits == 3.0
+
+
+def test_parse_response_retains_unknown_fields() -> None:
+ r = V2ParseResponse(markdown="# hi", surprise_field="x") # type: ignore[call-arg]
+ assert r.to_dict()["surprise_field"] == "x"
+
+ e = V2ExtractResult(
+ extraction={},
+ extraction_metadata={},
+ markdown="doc",
+ metadata={"job_id": "j1", "version": "extract-1", "duration_ms": 12}, # type: ignore[arg-type]
+ another_surprise=42, # type: ignore[call-arg]
+ )
+ assert e.to_dict()["another_surprise"] == 42
+
+
+def test_file_upload_response() -> None:
+ assert V2FileUploadResponse(file_ref="abc").file_ref == "abc"
diff --git a/tests/test_v2_waiter.py b/tests/test_v2_waiter.py
new file mode 100644
index 0000000..ec64f01
--- /dev/null
+++ b/tests/test_v2_waiter.py
@@ -0,0 +1,291 @@
+from __future__ import annotations
+
+from typing import List, Callable, Optional
+
+import pytest
+
+from landingai_ade.types.v2 import Job, JobError, JobStatus
+from landingai_ade.resources.v2 import _base
+from landingai_ade.lib.v2_errors import JobFailedError, JobWaitTimeoutError
+from landingai_ade.resources.v2._base import (
+ JobList,
+ poll_until_terminal,
+ apoll_until_terminal,
+)
+
+
+def _job(status: JobStatus, job_id: str = "job-1", error: Optional[JobError] = None) -> Job:
+ return Job(job_id=job_id, status=status, error=error)
+
+
+class FakeClock:
+ """A fake monotonic clock + sleep recorder so waiter tests never sleep for real."""
+
+ def __init__(self, times: Optional[List[float]] = None, start: float = 0.0) -> None:
+ self._times = list(times) if times is not None else None
+ self.t = start
+ self.sleeps: List[float] = []
+
+ def monotonic(self) -> float:
+ if self._times is not None:
+ if len(self._times) > 1:
+ return self._times.pop(0)
+ return self._times[0]
+ return self.t
+
+ def sleep(self, seconds: float) -> None:
+ self.sleeps.append(seconds)
+ self.t += seconds
+
+ async def asleep(self, seconds: float) -> None:
+ self.sleep(seconds)
+
+
+def _get_job_sequence(jobs: List[Job]) -> Callable[[], Job]:
+ it = iter(jobs)
+
+ def _get() -> Job:
+ return next(it)
+
+ return _get
+
+
+# --------------------------------------------------------------------------
+# poll_until_terminal (sync)
+# --------------------------------------------------------------------------
+
+
+def test_poll_until_terminal_returns_immediately_when_already_terminal() -> None:
+ clock = FakeClock()
+ job = _job(JobStatus.COMPLETED)
+
+ result = poll_until_terminal(
+ _get_job_sequence([job]),
+ monotonic=clock.monotonic,
+ sleep=clock.sleep,
+ timeout=10.0,
+ poll_interval=None,
+ raise_on_failure=False,
+ )
+
+ assert result is job
+ assert clock.sleeps == []
+
+
+def test_poll_until_terminal_backs_off_between_polls() -> None:
+ clock = FakeClock()
+ jobs = [_job(JobStatus.PENDING), _job(JobStatus.PROCESSING), _job(JobStatus.COMPLETED)]
+
+ result = poll_until_terminal(
+ _get_job_sequence(jobs),
+ monotonic=clock.monotonic,
+ sleep=clock.sleep,
+ timeout=100.0,
+ poll_interval=None,
+ raise_on_failure=False,
+ )
+
+ assert result.status == JobStatus.COMPLETED
+ # DEFAULT_POLL_INITIAL=1.0, next delay = min(1.0*1.5, 10.0) = 1.5
+ assert clock.sleeps == [1.0, 1.5]
+
+
+def test_poll_until_terminal_uses_fixed_poll_interval() -> None:
+ clock = FakeClock()
+ jobs = [_job(JobStatus.PENDING), _job(JobStatus.PENDING), _job(JobStatus.COMPLETED)]
+
+ poll_until_terminal(
+ _get_job_sequence(jobs),
+ monotonic=clock.monotonic,
+ sleep=clock.sleep,
+ timeout=100.0,
+ poll_interval=2.0,
+ raise_on_failure=False,
+ )
+
+ assert clock.sleeps == [2.0, 2.0]
+
+
+def test_poll_until_terminal_raises_timeout() -> None:
+ # First monotonic() call establishes the deadline (0 + timeout); the second call
+ # (post get_job) reports we're already past it, so we raise without sleeping.
+ clock = FakeClock(times=[0.0, 100.0])
+ job = _job(JobStatus.PENDING)
+
+ with pytest.raises(JobWaitTimeoutError, match="did not finish within"):
+ poll_until_terminal(
+ _get_job_sequence([job]),
+ monotonic=clock.monotonic,
+ sleep=clock.sleep,
+ timeout=10.0,
+ poll_interval=None,
+ raise_on_failure=False,
+ )
+
+
+def test_poll_until_terminal_raise_on_failure_true_raises() -> None:
+ clock = FakeClock()
+ job = _job(JobStatus.FAILED, error=JobError(code="bad_input", message="boom"))
+
+ with pytest.raises(JobFailedError, match="boom"):
+ poll_until_terminal(
+ _get_job_sequence([job]),
+ monotonic=clock.monotonic,
+ sleep=clock.sleep,
+ timeout=10.0,
+ poll_interval=None,
+ raise_on_failure=True,
+ )
+
+
+def test_poll_until_terminal_raise_on_failure_false_returns_job() -> None:
+ clock = FakeClock()
+ job = _job(JobStatus.FAILED, error=JobError(code="bad_input", message="boom"))
+
+ result = poll_until_terminal(
+ _get_job_sequence([job]),
+ monotonic=clock.monotonic,
+ sleep=clock.sleep,
+ timeout=10.0,
+ poll_interval=None,
+ raise_on_failure=False,
+ )
+
+ assert result is job
+ assert result.status == JobStatus.FAILED
+
+
+# --------------------------------------------------------------------------
+# apoll_until_terminal (async) -- anyio.sleep is patched so no real time passes.
+# --------------------------------------------------------------------------
+
+
+async def test_apoll_until_terminal_returns_immediately_when_already_terminal(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ clock = FakeClock()
+ monkeypatch.setattr(_base.anyio, "sleep", clock.asleep)
+ job = _job(JobStatus.COMPLETED)
+
+ async def _get() -> Job:
+ return job
+
+ result = await apoll_until_terminal(
+ _get,
+ monotonic=clock.monotonic,
+ timeout=10.0,
+ poll_interval=None,
+ raise_on_failure=False,
+ )
+
+ assert result is job
+ assert clock.sleeps == []
+
+
+async def test_apoll_until_terminal_backs_off_between_polls(monkeypatch: pytest.MonkeyPatch) -> None:
+ clock = FakeClock()
+ monkeypatch.setattr(_base.anyio, "sleep", clock.asleep)
+ jobs = iter([_job(JobStatus.PENDING), _job(JobStatus.PROCESSING), _job(JobStatus.COMPLETED)])
+
+ async def _get() -> Job:
+ return next(jobs)
+
+ result = await apoll_until_terminal(
+ _get,
+ monotonic=clock.monotonic,
+ timeout=100.0,
+ poll_interval=None,
+ raise_on_failure=False,
+ )
+
+ assert result.status == JobStatus.COMPLETED
+ assert clock.sleeps == [1.0, 1.5]
+
+
+async def test_apoll_until_terminal_raises_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
+ clock = FakeClock(times=[0.0, 100.0])
+ monkeypatch.setattr(_base.anyio, "sleep", clock.asleep)
+ job = _job(JobStatus.PENDING)
+
+ async def _get() -> Job:
+ return job
+
+ with pytest.raises(JobWaitTimeoutError, match="did not finish within"):
+ await apoll_until_terminal(
+ _get,
+ monotonic=clock.monotonic,
+ timeout=10.0,
+ poll_interval=None,
+ raise_on_failure=False,
+ )
+
+
+async def test_apoll_until_terminal_raise_on_failure_true_raises(monkeypatch: pytest.MonkeyPatch) -> None:
+ clock = FakeClock()
+ monkeypatch.setattr(_base.anyio, "sleep", clock.asleep)
+ job = _job(JobStatus.CANCELLED, error=JobError(message="cancelled by user"))
+
+ async def _get() -> Job:
+ return job
+
+ with pytest.raises(JobFailedError, match="cancelled by user"):
+ await apoll_until_terminal(
+ _get,
+ monotonic=clock.monotonic,
+ timeout=10.0,
+ poll_interval=None,
+ raise_on_failure=True,
+ )
+
+
+# --------------------------------------------------------------------------
+# JobList
+# --------------------------------------------------------------------------
+
+
+def test_joblist_build_defaults_when_envelope_empty() -> None:
+ jobs = [_job(JobStatus.COMPLETED, job_id="a"), _job(JobStatus.PENDING, job_id="b")]
+
+ result = JobList.build(jobs)
+
+ assert isinstance(result, list)
+ assert list(result) == jobs
+ assert result.has_more is False
+ assert result.org_id is None
+ assert result.page is None
+ assert result.page_size is None
+
+
+def test_joblist_build_captures_full_envelope() -> None:
+ jobs = [_job(JobStatus.COMPLETED)]
+
+ result = JobList.build(jobs, has_more=True, org_id="org-123", page=2, page_size=50)
+
+ assert result.has_more is True
+ assert result.org_id == "org-123"
+ assert result.page == 2
+ assert result.page_size == 50
+
+
+def test_joblist_build_ignores_wrong_typed_envelope_values() -> None:
+ jobs = [_job(JobStatus.COMPLETED)]
+
+ result = JobList.build(jobs, org_id=123, page="two", page_size=None)
+
+ assert result.org_id is None
+ assert result.page is None
+ assert result.page_size is None
+
+
+def test_joblist_build_has_more_rejects_truthy_string() -> None:
+ # A wrong-typed (but truthy) string must not be coerced via bool(); only a
+ # real bool should ever set has_more to True.
+ result = JobList.build([], has_more="false")
+
+ assert result.has_more is False
+
+
+def test_joblist_build_has_more_accepts_real_bool() -> None:
+ result = JobList.build([], has_more=True)
+
+ assert result.has_more is True