feat: V2 parse & extract SDK support (client.v2)#105
Conversation
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).
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.
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().
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.
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.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…iter 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) <[email protected]>
Wires a `files` cached_property (lazy import) into both V2Resource and AsyncV2Resource containers, and unskips the Task 6 routing test now that `.files` exists.
…dling 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.
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) <[email protected]>
…ed 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) <[email protected]>
- 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) <[email protected]>
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=<v1 model>). 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) <[email protected]>
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) <[email protected]>
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.
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) <[email protected]>
There was a problem hiding this comment.
Pull request overview
Adds an additive client.v2 sub-client that targets the ADE gateway host (aide.[env].landing.ai) to support V2 parse/extract (sync + async jobs), unified job normalization + polling helpers, and accompanying types, docs, examples, and tests—without changing the existing V1 surface.
Changes:
- Introduces
client.v2resources for sync parse/extract, async job routes, file staging (/v1/files), and a sharedwait()polling helper. - Adds V2 type models and job envelope normalizers (parse vs extract) plus V2-specific error wrappers (notably sync 504 →
V2SyncTimeoutError). - Expands environment handling to include
staging/devand adds docs/examples/tests for the V2 surface.
Reviewed changes
Copilot reviewed 31 out of 32 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_v2_waiter.py | Unit tests for sync/async polling backoff/timeout/failure behavior and JobList envelope handling. |
| tests/test_v2_types.py | Unit tests for V2 type models (Job, V2ParseResponse, V2ExtractResult, upload response). |
| tests/test_v2_schema.py | Regression and compatibility tests for schema coercion across pydantic v1/v2. |
| tests/test_v2_normalize.py | Tests for parse/extract job envelope normalization (epoch vs ISO timestamps, result/error mapping). |
| tests/test_v2_errors.py | Tests for V2 error classes and 504 conversion helper. |
| tests/test_v2_environment.py | Tests for V2 host/environment resolution and v2 subclient routing. |
| tests/api_resources/v2/test_parse.py | API-style tests for V2 parse sync + parse jobs routes and async parity. |
| tests/api_resources/v2/test_files.py | API-style tests for file staging upload routing/auth and async parity. |
| tests/api_resources/v2/test_extract.py | API-style tests for V2 extract sync + extract jobs routes, strict/idempotency, and 504 handling. |
| tests/api_resources/v2/test_async_smoke.py | Async smoke coverage for v2 parse/extract + jobs (create/get/wait). |
| tests/api_resources/v2/init.py | Marks v2 API resource tests package. |
| src/landingai_ade/types/v2/parse_response.py | Defines permissive V2 parse response + metadata/billing types. |
| src/landingai_ade/types/v2/job.py | Defines unified V2 Job shape, status enum, and error payload. |
| src/landingai_ade/types/v2/file_upload_response.py | Defines upload response type carrying file_ref. |
| src/landingai_ade/types/v2/extract_response.py | Defines typed V2 extract result + metadata types. |
| src/landingai_ade/types/v2/init.py | Re-exports V2 types under landingai_ade.types.v2. |
| src/landingai_ade/resources/v2/v2.py | Adds V2Resource / AsyncV2Resource container exposing client.v2.* surface. |
| src/landingai_ade/resources/v2/parse.py | Implements V2 parse sync + parse jobs create/get/list/wait (and async equivalents). |
| src/landingai_ade/resources/v2/files.py | Implements ADE-host file staging upload (/v1/files) with sync/async variants. |
| src/landingai_ade/resources/v2/extract.py | Implements V2 extract sync + extract jobs create/get/list/wait (and async equivalents). |
| src/landingai_ade/resources/v2/_normalize.py | Normalizes divergent parse/extract job envelopes into unified Job. |
| src/landingai_ade/resources/v2/_base.py | Provides V2 URL helper, polling/backoff waiter, and JobList envelope container. |
| src/landingai_ade/resources/v2/init.py | Exposes V2 resource container classes. |
| src/landingai_ade/lib/v2_errors.py | Adds V2-specific error types and sync-timeout conversion helper. |
| src/landingai_ade/lib/schema_utils.py | Adds pydantic v1/v2 schema extraction normalization and coerce_schema_to_dict. |
| src/landingai_ade/lib/init.py | Re-exports coerce_schema_to_dict in the public lib package. |
| src/landingai_ade/_client.py | Adds V2 environment map + V2 base URL resolution and client.v2 cached property. |
| README.md | Documents V2 API usage, environment selection, and examples for sync/jobs/upload flows. |
| examples/v2_parse.py | Runnable example using V2 parse jobs + sync parse. |
| examples/v2_extract.py | Runnable example using V2 extract (pydantic schema) + extract jobs. |
| api.md | Adds V2 section documenting client.v2 methods, types, and semantics. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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.
surface-lock: applied the
|
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
| # 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] = {} |
There was a problem hiding this comment.
Fixed in 6950460: Job.raw now uses Field(default_factory=dict) (with the Field import) so each instance gets its own dict. Added a test that mutates one Job's raw and asserts another Job's raw is unaffected; verified under both pydantic v1 and v2.
…ted) 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) <[email protected]>
…(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) <[email protected]>
| error: Optional[JobError] = None | ||
| # Full original envelope for fields not surfaced above (org_id, output_url, version, ...). | ||
| raw: Dict[str, object] = {} | ||
|
|
There was a problem hiding this comment.
Fixed in 6950460: Job.raw now uses Field(default_factory=dict) (with the Field import) so each instance gets its own dict. Added a test that mutates one Job's raw and asserts another Job's raw is unaffected; verified under both pydantic v1 and v2.
|
|
||
| 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] = {} |
There was a problem hiding this comment.
Good catch — writing the construct-path test exposed a real quirk: Job.raw uses Field(default_factory=dict) (from 6950460), but the SDK's construct() fast path called field_get_default() without call_default_factory=True, so on pydantic v2 raw resolved to None rather than a fresh dict. Fixed in 3890816 (_compat.field_get_default now passes call_default_factory=True on v2, matching pydantic's guidance). Added a Job.construct()-path test asserting per-instance independent raw. (Our normalizers build Job via the validating constructor, but this makes the construct path correct too.)
- 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) <[email protected]>
|
|
||
|
|
||
| def normalize_parse_job(raw: Mapping[str, Any]) -> Job: | ||
| status = JobStatus(raw.get("status") or "pending") |
There was a problem hiding this comment.
Fixed in 3890816: added a tolerant _status() helper — an unknown/renamed status string from the gateway now falls back to JobStatus.PENDING instead of raising ValueError and crashing the normalizer; the original status stays available via job.raw. Regression test added (a brand-new status value → PENDING, raw preserved), confirmed to fail without the fix, under both pydantic majors.
|
|
||
|
|
||
| def normalize_extract_job(raw: Mapping[str, Any]) -> Job: | ||
| status = JobStatus(raw.get("status") or "pending") |
There was a problem hiding this comment.
Fixed in 3890816: added a tolerant _status() helper — an unknown/renamed status string from the gateway now falls back to JobStatus.PENDING instead of raising ValueError and crashing the normalizer; the original status stays available via job.raw. Regression test added (a brand-new status value → PENDING, raw preserved), confirmed to fail without the fix, under both pydantic majors.
…ependence (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.
…hanges griffe flags a changed value of a public constant (e.g. adding staging/dev to the ENVIRONMENTS map) as breaking, tripping the gate on every PR since #105. Additive config is not an API-signature break, so filter griffe's 'Attribute value was changed' breakages; any other breakage still fails the gate (verified: renaming a public method still trips it). Removes the need for the breaking-change-approved override on such PRs. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Implements Problem 2 — V2 parse/extract SDK support per the plan in #96 (tracked in #95). Additive
client.v2sub-client; V1 surface untouched.Surface
client.v2.parse(...)— sync parse →V2ParseResponse(handles 206 partial-success;save_to; 504 →V2SyncTimeoutError)client.v2.parse_jobs.{create,list,get,wait}— async parse jobs → normalizedJobclient.v2.extract(...)— sync extract →V2ExtractResult(schema accepts a pydantic model / dict / JSON string;idempotency_key;options.strict)client.v2.extract_jobs.{create,list,get,wait}— async extract jobs →Jobclient.v2.files.upload(file) -> file_ref— stage large markdown formarkdown_refwait()polling helper with backoff; one unifiedJobshape across the divergent parse/extract envelopes (.resulttyped,.error,.rawescape hatch)landingai_ade.types.v2Hosts / environments
V2 routes to the AIDE gateway
aide.[env].landing.ai(productionaide.landing.ai, eu/staging/dev variants); V1 stays onapi.va.[env].landing.ai. Selectable viaenvironment=orLANDINGAI_ADE_ENVIRONMENT;v2_base_url/LANDINGAI_ADE_V2_BASE_URLoverrides for mocks. (api.ade.*is the separate V1/tools VTRA host — not used for V2.)Back-compat & testing
1.x../scripts/lintclean (ruff + pyright strict + mypy).Notable bugs caught & fixed during the build
coerce_schema_to_dictused the pydantic-v2-onlymodel_json_schema()→ brokeextract(schema=<pydantic model>)under pydantic v1. Fixed (v1.schema()/definitionspath).model.schema()returns a memoized shared dict; in-place mutation corrupted the class cache and threwKeyErroron the 2nd use of a nested schema. Fixed (deep-copy) + nested-model idempotency regression test.Omit/NotGivensentinels leaked into the parse multipart form (multipart skipsmaybe_transform). Fixed (is_givenfilter) + regression test.Follow-ups (non-blocking, from the final review)
parse_jobs.create()doesn't convert a local-pathdocument_urlthe way syncparse()does (documented divergence).V2ExtractResultrequiresextraction/markdown/metadata; a partialresulton a completed extract job would raise during normalization — loosen if the gateway can return partials.wait(raise_on_failure=True)keys off an attachederrorpayload, not terminalfailed/cancelledstatus alone._build_extract_bodytreats explicitNonelike omit vs parse'sis_given;liststatustypedOptional[str]notLiteral.Plan: #96 · Tracking: #95
🤖 Generated with Claude Code