diff --git a/README.md b/README.md index f444783..0ca580d 100644 --- a/README.md +++ b/README.md @@ -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 accepts a mapping or a JSON string that decodes to an object. Malformed JSON raises `json.JSONDecodeError`; a value that decodes to a non-object raises `TypeError`. Both are raised before the request is sent. + +[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`. diff --git a/src/landingai_ade/resources/v2/parse.py b/src/landingai_ade/resources/v2/parse.py index c9d3248..c2d904d 100644 --- a/src/landingai_ade/resources/v2/parse.py +++ b/src/landingai_ade/resources/v2/parse.py @@ -3,7 +3,7 @@ import json import time -from typing import Any, Mapping, Callable, Optional, cast +from typing import Any, Union, Mapping, Callable, Optional, cast from pathlib import Path from typing_extensions import Literal @@ -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. + + 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, @@ -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, @@ -75,7 +102,7 @@ def run( document: Optional[FileTypes] | Omit = omit, document_url: Optional[str] | Omit = omit, model: Optional[str] | Omit = omit, - options: Optional[Mapping[str, object]] | Omit = omit, + options: Optional[Union[str, 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. @@ -102,7 +129,9 @@ 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. Accepts a mapping, or a JSON string that decodes to an object. + Malformed JSON raises `json.JSONDecodeError`; a value that decodes to a + non-object raises `TypeError`. Both are raised 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 @@ -165,7 +194,7 @@ async def run( document: Optional[FileTypes] | Omit = omit, document_url: Optional[str] | Omit = omit, model: Optional[str] | Omit = omit, - options: Optional[Mapping[str, object]] | Omit = omit, + options: Optional[Union[str, 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. @@ -212,7 +241,7 @@ def create( document: Optional[FileTypes] | Omit = omit, document_url: Optional[str] | Omit = omit, model: Optional[str] | Omit = omit, - options: Optional[Mapping[str, object]] | Omit = omit, + options: Optional[Union[str, Mapping[str, object]]] | Omit = omit, password: Optional[str] | Omit = omit, output_save_url: Optional[str] | Omit = omit, service_tier: Optional[Literal["standard", "priority"]] | Omit = omit, @@ -238,7 +267,9 @@ 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. Accepts a mapping, or a JSON string that decodes to an object. + Malformed JSON raises `json.JSONDecodeError`; a value that decodes to a + non-object raises `TypeError`. Both are raised 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 @@ -375,7 +406,7 @@ async def create( document: Optional[FileTypes] | Omit = omit, document_url: Optional[str] | Omit = omit, model: Optional[str] | Omit = omit, - options: Optional[Mapping[str, object]] | Omit = omit, + options: Optional[Union[str, Mapping[str, object]]] | Omit = omit, password: Optional[str] | Omit = omit, output_save_url: Optional[str] | Omit = omit, service_tier: Optional[Literal["standard", "priority"]] | Omit = omit, diff --git a/src/landingai_ade/resources/v2/v2.py b/src/landingai_ade/resources/v2/v2.py index ae144aa..422de36 100644 --- a/src/landingai_ade/resources/v2/v2.py +++ b/src/landingai_ade/resources/v2/v2.py @@ -48,7 +48,7 @@ def parse( document: Optional[FileTypes] | Omit = omit, document_url: Optional[str] | Omit = omit, model: Optional[str] | Omit = omit, - options: Optional[Mapping[str, object]] | Omit = omit, + options: Optional[Union[str, 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. @@ -164,7 +164,7 @@ async def parse( document: Optional[FileTypes] | Omit = omit, document_url: Optional[str] | Omit = omit, model: Optional[str] | Omit = omit, - options: Optional[Mapping[str, object]] | Omit = omit, + options: Optional[Union[str, 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. diff --git a/tests/api_resources/v2/test_parse.py b/tests/api_resources/v2/test_parse.py index ebc8bd3..ede4ffa 100644 --- a/tests/api_resources/v2/test_parse.py +++ b/tests/api_resources/v2/test_parse.py @@ -237,13 +237,23 @@ def test_parse_sync_merges_password_into_existing_options() -> None: assert b'"pages": [1]' in sent assert b'"password": "pw"' in sent - # A pre-serialized JSON string for `options` is tolerated at runtime (though - # the signature advertises a Mapping); the password must merge into it too. - client.v2.parse(document=b"pdf", options='{"pages": [2]}', password="pw") # type: ignore[arg-type] + # `options` also accepts a pre-serialized JSON string; the password must merge + # into it too. + client.v2.parse(document=b"pdf", options='{"pages": [2]}', password="pw") sent = route.calls.last.request.content 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") + 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") @@ -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") + + +@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"], @@ -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)