# The LandingAI ADE client: parse a PDF into grounded Markdown, then pull typed fields out of it

> A typed Python SDK for Agentic Document Extraction, with a two-step shape that separates reading a document from querying it, and unusually careful handling of PDF passwords.

**landing-ai/ade-python** — Python library for Agentic Document Extraction (ADE).

- Repository: https://github.com/landing-ai/ade-python
- Website: https://ade.landing.ai
- Stars: 1,034 · Forks: 167
- Language: Python
- License: Apache-2.0
- Published: 2026-10-07 · Updated: 2026-10-07 · Language: en
- Canonical page: https://hysenlabs.com/projects/landing-ai-ade-python

## Two calls, deliberately not one

The README's quickstart is short enough to read as a design statement. Define a Pydantic model, construct a client, parse, then extract:

```python
class Invoice(BaseModel):
    invoice_number: str = Field(description="The invoice number")
    total: str = Field(description="Invoice grand total")


client = LandingAIADE()  # reads VISION_AGENT_API_KEY

# 1. Parse: convert the document to structured Markdown
parsed = client.v2.parse(document=Path("invoice.pdf"))
print(parsed.markdown)

# 2. Extract: pull typed fields out of the Markdown
result = client.v2.extract(schema=Invoice, markdown=parsed.markdown)
print(result.extraction)
```

The two-step shape is the thing to understand. Parse does not answer your question, it converts a document into a representation. Extract does not read the document, it queries the representation you already have.

That gives you options a single-call design does not. Parse once, extract several schemas from the same Markdown, at no extra document processing cost. Inspect the Markdown before extracting, and correct it if the conversion lost a table. Keep the parse result in a cache and re-run extraction as your schema evolves, without paying to re-read the document.

The README is explicit that `client.v2` is the API for new projects and that the earlier v1 methods, `client.parse`, `client.extract` and `client.split`, remain fully supported. Both API surfaces are documented, with the full method reference in `api.md`.

The feature list adds the pieces a production integration needs: fully typed requests with Pydantic response models, sync and async clients with identical surfaces, async jobs with a built-in `wait()` helper for large documents, automatic retries with exponential backoff, and an optional `save_to` parameter to write responses to disk.

## Grounding means every element has coordinates

The `parse` response is documented field by field, and the two fields that matter most are `structure` and `grounding`.

`markdown` is the full document as one Markdown string in reading order. `structure` is a typed tree going document, then pages, then elements, and each node carries its spatial grounding: a page number, a character range into the Markdown, and a normalized-coordinate bounding box. `metadata` carries `page_count`, `failed_pages`, `duration_ms` and `billing`.

There is a subtlety in the field list that tells you something about the API's history. `grounding` is described as a legacy tree mirroring `structure`, present only on older gateway responses, while newer responses carry grounding inline on the structure nodes. So you will encounter both shapes depending on which gateway version answers you, and a client written against only one of them will break on the other. That is a real migration concern and it is documented rather than glossed over.

```python
parsed = client.v2.parse(
    document=Path("path/to/file.pdf"),
    model="dpt-3-pro-latest",  # optional; defaults to the latest DPT-3 Pro model
    save_to="./output",  # optional; saves as {input_file}_parse_output.json
)

print(parsed.markdown)  # full document as Markdown
print(parsed.metadata.page_count)  # pages processed
```

Coordinates matter more than they first appear. If you need to highlight a region on the original page, verify a field visually, or attach a source location to an extracted value, the bounding box is what connects the extracted text back to the document. Without it, an extraction result is a value with no provenance.

Partial failure is handled too. If some pages cannot be parsed the request still succeeds with HTTP 206, and `metadata.failed_pages` lists which ones failed. A synchronous parse that times out raises `V2SyncTimeoutError`, and the README points you to the async jobs path instead of suggesting a retry of the same call.

## Schemas come in three shapes, and one is not quite like the others

The `extract` method's `schema` parameter accepts a Pydantic `BaseModel` subclass, a `dict`, or a JSON string. That flexibility is convenient and it means you can build the schema from a config file or an existing JSON Schema document without writing a model class.

You also supply exactly one Markdown source, either `markdown` or `markdown_url`. The response gives you `extraction` as the typed result and `extraction_metadata` with per-field source ranges in the Markdown. That second field is the useful one: it tells you where in the document each extracted value came from, which is how you can route low-confidence fields to human review without re-running anything.

