From 6e12d7fadd2fee0fe58fc12295f3aff467eece50 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 13:32:42 +0800 Subject: [PATCH 01/26] feat(v2): resolve dual V1/V2 base URLs from environment Adds a V2_ENVIRONMENTS map and _resolve_v2_base_url() helper to LandingAIADE and AsyncLandingAIADE, resolving self._v2_base_url alongside the existing V1 base_url. Precedence: explicit v2_base_url kwarg > LANDINGAI_ADE_V2_BASE_URL env var > environment map > (if only V1 base_url was set) follow it > production default. Also widens ENVIRONMENTS/environment Literal to include staging and dev, and adds LANDINGAI_ADE_ENVIRONMENT env var support. Foundation for the upcoming client.v2 sub-client (V2 parse/extract). --- src/landingai_ade/_client.py | 85 ++++++++++++++++++++++++++++++++---- tests/test_v2_environment.py | 53 ++++++++++++++++++++++ 2 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 tests/test_v2_environment.py diff --git a/src/landingai_ade/_client.py b/src/landingai_ade/_client.py index 4f51920..2024414 100644 --- a/src/landingai_ade/_client.py +++ b/src/landingai_ade/_client.py @@ -87,6 +87,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://aide.landing.ai", + "eu": "https://aide.eu-west-1.landing.ai", + "staging": "https://aide.staging.landing.ai", + "dev": "https://aide.dev.landing.ai", } @@ -148,18 +157,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 +232,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 +264,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, @@ -271,8 +324,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 +361,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 +903,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 +942,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 +974,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, @@ -967,8 +1034,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 +1071,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/tests/test_v2_environment.py b/tests/test_v2_environment.py new file mode 100644 index 0000000..7f822ed --- /dev/null +++ b/tests/test_v2_environment.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing_extensions import Literal + +import pytest + +from landingai_ade import LandingAIADE + +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://aide.landing.ai" + + +@pytest.mark.parametrize( + "env,v1,v2", + [ + ("production", "https://api.va.landing.ai", "https://aide.landing.ai"), + ("eu", "https://api.va.eu-west-1.landing.ai", "https://aide.eu-west-1.landing.ai"), + ("staging", "https://api.va.staging.landing.ai", "https://aide.staging.landing.ai"), + ("dev", "https://api.va.dev.landing.ai", "https://aide.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://aide.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" From d28a50f9c3eed6dd34c8fa6c8c5d8e7e41b0aca0 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 13:41:20 +0800 Subject: [PATCH 02/26] feat(v2): add V2 response and unified Job types Hand-authored pydantic types for the V2 sub-client: JobStatus/JobError/Job (one normalized job shape with is_terminal), V2ParseResponse+metadata+billing, V2ExtractResult+metadata, and V2FileUploadResponse. All subclass the SDK's _models.BaseModel for pydantic v1/v2 compat and permissive extra-field retention. --- src/landingai_ade/types/v2/__init__.py | 13 ++++ .../types/v2/extract_response.py | 22 ++++++ .../types/v2/file_upload_response.py | 13 ++++ src/landingai_ade/types/v2/job.py | 41 ++++++++++ src/landingai_ade/types/v2/parse_response.py | 34 ++++++++ tests/test_v2_types.py | 78 +++++++++++++++++++ 6 files changed, 201 insertions(+) create mode 100644 src/landingai_ade/types/v2/__init__.py create mode 100644 src/landingai_ade/types/v2/extract_response.py create mode 100644 src/landingai_ade/types/v2/file_upload_response.py create mode 100644 src/landingai_ade/types/v2/job.py create mode 100644 src/landingai_ade/types/v2/parse_response.py create mode 100644 tests/test_v2_types.py 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..8013cb3 --- /dev/null +++ b/src/landingai_ade/types/v2/job.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from enum import Enum +from typing import Dict, Optional +from datetime import datetime + +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] = {} + + @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/test_v2_types.py b/tests/test_v2_types.py new file mode 100644 index 0000000..4d0e728 --- /dev/null +++ b/tests/test_v2_types.py @@ -0,0 +1,78 @@ +# 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_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_file_upload_response() -> None: + assert V2FileUploadResponse(file_ref="abc").file_ref == "abc" From c746dee97fb2f23273310ebabb2c672bf9dd61c5 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 13:49:15 +0800 Subject: [PATCH 03/26] test: assert V2ParseResponse/V2ExtractResult retain unknown fields test_parse_response_tolerates_loose_shape only exercised fields already declared on the models, so it never proved the permissive extra="allow" behavior. Add test_parse_response_retains_unknown_fields, which passes an undeclared kwarg to each model and asserts it survives via .to_dict(). --- tests/test_v2_types.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_v2_types.py b/tests/test_v2_types.py index 4d0e728..d2d3718 100644 --- a/tests/test_v2_types.py +++ b/tests/test_v2_types.py @@ -74,5 +74,19 @@ def test_parse_response_tolerates_loose_shape() -> 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" From b37cf9bccdde2f9ac3815dec08cbde0c8eebe6a9 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 13:55:38 +0800 Subject: [PATCH 04/26] feat(v2): normalize parse/extract job envelopes into one Job shape Adds normalize_parse_job and normalize_extract_job, collapsing the divergent parse (epoch timestamps, failure_reason string) and extract (ISO timestamps, error object) job envelopes into the unified Job type. --- src/landingai_ade/resources/v2/__init__.py | 1 + src/landingai_ade/resources/v2/_normalize.py | 73 ++++++++++++++++++++ tests/test_v2_normalize.py | 64 +++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 src/landingai_ade/resources/v2/__init__.py create mode 100644 src/landingai_ade/resources/v2/_normalize.py create mode 100644 tests/test_v2_normalize.py diff --git a/src/landingai_ade/resources/v2/__init__.py b/src/landingai_ade/resources/v2/__init__.py new file mode 100644 index 0000000..0f8ad4a --- /dev/null +++ b/src/landingai_ade/resources/v2/__init__.py @@ -0,0 +1 @@ +# v2 resources package diff --git a/src/landingai_ade/resources/v2/_normalize.py b/src/landingai_ade/resources/v2/_normalize.py new file mode 100644 index 0000000..badfedb --- /dev/null +++ b/src/landingai_ade/resources/v2/_normalize.py @@ -0,0 +1,73 @@ +# 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 normalize_parse_job(raw: Mapping[str, Any]) -> Job: + status = JobStatus(raw["status"]) + 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)) + + return Job( + job_id=str(raw["job_id"]), + status=status, + created_at=_ts(raw.get("created_at") or raw.get("received_at")), + 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 = JobStatus(raw["status"]) + 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/tests/test_v2_normalize.py b/tests/test_v2_normalize.py new file mode 100644 index 0000000..c8aeeb0 --- /dev/null +++ b/tests/test_v2_normalize.py @@ -0,0 +1,64 @@ +# tests/test_v2_normalize.py +from __future__ import annotations + +from typing import Any, Dict + +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_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" From e93656cf46ef321461d15175732cccc6778a0d11 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 14:04:54 +0800 Subject: [PATCH 05/26] feat(v2): accept pydantic model / dict / json string as extract schema Add coerce_schema_to_dict (and helper pydantic_to_schema_dict) to lib/schema_utils.py so v2.extract can take a BaseModel subclass, a dict/Mapping, or a JSON string and normalize it to a plain dict for the request body. --- src/landingai_ade/lib/__init__.py | 3 ++- src/landingai_ade/lib/schema_utils.py | 28 ++++++++++++++++++++- tests/test_v2_schema.py | 35 +++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 tests/test_v2_schema.py 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..10e16d9 100644 --- a/src/landingai_ade/lib/schema_utils.py +++ b/src/landingai_ade/lib/schema_utils.py @@ -7,7 +7,7 @@ import copy import json -from typing import Any, Dict, Type +from typing import Any, Dict, Type, Union, Mapping, cast from pydantic import BaseModel @@ -72,3 +72,29 @@ def pydantic_to_json_schema(model: Type[BaseModel]) -> str: 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 hasattr(model, "model_json_schema"): + raise TypeError("model must be a Pydantic BaseModel subclass") + schema = model.model_json_schema() + 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/tests/test_v2_schema.py b/tests/test_v2_schema.py new file mode 100644 index 0000000..a07d638 --- /dev/null +++ b/tests/test_v2_schema.py @@ -0,0 +1,35 @@ +# tests/test_v2_schema.py +from __future__ import annotations + +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") + + +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] From 65c82ccabc57b64c90ed85d4b77b6940549e7612 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 14:42:31 +0800 Subject: [PATCH 06/26] feat(v2): add V2 error types and 504 sync-timeout wrapper Co-Authored-By: Claude Opus 4.8 (1M context) --- src/landingai_ade/lib/v2_errors.py | 35 +++++++++++++++++++++++++++++ tests/test_v2_errors.py | 36 ++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/landingai_ade/lib/v2_errors.py create mode 100644 tests/test_v2_errors.py diff --git a/src/landingai_ade/lib/v2_errors.py b/src/landingai_ade/lib/v2_errors.py new file mode 100644 index 0000000..47af21c --- /dev/null +++ b/src/landingai_ade/lib/v2_errors.py @@ -0,0 +1,35 @@ +# 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 (`*_jobs.create` + `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 (`.jobs.create(...)` then `.jobs.wait(...)`) " + "for long-running documents." + ) from exc diff --git a/tests/test_v2_errors.py b/tests/test_v2_errors.py new file mode 100644 index 0000000..03543fd --- /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://aide.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) From ebb069545a4ac524a8a25b875d8abbad47a3d638 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 14:57:49 +0800 Subject: [PATCH 07/26] feat(v2): add v2 sub-client container, absolute-URL mixin, and job waiter Adds V2ResourceMixin (_v2_url), poll_until_terminal/apoll_until_terminal (fake-clock-testable backoff waiters), and JobList to resources/v2/_base.py; wires an empty V2Resource/AsyncV2Resource container onto client.v2 via lazy cached-property imports mirroring parse_jobs. V2Resource intentionally has no sub-resource properties yet: pyright (strict) resolves imports statically even inside a lazy-import function body, so referencing the not-yet-created files.py/parse.py/extract.py modules (Tasks 7-11) would break `./scripts/lint` today. Each of those tasks adds its own lazily-imported cached-property to v2.py once its backing module exists. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/landingai_ade/_client.py | 13 + src/landingai_ade/resources/v2/__init__.py | 1 + src/landingai_ade/resources/v2/_base.py | 131 ++++++++++ src/landingai_ade/resources/v2/v2.py | 21 ++ tests/api_resources/v2/__init__.py | 0 tests/test_v2_environment.py | 23 ++ tests/test_v2_waiter.py | 277 +++++++++++++++++++++ 7 files changed, 466 insertions(+) create mode 100644 src/landingai_ade/resources/v2/_base.py create mode 100644 src/landingai_ade/resources/v2/v2.py create mode 100644 tests/api_resources/v2/__init__.py create mode 100644 tests/test_v2_waiter.py diff --git a/src/landingai_ade/_client.py b/src/landingai_ade/_client.py index 2024414..02c08ce 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") @@ -292,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) @@ -1002,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) diff --git a/src/landingai_ade/resources/v2/__init__.py b/src/landingai_ade/resources/v2/__init__.py index 0f8ad4a..4888d25 100644 --- a/src/landingai_ade/resources/v2/__init__.py +++ b/src/landingai_ade/resources/v2/__init__.py @@ -1 +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..c5835b8 --- /dev/null +++ b/src/landingai_ade/resources/v2/_base.py @@ -0,0 +1,131 @@ +# 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) + out.has_more = bool(envelope.get("has_more", 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/v2.py b/src/landingai_ade/resources/v2/v2.py new file mode 100644 index 0000000..6963d50 --- /dev/null +++ b/src/landingai_ade/resources/v2/v2.py @@ -0,0 +1,21 @@ +# src/landingai_ade/resources/v2/v2.py +from __future__ import annotations + +from ._base import V2ResourceMixin +from ..._resource import SyncAPIResource, AsyncAPIResource + +__all__ = ["V2Resource", "AsyncV2Resource"] + + +class V2Resource(SyncAPIResource, V2ResourceMixin): + """Container for the V2 (ADE) surface: ``client.v2.``. + + Sub-resources (``files``, ``parse``, ``parse_jobs``, ``extract``, ``extract_jobs``) + are attached as cached properties by later tasks (7-11), each doing its own lazy + import inside the property body -- mirroring ``LandingAIADE.parse_jobs`` -- so that + this module keeps importing standalone regardless of which sub-resources exist yet. + """ + + +class AsyncV2Resource(AsyncAPIResource, V2ResourceMixin): + """Async mirror of :class:`V2Resource`.""" 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/test_v2_environment.py b/tests/test_v2_environment.py index 7f822ed..95d0a7e 100644 --- a/tests/test_v2_environment.py +++ b/tests/test_v2_environment.py @@ -2,9 +2,12 @@ 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" @@ -51,3 +54,23 @@ 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) + + +@pytest.mark.skip(reason="files.upload lands in Task 7") +@respx.mock +def test_v2_subclient_routes_to_v2_host() -> None: + c = LandingAIADE(apikey=APIKEY, environment="production") + route = respx.post("https://aide.landing.ai/v1/files").mock( + return_value=httpx.Response(200, json={"file_ref": "ref-123"}) + ) + ref = c.v2.files.upload(file=b"hello") # type: ignore[attr-defined] + assert route.called + assert ref == "ref-123" diff --git a/tests/test_v2_waiter.py b/tests/test_v2_waiter.py new file mode 100644 index 0000000..26b3b44 --- /dev/null +++ b/tests/test_v2_waiter.py @@ -0,0 +1,277 @@ +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 From 123dc7a43129a2876f8e44b6b8352b925f2b5561 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 15:05:24 +0800 Subject: [PATCH 08/26] feat(v2): add client.v2.files.upload staging on the ADE host Wires a `files` cached_property (lazy import) into both V2Resource and AsyncV2Resource containers, and unskips the Task 6 routing test now that `.files` exists. --- src/landingai_ade/resources/v2/files.py | 73 +++++++++++++++++++++++++ src/landingai_ade/resources/v2/v2.py | 22 +++++++- tests/api_resources/v2/test_files.py | 32 +++++++++++ tests/test_v2_environment.py | 3 +- 4 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 src/landingai_ade/resources/v2/files.py create mode 100644 tests/api_resources/v2/test_files.py 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/v2.py b/src/landingai_ade/resources/v2/v2.py index 6963d50..a65f875 100644 --- a/src/landingai_ade/resources/v2/v2.py +++ b/src/landingai_ade/resources/v2/v2.py @@ -1,21 +1,39 @@ # src/landingai_ade/resources/v2/v2.py from __future__ import annotations +from typing import TYPE_CHECKING + from ._base import V2ResourceMixin +from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource +if TYPE_CHECKING: + from .files import FilesResource, AsyncFilesResource + __all__ = ["V2Resource", "AsyncV2Resource"] class V2Resource(SyncAPIResource, V2ResourceMixin): """Container for the V2 (ADE) surface: ``client.v2.``. - Sub-resources (``files``, ``parse``, ``parse_jobs``, ``extract``, ``extract_jobs``) - are attached as cached properties by later tasks (7-11), each doing its own lazy + Sub-resources (``parse``, ``parse_jobs``, ``extract``, ``extract_jobs``) are + attached as cached properties by later tasks (8-11), each doing its own lazy import inside the property body -- mirroring ``LandingAIADE.parse_jobs`` -- so that this module keeps importing standalone regardless of which sub-resources exist yet. """ + @cached_property + def files(self) -> FilesResource: + from .files import FilesResource + + return FilesResource(self._client) + class AsyncV2Resource(AsyncAPIResource, V2ResourceMixin): """Async mirror of :class:`V2Resource`.""" + + @cached_property + def files(self) -> AsyncFilesResource: + from .files import AsyncFilesResource + + return AsyncFilesResource(self._client) diff --git a/tests/api_resources/v2/test_files.py b/tests/api_resources/v2/test_files.py new file mode 100644 index 0000000..3bab619 --- /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://aide.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://aide.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/test_v2_environment.py b/tests/test_v2_environment.py index 95d0a7e..9c77877 100644 --- a/tests/test_v2_environment.py +++ b/tests/test_v2_environment.py @@ -64,13 +64,12 @@ def test_v2_attribute_exists() -> None: assert isinstance(c.v2, V2Resource) -@pytest.mark.skip(reason="files.upload lands in Task 7") @respx.mock def test_v2_subclient_routes_to_v2_host() -> None: c = LandingAIADE(apikey=APIKEY, environment="production") route = respx.post("https://aide.landing.ai/v1/files").mock( return_value=httpx.Response(200, json={"file_ref": "ref-123"}) ) - ref = c.v2.files.upload(file=b"hello") # type: ignore[attr-defined] + ref = c.v2.files.upload(file=b"hello") assert route.called assert ref == "ref-123" From 7ad9a12d99b43ba0660f4b2e6df0192e9a47b897 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 15:14:43 +0800 Subject: [PATCH 09/26] feat(v2): add client.v2.parse sync parse with 206 + save_to + 504 handling Adds ParseResource/AsyncParseResource (multipart POST to /v2/parse, JSON-encoded `options` form field, 504 -> V2SyncTimeoutError via raise_if_sync_timeout, save_to parity with V1 parse), and wires client.v2.parse(...) / await client.v2.parse(...) into the V2 containers with an explicit typed signature. --- src/landingai_ade/resources/v2/parse.py | 174 ++++++++++++++++++++++++ src/landingai_ade/resources/v2/v2.py | 80 ++++++++++- tests/api_resources/v2/test_parse.py | 75 ++++++++++ 3 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 src/landingai_ade/resources/v2/parse.py create mode 100644 tests/api_resources/v2/test_parse.py diff --git a/src/landingai_ade/resources/v2/parse.py b/src/landingai_ade/resources/v2/parse.py new file mode 100644 index 0000000..a241a26 --- /dev/null +++ b/src/landingai_ade/resources/v2/parse.py @@ -0,0 +1,174 @@ +# src/landingai_ade/resources/v2/parse.py +from __future__ import annotations + +import json +from typing import Any, Mapping, Optional, cast +from pathlib import Path + +import httpx + +from ._base import V2ResourceMixin +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 V2ParseResponse +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"] + + +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): + 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. + return {key: value for key, value in raw_body.items() if is_given(value)} + + +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 diff --git a/src/landingai_ade/resources/v2/v2.py b/src/landingai_ade/resources/v2/v2.py index a65f875..3766fbb 100644 --- a/src/landingai_ade/resources/v2/v2.py +++ b/src/landingai_ade/resources/v2/v2.py @@ -1,14 +1,20 @@ # src/landingai_ade/resources/v2/v2.py from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Mapping, Optional +from pathlib import Path + +import httpx 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 V2ParseResponse from ..._resource import SyncAPIResource, AsyncAPIResource if TYPE_CHECKING: from .files import FilesResource, AsyncFilesResource + from .parse import ParseResource, AsyncParseResource __all__ = ["V2Resource", "AsyncV2Resource"] @@ -28,6 +34,42 @@ def files(self) -> FilesResource: return FilesResource(self._client) + @cached_property + def _parse(self) -> ParseResource: + from .parse import ParseResource + + return ParseResource(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, + ) + class AsyncV2Resource(AsyncAPIResource, V2ResourceMixin): """Async mirror of :class:`V2Resource`.""" @@ -37,3 +79,39 @@ 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) + + 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, + ) diff --git a/tests/api_resources/v2/test_parse.py b/tests/api_resources/v2/test_parse.py new file mode 100644 index 0000000..dc781d0 --- /dev/null +++ b/tests/api_resources/v2/test_parse.py @@ -0,0 +1,75 @@ +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 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://aide.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_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://aide.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://aide.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://aide.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://aide.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" From 2a60b91d3f4c3b1eb7d289a0aea2895cf50b4adf Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 15:22:54 +0800 Subject: [PATCH 10/26] test(v2): add regression test for parse multipart sentinel filtering Guard the is_given(...) filter in _build_parse_body with a respx test asserting unset Omit/NotGiven sentinels never leak into the multipart body; also refresh the stale V2Resource docstring now that files/parse are wired up. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/landingai_ade/resources/v2/v2.py | 9 +++++---- tests/api_resources/v2/test_parse.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/landingai_ade/resources/v2/v2.py b/src/landingai_ade/resources/v2/v2.py index 3766fbb..425e6b4 100644 --- a/src/landingai_ade/resources/v2/v2.py +++ b/src/landingai_ade/resources/v2/v2.py @@ -22,10 +22,11 @@ class V2Resource(SyncAPIResource, V2ResourceMixin): """Container for the V2 (ADE) surface: ``client.v2.``. - Sub-resources (``parse``, ``parse_jobs``, ``extract``, ``extract_jobs``) are - attached as cached properties by later tasks (8-11), each doing its own lazy - import inside the property body -- mirroring ``LandingAIADE.parse_jobs`` -- so that - this module keeps importing standalone regardless of which sub-resources exist yet. + ``files`` and ``parse`` 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. ``extract`` and the job-polling resources are + attached by later tasks following the same pattern. """ @cached_property diff --git a/tests/api_resources/v2/test_parse.py b/tests/api_resources/v2/test_parse.py index dc781d0..f5ac01a 100644 --- a/tests/api_resources/v2/test_parse.py +++ b/tests/api_resources/v2/test_parse.py @@ -34,6 +34,24 @@ def test_parse_sync_ok_routes_to_v2_and_sends_options_json() -> None: 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://aide.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_206_returns_response_with_failed_pages() -> None: client = LandingAIADE(apikey=APIKEY) From 10eae7683e06a1176c7142f7105d5cb391ef55d0 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 15:30:23 +0800 Subject: [PATCH 11/26] feat(v2): add client.v2.parse_jobs create/list/get/wait with normalized Job Adds ParseJobsResource/AsyncParseJobsResource to resources/v2/parse.py, wired up as a lazy parse_jobs cached_property on both V2Resource and AsyncV2Resource containers. create/get/list use cast_to=object to get the raw envelope dict back from the transport and normalize it via normalize_parse_job (Task 3); wait() polls to a terminal state using poll_until_terminal/apoll_until_terminal (Task 6). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/landingai_ade/resources/v2/parse.py | 271 +++++++++++++++++++++++- src/landingai_ade/resources/v2/v2.py | 14 +- tests/api_resources/v2/test_parse.py | 99 ++++++++- 3 files changed, 378 insertions(+), 6 deletions(-) diff --git a/src/landingai_ade/resources/v2/parse.py b/src/landingai_ade/resources/v2/parse.py index a241a26..1d13a69 100644 --- a/src/landingai_ade/resources/v2/parse.py +++ b/src/landingai_ade/resources/v2/parse.py @@ -2,23 +2,26 @@ from __future__ import annotations import json -from typing import Any, Mapping, Optional, cast +import time +from typing import Any, Mapping, Callable, Optional, cast from pathlib import Path +from typing_extensions import Literal import httpx -from ._base import V2ResourceMixin +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 V2ParseResponse +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"] +__all__ = ["ParseResource", "AsyncParseResource", "ParseJobsResource", "AsyncParseJobsResource"] def _build_parse_body( @@ -172,3 +175,263 @@ async def run( 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): + body["output_save_url"] = output_save_url + if is_given(priority): + 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) + } + 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): + body["output_save_url"] = output_save_url + if is_given(priority): + 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) + } + 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 index 425e6b4..9fcaa5c 100644 --- a/src/landingai_ade/resources/v2/v2.py +++ b/src/landingai_ade/resources/v2/v2.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from .files import FilesResource, AsyncFilesResource - from .parse import ParseResource, AsyncParseResource + from .parse import ParseResource, ParseJobsResource, AsyncParseResource, AsyncParseJobsResource __all__ = ["V2Resource", "AsyncV2Resource"] @@ -41,6 +41,12 @@ def _parse(self) -> ParseResource: return ParseResource(self._client) + @cached_property + def parse_jobs(self) -> ParseJobsResource: + from .parse import ParseJobsResource + + return ParseJobsResource(self._client) + def parse( self, *, @@ -87,6 +93,12 @@ def _parse(self) -> AsyncParseResource: return AsyncParseResource(self._client) + @cached_property + def parse_jobs(self) -> AsyncParseJobsResource: + from .parse import AsyncParseJobsResource + + return AsyncParseJobsResource(self._client) + async def parse( self, *, diff --git a/tests/api_resources/v2/test_parse.py b/tests/api_resources/v2/test_parse.py index f5ac01a..38dbc01 100644 --- a/tests/api_resources/v2/test_parse.py +++ b/tests/api_resources/v2/test_parse.py @@ -9,7 +9,7 @@ import pytest from landingai_ade import LandingAIADE -from landingai_ade.types.v2 import V2ParseResponse +from landingai_ade.types.v2 import Job, JobStatus, V2ParseResponse from landingai_ade.lib.v2_errors import V2SyncTimeoutError APIKEY = "My Apikey" @@ -91,3 +91,100 @@ async def test_async_parse_sync_ok() -> None: 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://aide.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://aide.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://aide.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://aide.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://aide.landing.ai/v2/parse/jobs").mock( + return_value=httpx.Response(202, json={"job_id": "p1", "status": "pending"}) + ) + respx.get("https://aide.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://aide.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 From 30aba4313c363408c5af80375433dafa4e3c19d1 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 15:38:39 +0800 Subject: [PATCH 12/26] feat(v2): add client.v2.extract sync extract with schema coercion + idempotency --- src/landingai_ade/resources/v2/extract.py | 165 ++++++++++++++++++++++ src/landingai_ade/resources/v2/v2.py | 96 ++++++++++++- tests/api_resources/v2/test_extract.py | 60 ++++++++ 3 files changed, 314 insertions(+), 7 deletions(-) create mode 100644 src/landingai_ade/resources/v2/extract.py create mode 100644 tests/api_resources/v2/test_extract.py diff --git a/src/landingai_ade/resources/v2/extract.py b/src/landingai_ade/resources/v2/extract.py new file mode 100644 index 0000000..80d3602 --- /dev/null +++ b/src/landingai_ade/resources/v2/extract.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from typing import Any, Dict, 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, omit, not_given +from ...types.v2 import V2ExtractResult +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"] + + +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 diff --git a/src/landingai_ade/resources/v2/v2.py b/src/landingai_ade/resources/v2/v2.py index 9fcaa5c..7e4c6db 100644 --- a/src/landingai_ade/resources/v2/v2.py +++ b/src/landingai_ade/resources/v2/v2.py @@ -1,20 +1,22 @@ # src/landingai_ade/resources/v2/v2.py from __future__ import annotations -from typing import TYPE_CHECKING, Mapping, Optional +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 V2ParseResponse +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, AsyncExtractResource __all__ = ["V2Resource", "AsyncV2Resource"] @@ -22,11 +24,11 @@ class V2Resource(SyncAPIResource, V2ResourceMixin): """Container for the V2 (ADE) surface: ``client.v2.``. - ``files`` and ``parse`` 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. ``extract`` and the job-polling resources are - attached by later tasks following the same pattern. + ``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 @@ -77,6 +79,46 @@ def parse( timeout=timeout, ) + @cached_property + def _extract(self) -> ExtractResource: + from .extract import ExtractResource + + return ExtractResource(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`.""" @@ -128,3 +170,43 @@ async def parse( extra_body=extra_body, timeout=timeout, ) + + @cached_property + def _extract(self) -> AsyncExtractResource: + from .extract import AsyncExtractResource + + return AsyncExtractResource(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/tests/api_resources/v2/test_extract.py b/tests/api_resources/v2/test_extract.py new file mode 100644 index 0000000..26da24d --- /dev/null +++ b/tests/api_resources/v2/test_extract.py @@ -0,0 +1,60 @@ +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 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://aide.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://aide.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://aide.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") From 5390b55a7788d3593f700874213c1fb85868c2dd Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 15:49:08 +0800 Subject: [PATCH 13/26] feat(v2): add client.v2.extract_jobs create/list/get/wait --- src/landingai_ade/resources/v2/extract.py | 266 +++++++++++++++++++++- src/landingai_ade/resources/v2/v2.py | 14 +- tests/api_resources/v2/test_extract.py | 49 +++- 3 files changed, 323 insertions(+), 6 deletions(-) diff --git a/src/landingai_ade/resources/v2/extract.py b/src/landingai_ade/resources/v2/extract.py index 80d3602..781e0a5 100644 --- a/src/landingai_ade/resources/v2/extract.py +++ b/src/landingai_ade/resources/v2/extract.py @@ -1,21 +1,24 @@ from __future__ import annotations -from typing import Any, Dict, Type, Union, Mapping, Optional +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 V2ResourceMixin +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 ...types.v2 import V2ExtractResult +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"] +__all__ = ["ExtractResource", "AsyncExtractResource", "ExtractJobsResource", "AsyncExtractJobsResource"] def _build_extract_body( @@ -163,3 +166,258 @@ async def run( 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 value is not omit + } + 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 value is not omit + } + 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/v2.py b/src/landingai_ade/resources/v2/v2.py index 7e4c6db..ee20a69 100644 --- a/src/landingai_ade/resources/v2/v2.py +++ b/src/landingai_ade/resources/v2/v2.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from .files import FilesResource, AsyncFilesResource from .parse import ParseResource, ParseJobsResource, AsyncParseResource, AsyncParseJobsResource - from .extract import ExtractResource, AsyncExtractResource + from .extract import ExtractResource, ExtractJobsResource, AsyncExtractResource, AsyncExtractJobsResource __all__ = ["V2Resource", "AsyncV2Resource"] @@ -85,6 +85,12 @@ def _extract(self) -> ExtractResource: return ExtractResource(self._client) + @cached_property + def extract_jobs(self) -> ExtractJobsResource: + from .extract import ExtractJobsResource + + return ExtractJobsResource(self._client) + def extract( self, *, @@ -177,6 +183,12 @@ def _extract(self) -> AsyncExtractResource: return AsyncExtractResource(self._client) + @cached_property + def extract_jobs(self) -> AsyncExtractJobsResource: + from .extract import AsyncExtractJobsResource + + return AsyncExtractJobsResource(self._client) + async def extract( self, *, diff --git a/tests/api_resources/v2/test_extract.py b/tests/api_resources/v2/test_extract.py index 26da24d..6a49cb7 100644 --- a/tests/api_resources/v2/test_extract.py +++ b/tests/api_resources/v2/test_extract.py @@ -9,7 +9,7 @@ from pydantic import Field, BaseModel from landingai_ade import LandingAIADE -from landingai_ade.types.v2 import V2ExtractResult +from landingai_ade.types.v2 import JobStatus, V2ExtractResult from landingai_ade.lib.v2_errors import V2SyncTimeoutError APIKEY = "My Apikey" @@ -58,3 +58,50 @@ def test_extract_sync_504() -> None: respx.post("https://aide.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://aide.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://aide.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://aide.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://aide.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) From 18cdc4137473d200a7b3e904f70f82b01a120815 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 16:01:13 +0800 Subject: [PATCH 14/26] test(v2): async parity smoke tests; close extract_jobs coverage gaps - test_async_smoke.py: async parse/parse_jobs/extract smoke tests per the Task 12 brief, plus full create/get/wait coverage for AsyncExtractJobsResource (previously untested) using an injected monotonic clock so wait() doesn't sleep in real time. - test_extract.py: add extract_jobs.get("") empty-job_id ValueError test and extract_jobs.list() envelope test (has_more/page/page_size + normalized Job items), mirroring existing parse_jobs coverage. Full suite green: rye run pytest -q -> 557 passed, 240 skipped. ./scripts/lint clean (ruff + pyright strict + mypy + import check). Note: rye run nox -s test-pydantic-v1 fails on 2 pre-existing tests (test_extract_sync_json_body_with_pydantic_schema, test_coerce_from_pydantic_model) due to a real bug in schema_utils.coerce_schema_to_dict, which gates on the pydantic-v2-only model_json_schema attribute and raises TypeError for valid pydantic v1 BaseModel subclasses. Not fixed here (tests-only task); see .superpowers/sdd/task-12-report.md for details. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/api_resources/v2/test_async_smoke.py | 76 ++++++++++++++++++++++ tests/api_resources/v2/test_extract.py | 28 ++++++++ 2 files changed, 104 insertions(+) create mode 100644 tests/api_resources/v2/test_async_smoke.py 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..5be4ca1 --- /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://aide.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://aide.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://aide.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://aide.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://aide.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 index 6a49cb7..9e619c2 100644 --- a/tests/api_resources/v2/test_extract.py +++ b/tests/api_resources/v2/test_extract.py @@ -105,3 +105,31 @@ def test_extract_job_wait_raise_on_failure() -> None: 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_carries_envelope() -> None: + client = LandingAIADE(apikey=APIKEY) + respx.get("https://aide.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 From 083e40692945b15063f7c7c334c683b93f3605b8 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 16:05:52 +0800 Subject: [PATCH 15/26] fix(v2): support pydantic v1 in schema coercion pydantic_to_json_schema/pydantic_to_schema_dict gated on hasattr(model, "model_json_schema") and read $defs, both v2-only. Under pydantic v1 a genuine BaseModel subclass lacks model_json_schema (v1 uses .schema() + a "definitions" key instead), so coerce_schema_to_dict raised TypeError, breaking client.v2.extract(schema=). Add _model_json_schema() which dispatches on the SDK's existing PYDANTIC_V1 flag: v1 calls .schema() and renames "definitions" to "$defs"; v2 is unchanged. Replace the hasattr guard with a proper isinstance(model, type) and issubclass(model, BaseModel) check that works on both majors. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/landingai_ade/lib/schema_utils.py | 32 +++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/landingai_ade/lib/schema_utils.py b/src/landingai_ade/lib/schema_utils.py index 10e16d9..f3ab898 100644 --- a/src/landingai_ade/lib/schema_utils.py +++ b/src/landingai_ade/lib/schema_utils.py @@ -11,6 +11,24 @@ 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: + schema = model.schema() # type: ignore[attr-defined] + if "definitions" in schema: + schema["$defs"] = schema.pop("definitions") + return schema + return model.model_json_schema() + def _resolve_refs(obj: Any, defs: Dict[str, Any]) -> Any: """ @@ -65,10 +83,13 @@ 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) @@ -76,9 +97,12 @@ def pydantic_to_json_schema(model: Type[BaseModel]) -> str: 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 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", {}) return cast(Dict[str, Any], _resolve_refs(schema, defs)) From 9a5a0c94759f9c8fd4bbed21faf0a230e9e6248d Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 16:16:51 +0800 Subject: [PATCH 16/26] fix(v2): deep-copy pydantic v1 schema before mutating (cache corruption) pydantic v1's model.schema() is memoized per class and returns the same shared mutable dict on every call. schema_utils mutated that dict in place (popping "definitions"/"$defs"), which permanently corrupted the class-level cache: a second call on the same model with nested sub-models raised KeyError in _resolve_refs due to a dangling $ref. Deep-copy the schema in _model_json_schema before any mutation, for both pydantic majors, so repeated calls on the same class are safe. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/landingai_ade/lib/schema_utils.py | 11 ++++- tests/test_v2_schema.py | 58 +++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/landingai_ade/lib/schema_utils.py b/src/landingai_ade/lib/schema_utils.py index f3ab898..546162e 100644 --- a/src/landingai_ade/lib/schema_utils.py +++ b/src/landingai_ade/lib/schema_utils.py @@ -23,11 +23,18 @@ def _model_json_schema(model: Type[BaseModel]) -> Dict[str, Any]: know which pydantic major produced the schema. """ if PYDANTIC_V1: - schema = model.schema() # type: ignore[attr-defined] + # 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 - return model.model_json_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: diff --git a/tests/test_v2_schema.py b/tests/test_v2_schema.py index a07d638..4692d7a 100644 --- a/tests/test_v2_schema.py +++ b/tests/test_v2_schema.py @@ -1,6 +1,8 @@ # tests/test_v2_schema.py from __future__ import annotations +from typing import Any, Dict, List, cast + import pytest from pydantic import Field, BaseModel @@ -11,6 +13,62 @@ 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" From 0b5e1959105d4f78450e1a6845ee3d1ee7180abd Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 16:26:03 +0800 Subject: [PATCH 17/26] docs(v2): document client.v2 sub-client, environments, and add examples Add a V2 section to api.md (parse/parse_jobs/extract/extract_jobs/files methods and the unified Job/JobStatus/JobError/V2ParseResponse/ V2ExtractResult/V2FileUploadResponse types), a V2 API subsection to README.md (environment selection incl. LANDINGAI_ADE_ENVIRONMENT, sync parse/extract, async jobs + wait, pydantic-model schema, files.upload -> markdown_ref, save_to), and runnable examples/v2_parse.py and examples/v2_extract.py. Additive docs only; V1 usage is unchanged. --- README.md | 99 ++++++++++++++++++++++++++++++++++++++++++ api.md | 60 +++++++++++++++++++++++++ examples/v2_extract.py | 48 ++++++++++++++++++++ examples/v2_parse.py | 43 ++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 examples/v2_extract.py create mode 100644 examples/v2_parse.py diff --git a/README.md b/README.md index 7ae11a8..8811055 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 (`aide.[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..8e71cd3 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 (`aide.[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 job ends `failed`/`cancelled`. + +- 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..63b828f --- /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 (aide.[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..9eb03fb --- /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 (aide.[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) From c57b6ca3bbbbbdbb84259b3d6afc0d250c9a7e65 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 16:36:31 +0800 Subject: [PATCH 18/26] docs(v2): add live smoke-test harness for the V2 endpoints examples/v2_smoke_test.py exercises files.upload, parse (sync+job), extract (sync+job) against a live environment (default staging) with per-check PASS/FAIL reporting and --only/--async/--document selection. Manual QA tool, not part of the automated suite. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/v2_smoke_test.py | 233 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100755 examples/v2_smoke_test.py diff --git a/examples/v2_smoke_test.py b/examples/v2_smoke_test.py new file mode 100755 index 0000000..fc12ad4 --- /dev/null +++ b/examples/v2_smoke_test.py @@ -0,0 +1,233 @@ +#!/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). + +Setup +----- + export VISION_AGENT_API_KEY= + # target environment (default: staging). V2 lives on aide..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 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, default staging)") + 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 _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] = {} + if args.environment: + kwargs["environment"] = args.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] = {} + if args.environment: + kwargs["environment"] = args.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()) From 457335f01caea0d42783d0e278208f1b16c7d3c8 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 16:42:47 +0800 Subject: [PATCH 19/26] fix(v2): strict has_more bool + preserve created_at=0 (review) JobList.build coerced has_more via bool(), so a wrong-typed truthy string (e.g. "false") flipped pagination; now only a real bool sets it, matching the isinstance checks used for the sibling fields. normalize_parse_job used `created_at or received_at`, which treated a valid epoch-0 created_at as falsy and silently fell back to received_at; now falls back only when created_at is None. --- src/landingai_ade/resources/v2/_base.py | 3 ++- src/landingai_ade/resources/v2/_normalize.py | 5 ++++- tests/test_v2_normalize.py | 13 +++++++++++++ tests/test_v2_waiter.py | 14 ++++++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/landingai_ade/resources/v2/_base.py b/src/landingai_ade/resources/v2/_base.py index c5835b8..0b3cef3 100644 --- a/src/landingai_ade/resources/v2/_base.py +++ b/src/landingai_ade/resources/v2/_base.py @@ -122,7 +122,8 @@ class JobList(List[Job]): @classmethod def build(cls, jobs: List[Job], **envelope: object) -> "JobList": out = cls(jobs) - out.has_more = bool(envelope.get("has_more", False)) + 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") diff --git a/src/landingai_ade/resources/v2/_normalize.py b/src/landingai_ade/resources/v2/_normalize.py index badfedb..0f6d416 100644 --- a/src/landingai_ade/resources/v2/_normalize.py +++ b/src/landingai_ade/resources/v2/_normalize.py @@ -36,10 +36,13 @@ def normalize_parse_job(raw: Mapping[str, Any]) -> Job: 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(raw.get("created_at") or raw.get("received_at")), + created_at=_ts(created), completed_at=None, # parse envelope has no completed_at progress=_progress(raw.get("progress")), result=result, diff --git a/tests/test_v2_normalize.py b/tests/test_v2_normalize.py index c8aeeb0..83309da 100644 --- a/tests/test_v2_normalize.py +++ b/tests/test_v2_normalize.py @@ -2,6 +2,7 @@ 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 @@ -28,6 +29,18 @@ def test_normalize_parse_job_epoch_and_data() -> 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) diff --git a/tests/test_v2_waiter.py b/tests/test_v2_waiter.py index 26b3b44..ec64f01 100644 --- a/tests/test_v2_waiter.py +++ b/tests/test_v2_waiter.py @@ -275,3 +275,17 @@ def test_joblist_build_ignores_wrong_typed_envelope_values() -> 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 From d372e5516e34e8f7fa3089b48bd15122ff9ef8ea Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 16:46:19 +0800 Subject: [PATCH 20/26] ci: re-trigger PR gates so surface-lock sees the override label From 6e4baa5c56ac1782dfb28228ced65ff7fcd306cf Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 18:28:31 +0800 Subject: [PATCH 21/26] fix(v2): treat explicit None as unset in body/query builders + doc fixes (review) is_given() only filters the omit/not_given sentinels and returns True for None, so explicit None for optional wire fields was leaking into requests (e.g. options=None serialized to the literal string "null"). Exclude None alongside omit/not_given in _build_parse_body, the parse_jobs.create extra fields (output_save_url/priority), and the parse_jobs/extract_jobs list() query builders. Also corrects two doc issues flagged in review: the smoke test now actually defaults to the staging environment (matching its stated intent) instead of silently falling back to the client's production default, and api.md now describes JobFailedError accurately (raised when the terminal job carries an error, not on every failed/cancelled status). Co-Authored-By: Claude Opus 4.8 (1M context) --- api.md | 2 +- examples/v2_smoke_test.py | 42 ++++++++++-- src/landingai_ade/resources/v2/extract.py | 5 +- src/landingai_ade/resources/v2/parse.py | 21 +++--- tests/api_resources/v2/test_extract.py | 42 ++++++++++++ tests/api_resources/v2/test_parse.py | 79 +++++++++++++++++++++++ 6 files changed, 173 insertions(+), 18 deletions(-) diff --git a/api.md b/api.md index 8e71cd3..683e980 100644 --- a/api.md +++ b/api.md @@ -98,7 +98,7 @@ Methods: - 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 job ends `failed`/`cancelled`. + 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 diff --git a/examples/v2_smoke_test.py b/examples/v2_smoke_test.py index fc12ad4..8fc8282 100755 --- a/examples/v2_smoke_test.py +++ b/examples/v2_smoke_test.py @@ -5,10 +5,17 @@ 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= - # target environment (default: staging). V2 lives on aide..landing.ai + # optional: override the target environment (script default: staging). + # V2 lives on aide..landing.ai export LANDINGAI_ADE_ENVIRONMENT=staging Run @@ -24,6 +31,7 @@ from __future__ import annotations +import os import sys import asyncio import argparse @@ -55,7 +63,11 @@ def _short(value: Any, limit: int = 200) -> str: 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, default staging)") + 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)}") @@ -66,6 +78,22 @@ def _make_parser() -> argparse.ArgumentParser: 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 @@ -80,8 +108,9 @@ def _selected(only: Optional[str]) -> List[str]: def run_sync(args: argparse.Namespace, checks: List[str]) -> int: kwargs: dict[str, Any] = {} - if args.environment: - kwargs["environment"] = args.environment + 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") @@ -163,8 +192,9 @@ def _document_kwargs(args: argparse.Namespace) -> Optional[dict[str, Any]]: def run_async(args: argparse.Namespace, checks: List[str]) -> int: async def _main() -> int: kwargs: dict[str, Any] = {} - if args.environment: - kwargs["environment"] = args.environment + 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] = {} diff --git a/src/landingai_ade/resources/v2/extract.py b/src/landingai_ade/resources/v2/extract.py index 781e0a5..e7d5590 100644 --- a/src/landingai_ade/resources/v2/extract.py +++ b/src/landingai_ade/resources/v2/extract.py @@ -10,6 +10,7 @@ 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 @@ -272,7 +273,7 @@ def list( query = { key: value for key, value in {"page": page, "page_size": page_size, "status": status}.items() - if value is not omit + if is_given(value) and value is not None } raw = self._get( self._v2_url("/v2/extract/jobs"), @@ -387,7 +388,7 @@ async def list( query = { key: value for key, value in {"page": page, "page_size": page_size, "status": status}.items() - if value is not omit + if is_given(value) and value is not None } raw = await self._get( self._v2_url("/v2/extract/jobs"), diff --git a/src/landingai_ade/resources/v2/parse.py b/src/landingai_ade/resources/v2/parse.py index 1d13a69..66126b7 100644 --- a/src/landingai_ade/resources/v2/parse.py +++ b/src/landingai_ade/resources/v2/parse.py @@ -32,7 +32,7 @@ def _build_parse_body( password: object, ) -> dict[str, Any]: # `options` is a JSON-encoded string form field per the contract. - if is_given(options): + if is_given(options) and options is not None: options = json.dumps(options) if not isinstance(options, str) else options raw_body = { "document": document, @@ -43,8 +43,11 @@ def _build_parse_body( } # 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. - return {key: value for key, value in raw_body.items() if is_given(value)} + # 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): @@ -228,9 +231,9 @@ def create( 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): + if is_given(output_save_url) and output_save_url is not None: body["output_save_url"] = output_save_url - if is_given(priority): + 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"]]) @@ -282,7 +285,7 @@ def list( query = { key: value for key, value in {"page": page, "page_size": page_size, "status": status}.items() - if is_given(value) + if is_given(value) and value is not None } raw = self._get( self._v2_url("/v2/parse/jobs"), @@ -347,9 +350,9 @@ async def create( ) -> 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): + if is_given(output_save_url) and output_save_url is not None: body["output_save_url"] = output_save_url - if is_given(priority): + 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"]]) @@ -401,7 +404,7 @@ async def list( query = { key: value for key, value in {"page": page, "page_size": page_size, "status": status}.items() - if is_given(value) + if is_given(value) and value is not None } raw = await self._get( self._v2_url("/v2/parse/jobs"), diff --git a/tests/api_resources/v2/test_extract.py b/tests/api_resources/v2/test_extract.py index 9e619c2..7d90dfe 100644 --- a/tests/api_resources/v2/test_extract.py +++ b/tests/api_resources/v2/test_extract.py @@ -114,6 +114,48 @@ def test_extract_job_get_empty_job_id_raises() -> None: 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://aide.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://aide.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) diff --git a/tests/api_resources/v2/test_parse.py b/tests/api_resources/v2/test_parse.py index 38dbc01..514c558 100644 --- a/tests/api_resources/v2/test_parse.py +++ b/tests/api_resources/v2/test_parse.py @@ -52,6 +52,85 @@ def test_parse_sync_omits_unset_fields_from_multipart_body() -> None: 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://aide.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://aide.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://aide.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) From 29386a225992921e849921127a993edfed3e22a4 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 03:36:42 -0700 Subject: [PATCH 22/26] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/landingai_ade/lib/v2_errors.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/landingai_ade/lib/v2_errors.py b/src/landingai_ade/lib/v2_errors.py index 47af21c..d8f2025 100644 --- a/src/landingai_ade/lib/v2_errors.py +++ b/src/landingai_ade/lib/v2_errors.py @@ -30,6 +30,6 @@ 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 (`.jobs.create(...)` then `.jobs.wait(...)`) " - "for long-running documents." + "timeout — use the async jobs routes (`.parse_jobs.create(...)` / `.extract_jobs.create(...)` " + "then `.wait(...)`) for long-running documents." ) from exc From 407bdc739f603fcaa6e3ee4477fc84d428a9b9aa Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 21:21:47 +0800 Subject: [PATCH 23/26] fix(v2): V2 API host is api.ade.[env] (aide.[env] is spec-only/SSO-gated) A live staging smoke test with a real customer bearer key confirmed api.ade.staging.landing.ai serves files.upload/extract/extract_jobs successfully, while aide.staging.landing.ai fails all calls with a Google-frontend 400 (the host is gated behind staff Google SSO). aide.[env] remains the spec-publication host only, not the callable customer API. Update V2_ENVIRONMENTS in src/landingai_ade/_client.py and all respx-mocked/asserted hosts in tests, plus docs/examples referencing the V2 host, from aide.[env].landing.ai to api.ade.[env].landing.ai. V1 host (api.va.[env].landing.ai) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- api.md | 2 +- examples/v2_extract.py | 2 +- examples/v2_parse.py | 2 +- examples/v2_smoke_test.py | 2 +- src/landingai_ade/_client.py | 8 +++--- tests/api_resources/v2/test_async_smoke.py | 10 +++---- tests/api_resources/v2/test_extract.py | 20 +++++++------- tests/api_resources/v2/test_files.py | 4 +-- tests/api_resources/v2/test_parse.py | 32 +++++++++++----------- tests/test_v2_environment.py | 14 +++++----- tests/test_v2_errors.py | 2 +- 12 files changed, 50 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 8811055..70a842c 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ for job in response.jobs: `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 (`aide.[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: +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 diff --git a/api.md b/api.md index 683e980..8d99aef 100644 --- a/api.md +++ b/api.md @@ -62,7 +62,7 @@ Methods: # V2 -The `client.v2` sub-client targets LandingAI's next-generation ADE gateway, which lives on its own host (`aide.[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. +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. diff --git a/examples/v2_extract.py b/examples/v2_extract.py index 63b828f..957e52e 100644 --- a/examples/v2_extract.py +++ b/examples/v2_extract.py @@ -2,7 +2,7 @@ """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 (aide.[env].landing.ai), separate from the +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. diff --git a/examples/v2_parse.py b/examples/v2_parse.py index 9eb03fb..8a04783 100644 --- a/examples/v2_parse.py +++ b/examples/v2_parse.py @@ -1,7 +1,7 @@ #!/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 (aide.[env].landing.ai), separate from the +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. diff --git a/examples/v2_smoke_test.py b/examples/v2_smoke_test.py index 8fc8282..1d6b366 100755 --- a/examples/v2_smoke_test.py +++ b/examples/v2_smoke_test.py @@ -15,7 +15,7 @@ ----- export VISION_AGENT_API_KEY= # optional: override the target environment (script default: staging). - # V2 lives on aide..landing.ai + # V2 lives on api.ade..landing.ai export LANDINGAI_ADE_ENVIRONMENT=staging Run diff --git a/src/landingai_ade/_client.py b/src/landingai_ade/_client.py index 02c08ce..5c6aadc 100644 --- a/src/landingai_ade/_client.py +++ b/src/landingai_ade/_client.py @@ -93,10 +93,10 @@ } V2_ENVIRONMENTS: Dict[str, str] = { - "production": "https://aide.landing.ai", - "eu": "https://aide.eu-west-1.landing.ai", - "staging": "https://aide.staging.landing.ai", - "dev": "https://aide.dev.landing.ai", + "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", } diff --git a/tests/api_resources/v2/test_async_smoke.py b/tests/api_resources/v2/test_async_smoke.py index 5be4ca1..b5f64a0 100644 --- a/tests/api_resources/v2/test_async_smoke.py +++ b/tests/api_resources/v2/test_async_smoke.py @@ -23,12 +23,12 @@ @pytest.mark.asyncio async def test_async_parse_and_jobs() -> None: client = AsyncLandingAIADE(apikey=APIKEY) - respx.post("https://aide.landing.ai/v2/parse").mock( + 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://aide.landing.ai/v2/parse/jobs").mock( + 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") @@ -39,7 +39,7 @@ async def test_async_parse_and_jobs() -> None: @pytest.mark.asyncio async def test_async_extract() -> None: client = AsyncLandingAIADE(apikey=APIKEY) - respx.post("https://aide.landing.ai/v2/extract").mock(return_value=httpx.Response(200, json=EXTRACT_BODY)) + 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) @@ -52,7 +52,7 @@ async def test_async_extract_jobs_create_get_and_wait() -> None: time passes while polling.""" client = AsyncLandingAIADE(apikey=APIKEY) - respx.post("https://aide.landing.ai/v2/extract/jobs").mock( + 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") @@ -63,7 +63,7 @@ async def test_async_extract_jobs_create_get_and_wait() -> None: 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://aide.landing.ai/v2/extract/jobs/e1").mock(side_effect=responses) + 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 diff --git a/tests/api_resources/v2/test_extract.py b/tests/api_resources/v2/test_extract.py index 7d90dfe..9636504 100644 --- a/tests/api_resources/v2/test_extract.py +++ b/tests/api_resources/v2/test_extract.py @@ -28,7 +28,7 @@ class Invoice(BaseModel): @respx.mock def test_extract_sync_json_body_with_pydantic_schema() -> None: client = LandingAIADE(apikey=APIKEY) - route = respx.post("https://aide.landing.ai/v2/extract").mock( + 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") @@ -43,7 +43,7 @@ def test_extract_sync_json_body_with_pydantic_schema() -> None: @respx.mock def test_extract_sync_strict_option() -> None: client = LandingAIADE(apikey=APIKEY) - route = respx.post("https://aide.landing.ai/v2/extract").mock( + 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) @@ -55,7 +55,7 @@ def test_extract_sync_strict_option() -> None: @respx.mock def test_extract_sync_504() -> None: client = LandingAIADE(apikey=APIKEY, max_retries=0) - respx.post("https://aide.landing.ai/v2/extract").mock(return_value=httpx.Response(504, json={"detail": "x"})) + 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") @@ -63,13 +63,13 @@ def test_extract_sync_504() -> None: @respx.mock def test_extract_job_create_and_get() -> None: client = LandingAIADE(apikey=APIKEY) - respx.post("https://aide.landing.ai/v2/extract/jobs").mock( + 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://aide.landing.ai/v2/extract/jobs/e1").mock( + 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", @@ -85,7 +85,7 @@ def test_extract_job_create_and_get() -> None: @respx.mock def test_extract_job_get_failed_maps_error_object() -> None: client = LandingAIADE(apikey=APIKEY) - respx.get("https://aide.landing.ai/v2/extract/jobs/e2").mock( + 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"}} ) @@ -99,7 +99,7 @@ def test_extract_job_wait_raise_on_failure() -> None: from landingai_ade.lib.v2_errors import JobFailedError client = LandingAIADE(apikey=APIKEY) - respx.get("https://aide.landing.ai/v2/extract/jobs/e3").mock( + 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): @@ -117,7 +117,7 @@ def test_extract_job_get_empty_job_id_raises() -> None: @respx.mock def test_extract_job_list_status_none_omits_query_param() -> None: client = LandingAIADE(apikey=APIKEY) - route = respx.get("https://aide.landing.ai/v2/extract/jobs").mock( + 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) @@ -149,7 +149,7 @@ def fake_get(path: str, *, cast_to: Any, options: Any = None, **kwargs: Any) -> @respx.mock def test_extract_job_list_status_given_includes_query_param() -> None: client = LandingAIADE(apikey=APIKEY) - route = respx.get("https://aide.landing.ai/v2/extract/jobs").mock( + 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") @@ -159,7 +159,7 @@ def test_extract_job_list_status_given_includes_query_param() -> None: @respx.mock def test_extract_job_list_carries_envelope() -> None: client = LandingAIADE(apikey=APIKEY) - respx.get("https://aide.landing.ai/v2/extract/jobs").mock( + respx.get("https://api.ade.landing.ai/v2/extract/jobs").mock( return_value=httpx.Response( 200, json={ diff --git a/tests/api_resources/v2/test_files.py b/tests/api_resources/v2/test_files.py index 3bab619..e3e376f 100644 --- a/tests/api_resources/v2/test_files.py +++ b/tests/api_resources/v2/test_files.py @@ -12,7 +12,7 @@ @respx.mock def test_files_upload_returns_ref_and_hits_v2_host() -> None: client = LandingAIADE(apikey=APIKEY, environment="production") - route = respx.post("https://aide.landing.ai/v1/files").mock( + 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") @@ -25,7 +25,7 @@ def test_files_upload_returns_ref_and_hits_v2_host() -> None: @pytest.mark.asyncio async def test_async_files_upload() -> None: client = AsyncLandingAIADE(apikey=APIKEY, environment="staging") - respx.post("https://aide.staging.landing.ai/v1/files").mock( + 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") diff --git a/tests/api_resources/v2/test_parse.py b/tests/api_resources/v2/test_parse.py index 514c558..e70c7f5 100644 --- a/tests/api_resources/v2/test_parse.py +++ b/tests/api_resources/v2/test_parse.py @@ -23,7 +23,7 @@ @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://aide.landing.ai/v2/parse").mock( + 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"}) @@ -37,7 +37,7 @@ def test_parse_sync_ok_routes_to_v2_and_sends_options_json() -> None: @respx.mock def test_parse_sync_omits_unset_fields_from_multipart_body() -> None: client = LandingAIADE(apikey=APIKEY, environment="production") - route = respx.post("https://aide.landing.ai/v2/parse").mock( + 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") @@ -58,7 +58,7 @@ def test_parse_sync_omits_explicit_none_fields_from_multipart_body() -> None: # 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://aide.landing.ai/v2/parse").mock( + 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) @@ -92,7 +92,7 @@ def fake_post(path: str, *, cast_to: Any, body: Any = None, files: Any = None, o @respx.mock def test_parse_job_list_status_none_omits_query_param() -> None: client = LandingAIADE(apikey=APIKEY) - route = respx.get("https://aide.landing.ai/v2/parse/jobs").mock( + 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) @@ -124,7 +124,7 @@ def fake_get(path: str, *, cast_to: Any, options: Any = None, **kwargs: Any) -> @respx.mock def test_parse_job_list_status_given_includes_query_param() -> None: client = LandingAIADE(apikey=APIKEY) - route = respx.get("https://aide.landing.ai/v2/parse/jobs").mock( + 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") @@ -138,7 +138,7 @@ def test_parse_sync_206_returns_response_with_failed_pages() -> None: metadata: Dict[str, Any] = dict(PARSE_BODY["metadata"]) metadata["failed_pages"] = [3] body["metadata"] = metadata - respx.post("https://aide.landing.ai/v2/parse").mock(return_value=httpx.Response(206, json=body)) + 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] @@ -146,7 +146,7 @@ def test_parse_sync_206_returns_response_with_failed_pages() -> None: @respx.mock def test_parse_sync_504_raises_v2_sync_timeout() -> None: client = LandingAIADE(apikey=APIKEY, max_retries=0) - respx.post("https://aide.landing.ai/v2/parse").mock(return_value=httpx.Response(504, json={"detail": "x"})) + 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") @@ -154,7 +154,7 @@ def test_parse_sync_504_raises_v2_sync_timeout() -> None: @respx.mock def test_parse_save_to_writes_file(tmp_path: Path) -> None: client = LandingAIADE(apikey=APIKEY) - respx.post("https://aide.landing.ai/v2/parse").mock(return_value=httpx.Response(200, json=PARSE_BODY)) + 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" @@ -166,7 +166,7 @@ async def test_async_parse_sync_ok() -> None: from landingai_ade import AsyncLandingAIADE client = AsyncLandingAIADE(apikey=APIKEY, environment="production") - respx.post("https://aide.landing.ai/v2/parse").mock(return_value=httpx.Response(200, json=PARSE_BODY)) + 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" @@ -175,7 +175,7 @@ async def test_async_parse_sync_ok() -> None: @respx.mock def test_parse_job_create_normalizes_envelope() -> None: client = LandingAIADE(apikey=APIKEY) - respx.post("https://aide.landing.ai/v2/parse/jobs").mock( + 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") @@ -186,7 +186,7 @@ def test_parse_job_create_normalizes_envelope() -> None: @respx.mock def test_parse_job_get_completed_has_typed_result() -> None: client = LandingAIADE(apikey=APIKEY) - respx.get("https://aide.landing.ai/v2/parse/jobs/p1").mock( + 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}, @@ -208,7 +208,7 @@ def test_parse_job_get_empty_job_id_raises() -> None: @respx.mock def test_parse_job_list_carries_envelope() -> None: client = LandingAIADE(apikey=APIKEY) - respx.get("https://aide.landing.ai/v2/parse/jobs").mock( + 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}, @@ -226,7 +226,7 @@ def test_parse_job_wait_polls_until_completed() -> None: 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://aide.landing.ai/v2/parse/jobs/p1").mock(side_effect=responses) + 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)) @@ -239,10 +239,10 @@ async def test_async_parse_job_create_and_get() -> None: from landingai_ade import AsyncLandingAIADE client = AsyncLandingAIADE(apikey=APIKEY) - respx.post("https://aide.landing.ai/v2/parse/jobs").mock( + 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://aide.landing.ai/v2/parse/jobs/p1").mock( + 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") @@ -263,7 +263,7 @@ async def test_async_parse_job_wait_polls_until_completed() -> None: 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://aide.landing.ai/v2/parse/jobs/p1").mock(side_effect=responses) + 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 index 9c77877..0dcc2c9 100644 --- a/tests/test_v2_environment.py +++ b/tests/test_v2_environment.py @@ -15,16 +15,16 @@ 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://aide.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://aide.landing.ai"), - ("eu", "https://api.va.eu-west-1.landing.ai", "https://aide.eu-west-1.landing.ai"), - ("staging", "https://api.va.staging.landing.ai", "https://aide.staging.landing.ai"), - ("dev", "https://api.va.dev.landing.ai", "https://aide.dev.landing.ai"), + ("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: @@ -36,7 +36,7 @@ def test_environment_pairs(env: Literal["production", "eu", "staging", "dev"], v 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://aide.staging.landing.ai" + assert c._v2_base_url == "https://api.ade.staging.landing.ai" def test_explicit_v2_base_url_wins() -> None: @@ -67,7 +67,7 @@ def test_v2_attribute_exists() -> None: @respx.mock def test_v2_subclient_routes_to_v2_host() -> None: c = LandingAIADE(apikey=APIKEY, environment="production") - route = respx.post("https://aide.landing.ai/v1/files").mock( + 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") diff --git a/tests/test_v2_errors.py b/tests/test_v2_errors.py index 03543fd..1ded801 100644 --- a/tests/test_v2_errors.py +++ b/tests/test_v2_errors.py @@ -14,7 +14,7 @@ def _status_error(code: int) -> APIStatusError: - request = httpx.Request("POST", "https://aide.landing.ai/v2/extract") + request = httpx.Request("POST", "https://api.ade.landing.ai/v2/extract") response = httpx.Response(code, request=request) return APIStatusError("err", response=response, body=None) From df12d330c90f02daa6b1c6a1d46332a6ebd7ad66 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 21:27:07 +0800 Subject: [PATCH 24/26] fix(v2): default Job status to pending when create envelope omits it (live-confirmed) The live /v2/parse/jobs create (202) response is minimal ({"job_id": "..."} only, no status field), causing normalize_parse_job to KeyError on raw["status"]. normalize_extract_job has the same latent issue. Default to JobStatus.PENDING when status is absent/empty, since a just-created job is pending. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/landingai_ade/resources/v2/_normalize.py | 4 ++-- tests/test_v2_normalize.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/landingai_ade/resources/v2/_normalize.py b/src/landingai_ade/resources/v2/_normalize.py index 0f6d416..e73816a 100644 --- a/src/landingai_ade/resources/v2/_normalize.py +++ b/src/landingai_ade/resources/v2/_normalize.py @@ -27,7 +27,7 @@ def _progress(value: object) -> Optional[float]: def normalize_parse_job(raw: Mapping[str, Any]) -> Job: - status = JobStatus(raw["status"]) + status = JobStatus(raw.get("status") or "pending") data = raw.get("data") result = V2ParseResponse(**cast(Dict[str, Any], data)) if isinstance(data, Mapping) else None @@ -52,7 +52,7 @@ def normalize_parse_job(raw: Mapping[str, Any]) -> Job: def normalize_extract_job(raw: Mapping[str, Any]) -> Job: - status = JobStatus(raw["status"]) + status = JobStatus(raw.get("status") or "pending") payload = raw.get("result") result = V2ExtractResult(**cast(Dict[str, Any], payload)) if isinstance(payload, Mapping) else None diff --git a/tests/test_v2_normalize.py b/tests/test_v2_normalize.py index 83309da..1f9fcfc 100644 --- a/tests/test_v2_normalize.py +++ b/tests/test_v2_normalize.py @@ -75,3 +75,22 @@ def test_normalize_extract_job_error_object() -> None: 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 From 695046009166464b68df5d1ab0a9d2a1556a0a31 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 21:34:58 +0800 Subject: [PATCH 25/26] fix(v2): correct 504 message + Job.raw default_factory (review) - V2SyncTimeoutError message now points at the real async helpers (client.v2.parse_jobs / client.v2.extract_jobs) instead of the nonexistent .jobs attribute. - Job.raw now uses Field(default_factory=dict) instead of a mutable {} default so instances don't share the same dict. - Add regression test asserting Job.raw is independent per instance. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/landingai_ade/lib/v2_errors.py | 9 +++++---- src/landingai_ade/types/v2/job.py | 4 +++- tests/test_v2_types.py | 8 ++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/landingai_ade/lib/v2_errors.py b/src/landingai_ade/lib/v2_errors.py index d8f2025..2c72747 100644 --- a/src/landingai_ade/lib/v2_errors.py +++ b/src/landingai_ade/lib/v2_errors.py @@ -14,8 +14,9 @@ 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 (`*_jobs.create` + `wait`) - for long-running documents.""" + 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): @@ -30,6 +31,6 @@ 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 routes (`.parse_jobs.create(...)` / `.extract_jobs.create(...)` " - "then `.wait(...)`) for long-running documents." + "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/types/v2/job.py b/src/landingai_ade/types/v2/job.py index 8013cb3..1f98631 100644 --- a/src/landingai_ade/types/v2/job.py +++ b/src/landingai_ade/types/v2/job.py @@ -4,6 +4,8 @@ from typing import Dict, Optional from datetime import datetime +from pydantic import Field + from ..._models import BaseModel __all__ = ["JobStatus", "JobError", "Job"] @@ -34,7 +36,7 @@ class Job(BaseModel): 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] = {} + raw: Dict[str, object] = Field(default_factory=dict) @property def is_terminal(self) -> bool: diff --git a/tests/test_v2_types.py b/tests/test_v2_types.py index d2d3718..2bf8b17 100644 --- a/tests/test_v2_types.py +++ b/tests/test_v2_types.py @@ -45,6 +45,14 @@ def test_job_holds_typed_result_and_error() -> None: 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_extract_result_parses_nested_metadata() -> None: r = V2ExtractResult( extraction={"revenue": "1M"}, From 38908167af61c5f188832b51668c690e4027de64 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 22:59:32 +0800 Subject: [PATCH 26/26] fix(v2): tolerate unknown job status; lock Job.raw construct-path independence (review) - _normalize.py: unknown/renamed gateway status strings no longer raise ValueError and crash the whole normalizer; they fall back to PENDING while the original raw status stays available via job.raw. - _compat.py: field_get_default() now passes call_default_factory=True on pydantic v2, matching pydantic's own guidance for the construct()/ model_construct() fast path. Without it, fields declared with default_factory=... (e.g. Job.raw) resolved to None instead of a fresh value when built via Job.construct()/model_construct(), which the new test exposed. - Tests: unknown-status coverage for both normalizers, and a construct()-path test proving Job.raw stays independent per instance. --- src/landingai_ade/_compat.py | 11 +++++++++-- src/landingai_ade/resources/v2/_normalize.py | 16 ++++++++++++++-- tests/test_v2_normalize.py | 16 ++++++++++++++++ tests/test_v2_types.py | 12 ++++++++++++ 4 files changed, 51 insertions(+), 4 deletions(-) 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/resources/v2/_normalize.py b/src/landingai_ade/resources/v2/_normalize.py index e73816a..fcf1524 100644 --- a/src/landingai_ade/resources/v2/_normalize.py +++ b/src/landingai_ade/resources/v2/_normalize.py @@ -26,8 +26,20 @@ def _progress(value: object) -> Optional[float]: 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 = JobStatus(raw.get("status") or "pending") + status = _status(raw) data = raw.get("data") result = V2ParseResponse(**cast(Dict[str, Any], data)) if isinstance(data, Mapping) else None @@ -52,7 +64,7 @@ def normalize_parse_job(raw: Mapping[str, Any]) -> Job: def normalize_extract_job(raw: Mapping[str, Any]) -> Job: - status = JobStatus(raw.get("status") or "pending") + status = _status(raw) payload = raw.get("result") result = V2ExtractResult(**cast(Dict[str, Any], payload)) if isinstance(payload, Mapping) else None diff --git a/tests/test_v2_normalize.py b/tests/test_v2_normalize.py index 1f9fcfc..e5981ec 100644 --- a/tests/test_v2_normalize.py +++ b/tests/test_v2_normalize.py @@ -94,3 +94,19 @@ def test_normalize_extract_job_minimal_create_envelope_defaults_to_pending() -> 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_types.py b/tests/test_v2_types.py index 2bf8b17..1ea4cb0 100644 --- a/tests/test_v2_types.py +++ b/tests/test_v2_types.py @@ -53,6 +53,18 @@ def test_job_raw_default_is_independent_per_instance() -> None: 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"},