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