```python
result = client.v2.extract(
    schema=Person,  # Pydantic model, dict, or JSON string
    markdown=parsed.markdown,  # or markdown_url="https://example.com/doc.md"
    save_to="./output",  # optional
)

print(result.extraction)  # {"name": "...", "age": ...}
print(result.extraction_metadata)  # per-field source ranges in the Markdown
```

The `Field(description=...)` pattern is doing real work in both examples, and it is worth understanding why. The description is not a comment for the reader of your code, it is a prompt input. These are language model extractions, so the field descriptions are how you tell the model what you mean by invoice number or grand total. A field called `total` with no description gets an ambiguous answer; a field called `total` described as the invoice grand total gets the number you wanted.

That is the single most important practical detail in this SDK. The Pydantic model is doing double duty as both a type for your code and as the specification handed to the extraction model.

## Encrypted PDFs, documented at the level of individual error codes

Password-protected PDFs parse directly, with a `password` argument, and the README makes a point of the mechanics: the document is decrypted once at the start of processing, and the password is not retained with the result.

```python
parsed = client.v2.parse(
    document=Path("locked.pdf"),
    password=os.environ["PDF_PASSWORD"],
)
```

Then it enumerates the three failure modes, each with its own HTTP 422 and its own named case: `password_unsupported_content_type` when a password is sent with an image or an Office document, `encrypted_pdf_wrong_password` when the password does not open the PDF, and `encrypted_pdf_password_required` when a locked PDF is submitted without one. Naming the cases means you can branch on the error instead of string-matching a message.

The more interesting part is the precedence rule, and it has a sharp edge worth knowing about. `password` is shorthand for `options["password"]`, and that is the only place the SDK puts it on the wire. Both forms work. If you supply both, the explicit `options["password"]` wins.

```python
# sends options["password"] = "from-options"
client.v2.parse(
    document=Path("locked.pdf"),
    options={"password": "from-options"},
    password="ignored",
)
```

The documented edge case is that this applies to an explicit `None` too. `options={"password": None}` means no password, and it silences the `password` argument behind it. So a merge helper that sets options keys can quietly drop a password you passed as an argument, with no error at the call site.

The `options` parameter itself is permissive: it accepts a mapping or a JSON string that decodes to an object. Malformed JSON raises `json.JSONDecodeError` and a value decoding to a non-object raises `TypeError`, both before the request is sent. Release v1.18.0 pinned this precedence rule and added a fix to keep the password out of debug logs, and the corresponding TypeScript client aligned on the same rule. For a credential, the fact that both clients now agree on the precedence is the thing that matters most.

## A small dependency set, and what it implies about the transport

The `pyproject.toml` declares a deliberately short runtime dependency list. There is `httpx` with a floor of 0.23.0 and a ceiling below 1, `pydantic` accepting anything from 1.9 up to but not including 3, `typing-extensions`, `anyio`, `distro`, and `sniffio`.

Two details there are worth reading carefully. First, the Pydantic range spans both major versions, from 1.9 to under 3, which means the client supports codebases still on Pydantic v1 as well as those on v2. Second, the presence of `sniffio`, a library whose entire job is detecting whether you are in asyncio or trio, tells you the async client is not hardcoded to one event loop. That matters for anyone running this inside an existing async application.

`anyio` supports the same goal from the other direction. Both of those libraries together say the async surface is designed to coexist with whatever async framework you already have.

The optional extra is `aiohttp`, together with `httpx_aiohttp`. That is an unusual shape and a useful one, since it suggests the httpx transport can be swapped for an aiohttp-backed one in environments where httpx is not the right fit, which is a real consideration in some corporate network stacks.

The package requires Python 3.9 or newer, and the classifier list names 3.9 through 3.14 explicitly. The README badge agrees, showing Python 3.9 and later. There is a `noxfile.py` and a `requirements-dev.lock` at the root, with the comment in the config noting that exact pins matter because generated code is version-sensitive, so the committed reference models must be produced by the same tool version CI installs. That is a sensible constraint for a project that ships generated models under `specs/`.

## Release rhythm, and what the repository is actually for

The release history shows an active project with a fast cadence. v1.17.1 came out on 2026-08-28, v1.18.0 on 2026-09-11, and v1.18.1 on 2026-09-17. The package version in `pyproject.toml` matches the latest tag at 1.18.1.

