Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,37 @@ The response is a `V2ParseResponse`:

If some pages cannot be parsed, the request still succeeds (HTTP 206) and `metadata.failed_pages` lists the pages that failed. If a synchronous parse times out, the client raises `V2SyncTimeoutError`; use [jobs](#process-large-documents-asynchronously-jobs) instead.

### Encrypted PDFs

Password-protected PDFs parse directly — pass `password` and skip decrypting the file yourself. The document is decrypted once at the start of processing, and the password is not retained with the result.

```python
import os
from pathlib import Path

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

`password` applies to PDFs only, and the server rejects the three mistakes with a `422`, each naming its case: `password_unsupported_content_type` (a password sent with an image or an Office document), `encrypted_pdf_wrong_password` (the password does not open the PDF), and `encrypted_pdf_password_required` (a locked PDF submitted without one).

`password` is shorthand for the contract field `options["password"]`, which is where the SDK puts it on the wire — and the only place it puts it. 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",
)
```

That applies to an explicit `None` too: `options={"password": None}` means "no password" and silences the `password` argument behind it. `options` itself must be a mapping, or a JSON string that decodes to an object — anything else raises `TypeError` before the request is sent.
Comment thread
tian-lan-landing marked this conversation as resolved.
Outdated

[ade-typescript](https://github.com/landing-ai/ade-typescript) aligns on the same precedence rule in [#121](https://github.com/landing-ai/ade-typescript/pull/121).

## Extract

Use `client.v2.extract` to pull structured fields out of Markdown (typically from a parse response) using a schema. The `schema` parameter accepts a Pydantic `BaseModel` subclass, a `dict`, or a JSON string. Provide exactly one Markdown source: `markdown` or `markdown_url`.
Expand Down
47 changes: 38 additions & 9 deletions src/landingai_ade/resources/v2/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,30 @@
__all__ = ["ParseResource", "AsyncParseResource", "ParseJobsResource", "AsyncParseJobsResource"]


def _coerce_options(options: object) -> dict[str, Any]:
"""Accept a mapping or a pre-serialized JSON string; return a plain dict.
Comment thread
tian-lan-landing marked this conversation as resolved.

The contract sends `options` as a JSON object, so anything that does not decode to
one is a caller mistake -- name the field here rather than leaving the gateway to
reject the request without naming it. `dict()` did not: it accepts any pair-sequence
in either form, so both `'[["password", "x"]]'` and `[["password", "x"]]` silently
became an options dict whose password then beat the caller's own `password` argument.

That is why the non-string branch narrows to `Mapping` -- what the `options`
annotation already declares -- instead of staying on `dict()`. This otherwise mirrors
`coerce_schema_to_dict` in `lib/schema_utils.py`, with one deliberate difference:
that helper also accepts a pydantic model, and `options` has never advertised one.
"""
if isinstance(options, str):
parsed: Any = json.loads(options) # raises ValueError on bad JSON
if not isinstance(parsed, dict):
raise TypeError("options JSON string must decode to an object")
return cast(dict[str, Any], parsed)
if isinstance(options, Mapping):
return dict(cast(Mapping[str, Any], options))
raise TypeError(f"Unsupported options type: {type(options)!r}")


def _build_parse_body(
document: object,
document_url: object,
Expand All @@ -39,20 +63,23 @@ def _build_parse_body(
# exposure in logs and proxies, and the two copies could silently disagree --
# `extra_body` overrides raw wire fields and would have replaced one without the
# other, leaving two gateway versions decrypting with different passwords.
# ade-typescript folds it the same way (`buildParseForm`).
opts: Optional[dict[str, Any]] = None
if is_given(options) and options is not None:
opts = dict(json.loads(options)) if isinstance(options, str) else dict(cast(Mapping[str, Any], options))
opts = _coerce_options(options)
# An explicit `options["password"]` wins over the kwarg, including an explicit
# `None`, which means "no password".
# `None`, which means "no password": the kwarg is only shorthand for that contract
# field, so the caller who wrote the field out is the deliberate one. ade-typescript
# aligns on this rule in landing-ai/ade-typescript#121; before that its
# `buildParseForm` let the kwarg win, so the same call decrypted with a different
# password depending on which SDK you called it from -- and the losing one surfaced
# only as a 422 `encrypted_pdf_wrong_password` naming no cause.
if (opts is None or "password" not in opts) and is_given(password) and password is not None:
opts = {} if opts is None else opts
opts["password"] = password
# `options` is a JSON-encoded string form field per the contract. `_coerce_options`
# always hands back a dict, so there is no pre-serialized string left to forward.
if opts is not None:
options = opts
# `options` is a JSON-encoded string form field per the contract.
if is_given(options) and options is not None:
options = json.dumps(options) if not isinstance(options, str) else options
options = json.dumps(opts)
raw_body = {
"document": document,
"document_url": document_url,
Expand Down Expand Up @@ -102,7 +129,8 @@ def run(
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.
field. Must be a mapping, or a JSON string that decodes to an object --
anything else raises `TypeError` before the request is sent.
Comment thread
tian-lan-landing marked this conversation as resolved.
Outdated

password: Password for an encrypted PDF. Sent to the server as `options.password`
and only there (an explicit `options["password"]` takes precedence). The
Expand Down Expand Up @@ -238,7 +266,8 @@ def create(
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.
field. Must be a mapping, or a JSON string that decodes to an object --
anything else raises `TypeError` before the request is sent.

password: Password for an encrypted PDF. Sent to the server as `options.password`
and only there (an explicit `options["password"]` takes precedence). The
Expand Down
82 changes: 82 additions & 0 deletions tests/api_resources/v2/test_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,16 @@ def test_parse_sync_merges_password_into_existing_options() -> None:
assert b'"pages": [2]' in sent
assert b'"password": "pw"' in sent

# ...and the string branch loses the tie the same way the dict branch does. This
# is the branch that diverged in ade-typescript, where the kwarg was spread in
# after the parsed string and won. The input is deliberately COMPACT while the
# assertion expects `json.dumps` spacing, so this cannot pass on a verbatim
# pass-through -- it only passes if the string was really parsed and re-serialized.
client.v2.parse(document=b"pdf", options='{"password":"explicit"}', password="kwarg-only") # type: ignore[arg-type]
sent = route.calls.last.request.content
assert b'"password": "explicit"' in sent
assert b"kwarg-only" not in sent

# An explicit `options["password"]` wins over the kwarg, and the kwarg value must
# not survive anywhere on the wire.
client.v2.parse(document=b"pdf", options={"password": "explicit"}, password="kwarg-only")
Expand All @@ -260,6 +270,59 @@ def test_parse_sync_merges_password_into_existing_options() -> None:
assert multipart_field(sent, "password") is None


@respx.mock
@pytest.mark.parametrize(
("bad", "match"),
[
# `dict()` accepts any pair-sequence, in string form and in list form alike, so
# before `_coerce_options` BOTH of these silently became an options dict whose
# password then beat the caller's own `password` argument.
('[["password", "sneaky"]]', "must decode to an object"),
([["password", "sneaky"]], "Unsupported options type"),
("[]", "must decode to an object"),
("5", "must decode to an object"),
('"str"', "must decode to an object"),
(5, "Unsupported options type"),
([("a", 1)], "Unsupported options type"),
],
)
def test_parse_sync_rejects_options_that_is_not_a_json_object(bad: object, match: str) -> None:
# `options` is a JSON object per the contract; decoding a non-object is a caller
# mistake, so name the field rather than guess at it. No route is registered on
# purpose -- a regression that sends the request surfaces as a respx routing error
# instead of quietly passing.
client = LandingAIADE(apikey=APIKEY, environment="production")
with pytest.raises(TypeError, match=match):
client.v2.parse(document=b"pdf", options=bad, password="kwarg-only") # type: ignore[arg-type]


@respx.mock
def test_parse_sync_malformed_options_json_raises_json_decode_error() -> None:
# Malformed JSON still surfaces as the error `json.loads` raises, matching
# `coerce_schema_to_dict`. Note this is a ValueError while the non-object rejections
# above are TypeErrors -- neither is a subclass of the other, so a caller guarding
# this needs both.
client = LandingAIADE(apikey=APIKEY, environment="production")
with pytest.raises(json.JSONDecodeError):
client.v2.parse(document=b"pdf", options="garbage", password="kwarg-only") # type: ignore[arg-type]


@respx.mock
@pytest.mark.asyncio
async def test_async_parse_gives_options_password_precedence() -> None:
# The async resources share `_build_parse_body`, but nothing pinned that -- the
# precedence rule has to hold on all four call sites, not just the sync two.
from landingai_ade import AsyncLandingAIADE

client = AsyncLandingAIADE(apikey=APIKEY, environment="production")
route = respx.post("https://api.ade.landing.ai/v2/parse").mock(return_value=httpx.Response(200, json=PARSE_BODY))
await client.v2.parse(document=b"pdf", options={"password": "explicit"}, password="kwarg-only")
sent = route.calls.last.request.content
assert b'"password": "explicit"' in sent
assert b"kwarg-only" not in sent
assert multipart_field(sent, "password") is None


@pytest.mark.parametrize(
"code",
["password_unsupported_content_type", "encrypted_pdf_wrong_password", "encrypted_pdf_password_required"],
Expand Down Expand Up @@ -593,6 +656,25 @@ def test_parse_job_create_folds_password_into_options() -> None:
assert multipart_field(sent, "password") is None # `options` only, like the sync route


@respx.mock
def test_parse_job_create_gives_options_password_precedence() -> None:
# The precedence rule is part of the contract, not an accident of the sync route:
# `password` is shorthand for `options["password"]`, so the explicit field wins
# here too. ade-typescript's `buildParseForm` breaks the tie the same way -- it
# used to let the kwarg win, so the same call decrypted with a different password
# depending on the SDK, and the losing one only ever surfaced as a 422
# `encrypted_pdf_wrong_password` naming no cause.
client = LandingAIADE(apikey=APIKEY)
route = respx.post("https://api.ade.landing.ai/v2/parse/jobs").mock(
return_value=httpx.Response(202, json={"job_id": "p2", "status": "pending"})
)
client.v2.parse_jobs.create(document=b"pdf", options={"password": "explicit"}, password="kwarg-only")
sent = route.calls.last.request.content
assert b'"password": "explicit"' in sent
assert b"kwarg-only" not in sent
assert multipart_field(sent, "password") is None


@respx.mock
def test_parse_job_get_completed_has_typed_result() -> None:
client = LandingAIADE(apikey=APIKEY)
Expand Down