The release notes are auto-generated in conventional-commit style with Features, Bug Fixes, Chores and Other Changes sections, and each entry links to a specific commit hash. That is a project with release engineering in place. The entries also show a `spec-sync` category appearing repeatedly, meaning changes to the API specification are propagated into committed reference models and regenerated as part of the same change, which explains the strict tool pinning.

A few specific entries indicate the direction. v1.18.1 exposed extract grounding as a top-level keyword on the v2 API, alongside a spec snapshot update. v1.18.0 pinned the parse password precedence rule, rejected a non-object options value, and kept the password out of debug logs. v1.17.1 renamed a model in the spec, changing `dpt-3-fast` to `dpt-3-verity` and narrowing the confidence scope, which is a reminder that model identifiers in this API move and should not be hardcoded without checking.

The repository structure confirms this is a client library rather than a demo. `src/` holds the implementation, `tests/` the test suite, `specs/` the API specification with generated reference models, `api.md` the method reference, `docs/` usage guides, `examples/` runnable samples, `bin/` and `scripts/` tooling, and `.devcontainer/` plus a `Brewfile` for local setup. There is also `SECURITY.md` and a `.vscode/` directory.

With 1,034 stars and 167 forks against 14 open issues, this is a young, actively maintained client with a narrow and well-defined surface. Its documentation lives at docs.landing.ai and the playground at ade.landing.ai, which is the fastest way to see what a parse looks like before writing any code.

## Conclusion

This SDK's design decision is to split the work in two rather than hide it. `parse` turns a document into Markdown plus a structure tree with pixel-coordinate bounding boxes on every element, and `extract` then queries that Markdown with a schema you supply. That separation has real consequences: you can parse once and extract many different schemas from the same result, and you can inspect or correct the Markdown before extraction rather than accepting whatever the model inferred as final. The v2 API is the current one and the v1 methods remain supported, so an existing integration does not break by moving. The place to look closely before shipping is the password handling on encrypted PDFs, because it is documented at a level of detail that suggests real operational pain, including a precedence rule between the `password` argument and `options["password"]` where an explicit None in options silently wins. If you are new to it, write one Pydantic model, parse one document you already know the answer to, and check the extracted fields against your own reading before trusting the pipeline.

## FAQ

### What is the ADE Python library used for?

It converts documents into structured, grounded Markdown and then extracts typed fields from that Markdown. The `client.v2.parse` call returns the Markdown plus a structure tree in which every element carries a page number, a character range and a bounding box. The `client.v2.extract` call then queries that result with a Pydantic model, a dict, or a JSON string schema and returns typed values with source ranges.

### Can I parse a password-protected PDF?

Yes. Pass a `password` argument and the document is decrypted once at the start of processing, and the password is not retained with the result. Three error cases return HTTP 422 with named codes: a password sent with an image or Office document, a wrong password, and a locked PDF submitted with no password. If you set `options["password"]` explicitly it takes precedence over the `password` argument, including when it is explicitly None.

### Does the ADE client support async?

Yes, and the sync and async clients have identical surfaces. Large documents can go through async jobs with a built-in `wait()` helper, which is also the documented path when a synchronous parse times out and raises `V2SyncTimeoutError`. The async implementation detects the running event loop rather than assuming asyncio, so it can run inside other async frameworks.

### How do I install the LandingAI ADE Python library and authenticate?

Install it with `pip install landingai-ade`, then export your key as `VISION_AGENT_API_KEY` and the client reads it automatically. You can also pass it directly with `LandingAIADE(apikey=...)`. The project suggests a tool like python-dotenv to keep the key out of source control. Python 3.9 or newer is required.

### What happens if some pages of a document fail to parse?

The request still succeeds and returns HTTP 206, with `metadata.failed_pages` listing the pages that did not parse. This is a deliberate partial-success design rather than an exception, so you can check that list after a large parse instead of losing the whole document to one bad page.

## Sources

- [landing-ai/ade-python on GitHub](https://github.com/landing-ai/ade-python)
- [License: Apache-2.0](https://github.com/landing-ai/ade-python/blob/main/LICENSE)
- [Project website](https://ade.landing.ai)
- [README](https://github.com/landing-ai/ade-python/blob/main/README.md)
- [Releases](https://github.com/landing-ai/ade-python/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/landing-ai-ade-python
