From 7f4c7c5e1b4aecce6500a5f2a86924d7389de363 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:23:18 +0000 Subject: [PATCH 1/7] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 603f57a..25ece8f 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 21 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-d9763d006969b49a1473851069fdfa429eb13133b64103a62963bb70ddb22305.yml -openapi_spec_hash: 6aee689b7a759b12c85c088c15e29bc0 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-d868ff00b7b07f6b6802b00f22fad531a91a76bb219a634f3f90fe488bd499ba.yml +openapi_spec_hash: 20e9f2fc31feee78878cdf56e46dab60 config_hash: 5509bb7a961ae2e79114b24c381606d4 From 3e5eea1e92928bd98243022c607f6f15afa24d52 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 04:38:32 +0000 Subject: [PATCH 2/7] fix(client): preserve hardcoded query params when merging with user params --- src/cas_parser/_base_client.py | 4 +++ tests/test_client.py | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/cas_parser/_base_client.py b/src/cas_parser/_base_client.py index 5b130cf..33ae5fa 100644 --- a/src/cas_parser/_base_client.py +++ b/src/cas_parser/_base_client.py @@ -558,6 +558,10 @@ def _build_request( files = cast(HttpxRequestFiles, ForceMultipartDict()) prepared_url = self._prepare_url(options.url) + # preserve hard-coded query params from the url + if params and prepared_url.query: + params = {**dict(prepared_url.params.items()), **params} + prepared_url = prepared_url.copy_with(raw_path=prepared_url.raw_path.split(b"?", 1)[0]) if "_" in prepared_url.host: # work around https://github.com/encode/httpx/discussions/2880 kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} diff --git a/tests/test_client.py b/tests/test_client.py index 9e2377f..dd96b67 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -429,6 +429,30 @@ def test_default_query_option(self) -> None: client.close() + def test_hardcoded_query_params_in_url(self, client: CasParser) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + def test_request_extra_json(self, client: CasParser) -> None: request = client._build_request( FinalRequestOptions( @@ -1320,6 +1344,30 @@ async def test_default_query_option(self) -> None: await client.close() + async def test_hardcoded_query_params_in_url(self, async_client: AsyncCasParser) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + def test_request_extra_json(self, client: CasParser) -> None: request = client._build_request( FinalRequestOptions( From 4985a349eee6f59007dfdf770c26deba75acc7dd Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:46:54 +0000 Subject: [PATCH 3/7] fix: ensure file data are only sent as 1 parameter --- src/cas_parser/_utils/_utils.py | 5 +++-- tests/test_extract_files.py | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/cas_parser/_utils/_utils.py b/src/cas_parser/_utils/_utils.py index eec7f4a..63b8cd6 100644 --- a/src/cas_parser/_utils/_utils.py +++ b/src/cas_parser/_utils/_utils.py @@ -86,8 +86,9 @@ def _extract_items( index += 1 if is_dict(obj): try: - # We are at the last entry in the path so we must remove the field - if (len(path)) == index: + # Remove the field if there are no more dict keys in the path, + # only "" traversal markers or end. + if all(p == "" for p in path[index:]): item = obj.pop(key) else: item = obj[key] diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py index 37985fb..bb4d8bb 100644 --- a/tests/test_extract_files.py +++ b/tests/test_extract_files.py @@ -35,6 +35,15 @@ def test_multiple_files() -> None: assert query == {"documents": [{}, {}]} +def test_top_level_file_array() -> None: + query = {"files": [b"file one", b"file two"], "title": "hello"} + assert extract_files(query, paths=[["files", ""]]) == [ + ("files[]", b"file one"), + ("files[]", b"file two"), + ] + assert query == {"title": "hello"} + + @pytest.mark.parametrize( "query,paths,expected", [ From 1c854af1baa2f1e702881692b65e9a85efffedd5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:14:00 +0000 Subject: [PATCH 4/7] perf(client): optimize file structure copying in multipart requests --- src/cas_parser/_files.py | 56 ++++++++++++- src/cas_parser/_utils/__init__.py | 1 - src/cas_parser/_utils/_utils.py | 15 ---- src/cas_parser/resources/cams_kfintech.py | 13 +-- src/cas_parser/resources/cdsl/cdsl.py | 13 +-- src/cas_parser/resources/contract_note.py | 13 +-- src/cas_parser/resources/nsdl.py | 13 +-- src/cas_parser/resources/smart.py | 13 +-- tests/test_deepcopy.py | 58 ------------- tests/test_files.py | 99 ++++++++++++++++++++++- 10 files changed, 191 insertions(+), 103 deletions(-) delete mode 100644 tests/test_deepcopy.py diff --git a/src/cas_parser/_files.py b/src/cas_parser/_files.py index cc14c14..0fdce17 100644 --- a/src/cas_parser/_files.py +++ b/src/cas_parser/_files.py @@ -3,8 +3,8 @@ import io import os import pathlib -from typing import overload -from typing_extensions import TypeGuard +from typing import Sequence, cast, overload +from typing_extensions import TypeVar, TypeGuard import anyio @@ -17,7 +17,9 @@ HttpxFileContent, HttpxRequestFiles, ) -from ._utils import is_tuple_t, is_mapping_t, is_sequence_t +from ._utils import is_list, is_mapping, is_tuple_t, is_mapping_t, is_sequence_t + +_T = TypeVar("_T") def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: @@ -121,3 +123,51 @@ async def async_read_file_content(file: FileContent) -> HttpxFileContent: return await anyio.Path(file).read_bytes() return file + + +def deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]]) -> _T: + """Copy only the containers along the given paths. + + Used to guard against mutation by extract_files without copying the entire structure. + Only dicts and lists that lie on a path are copied; everything else + is returned by reference. + + For example, given paths=[["foo", "files", "file"]] and the structure: + { + "foo": { + "bar": {"baz": {}}, + "files": {"file": } + } + } + The root dict, "foo", and "files" are copied (they lie on the path). + "bar" and "baz" are returned by reference (off the path). + """ + return _deepcopy_with_paths(item, paths, 0) + + +def _deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]], index: int) -> _T: + if not paths: + return item + if is_mapping(item): + key_to_paths: dict[str, list[Sequence[str]]] = {} + for path in paths: + if index < len(path): + key_to_paths.setdefault(path[index], []).append(path) + + # if no path continues through this mapping, it won't be mutated and copying it is redundant + if not key_to_paths: + return item + + result = dict(item) + for key, subpaths in key_to_paths.items(): + if key in result: + result[key] = _deepcopy_with_paths(result[key], subpaths, index + 1) + return cast(_T, result) + if is_list(item): + array_paths = [path for path in paths if index < len(path) and path[index] == ""] + + # if no path expects a list here, nothing will be mutated inside it - return by reference + if not array_paths: + return cast(_T, item) + return cast(_T, [_deepcopy_with_paths(entry, array_paths, index + 1) for entry in item]) + return item diff --git a/src/cas_parser/_utils/__init__.py b/src/cas_parser/_utils/__init__.py index 10cb66d..1c090e5 100644 --- a/src/cas_parser/_utils/__init__.py +++ b/src/cas_parser/_utils/__init__.py @@ -24,7 +24,6 @@ coerce_integer as coerce_integer, file_from_path as file_from_path, strip_not_given as strip_not_given, - deepcopy_minimal as deepcopy_minimal, get_async_library as get_async_library, maybe_coerce_float as maybe_coerce_float, get_required_header as get_required_header, diff --git a/src/cas_parser/_utils/_utils.py b/src/cas_parser/_utils/_utils.py index 63b8cd6..771859f 100644 --- a/src/cas_parser/_utils/_utils.py +++ b/src/cas_parser/_utils/_utils.py @@ -177,21 +177,6 @@ def is_iterable(obj: object) -> TypeGuard[Iterable[object]]: return isinstance(obj, Iterable) -def deepcopy_minimal(item: _T) -> _T: - """Minimal reimplementation of copy.deepcopy() that will only copy certain object types: - - - mappings, e.g. `dict` - - list - - This is done for performance reasons. - """ - if is_mapping(item): - return cast(_T, {k: deepcopy_minimal(v) for k, v in item.items()}) - if is_list(item): - return cast(_T, [deepcopy_minimal(entry) for entry in item]) - return item - - # copied from https://github.com/Rapptz/RoboDanny def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str: size = len(seq) diff --git a/src/cas_parser/resources/cams_kfintech.py b/src/cas_parser/resources/cams_kfintech.py index fb32699..c11c3a9 100644 --- a/src/cas_parser/resources/cams_kfintech.py +++ b/src/cas_parser/resources/cams_kfintech.py @@ -7,8 +7,9 @@ import httpx from ..types import cams_kfintech_parse_params +from .._files import deepcopy_with_paths from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform +from .._utils import extract_files, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -78,12 +79,13 @@ def parse( timeout: Override the client-level default timeout for this request, in seconds """ - body = deepcopy_minimal( + body = deepcopy_with_paths( { "password": password, "pdf_file": pdf_file, "pdf_url": pdf_url, - } + }, + [["pdf_file"]], ) files = extract_files(cast(Mapping[str, object], body), paths=[["pdf_file"]]) if files: @@ -157,12 +159,13 @@ async def parse( timeout: Override the client-level default timeout for this request, in seconds """ - body = deepcopy_minimal( + body = deepcopy_with_paths( { "password": password, "pdf_file": pdf_file, "pdf_url": pdf_url, - } + }, + [["pdf_file"]], ) files = extract_files(cast(Mapping[str, object], body), paths=[["pdf_file"]]) if files: diff --git a/src/cas_parser/resources/cdsl/cdsl.py b/src/cas_parser/resources/cdsl/cdsl.py index d0c69b9..75e66b3 100644 --- a/src/cas_parser/resources/cdsl/cdsl.py +++ b/src/cas_parser/resources/cdsl/cdsl.py @@ -15,8 +15,9 @@ AsyncFetchResourceWithStreamingResponse, ) from ...types import cdsl_parse_pdf_params +from ..._files import deepcopy_with_paths from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from ..._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform +from ..._utils import extract_files, maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -94,12 +95,13 @@ def parse_pdf( timeout: Override the client-level default timeout for this request, in seconds """ - body = deepcopy_minimal( + body = deepcopy_with_paths( { "password": password, "pdf_file": pdf_file, "pdf_url": pdf_url, - } + }, + [["pdf_file"]], ) files = extract_files(cast(Mapping[str, object], body), paths=[["pdf_file"]]) if files: @@ -181,12 +183,13 @@ async def parse_pdf( timeout: Override the client-level default timeout for this request, in seconds """ - body = deepcopy_minimal( + body = deepcopy_with_paths( { "password": password, "pdf_file": pdf_file, "pdf_url": pdf_url, - } + }, + [["pdf_file"]], ) files = extract_files(cast(Mapping[str, object], body), paths=[["pdf_file"]]) if files: diff --git a/src/cas_parser/resources/contract_note.py b/src/cas_parser/resources/contract_note.py index 7133be7..45e6464 100644 --- a/src/cas_parser/resources/contract_note.py +++ b/src/cas_parser/resources/contract_note.py @@ -8,8 +8,9 @@ import httpx from ..types import contract_note_parse_params +from .._files import deepcopy_with_paths from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform +from .._utils import extract_files, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -110,13 +111,14 @@ def parse( timeout: Override the client-level default timeout for this request, in seconds """ - body = deepcopy_minimal( + body = deepcopy_with_paths( { "broker_type": broker_type, "password": password, "pdf_file": pdf_file, "pdf_url": pdf_url, - } + }, + [["pdf_file"]], ) files = extract_files(cast(Mapping[str, object], body), paths=[["pdf_file"]]) if files: @@ -221,13 +223,14 @@ async def parse( timeout: Override the client-level default timeout for this request, in seconds """ - body = deepcopy_minimal( + body = deepcopy_with_paths( { "broker_type": broker_type, "password": password, "pdf_file": pdf_file, "pdf_url": pdf_url, - } + }, + [["pdf_file"]], ) files = extract_files(cast(Mapping[str, object], body), paths=[["pdf_file"]]) if files: diff --git a/src/cas_parser/resources/nsdl.py b/src/cas_parser/resources/nsdl.py index 4312757..9e3f8d1 100644 --- a/src/cas_parser/resources/nsdl.py +++ b/src/cas_parser/resources/nsdl.py @@ -7,8 +7,9 @@ import httpx from ..types import nsdl_parse_params +from .._files import deepcopy_with_paths from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform +from .._utils import extract_files, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -78,12 +79,13 @@ def parse( timeout: Override the client-level default timeout for this request, in seconds """ - body = deepcopy_minimal( + body = deepcopy_with_paths( { "password": password, "pdf_file": pdf_file, "pdf_url": pdf_url, - } + }, + [["pdf_file"]], ) files = extract_files(cast(Mapping[str, object], body), paths=[["pdf_file"]]) if files: @@ -157,12 +159,13 @@ async def parse( timeout: Override the client-level default timeout for this request, in seconds """ - body = deepcopy_minimal( + body = deepcopy_with_paths( { "password": password, "pdf_file": pdf_file, "pdf_url": pdf_url, - } + }, + [["pdf_file"]], ) files = extract_files(cast(Mapping[str, object], body), paths=[["pdf_file"]]) if files: diff --git a/src/cas_parser/resources/smart.py b/src/cas_parser/resources/smart.py index 0d85213..1cb95d5 100644 --- a/src/cas_parser/resources/smart.py +++ b/src/cas_parser/resources/smart.py @@ -7,8 +7,9 @@ import httpx from ..types import smart_parse_cas_pdf_params +from .._files import deepcopy_with_paths from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform +from .._utils import extract_files, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -79,12 +80,13 @@ def parse_cas_pdf( timeout: Override the client-level default timeout for this request, in seconds """ - body = deepcopy_minimal( + body = deepcopy_with_paths( { "password": password, "pdf_file": pdf_file, "pdf_url": pdf_url, - } + }, + [["pdf_file"]], ) files = extract_files(cast(Mapping[str, object], body), paths=[["pdf_file"]]) if files: @@ -159,12 +161,13 @@ async def parse_cas_pdf( timeout: Override the client-level default timeout for this request, in seconds """ - body = deepcopy_minimal( + body = deepcopy_with_paths( { "password": password, "pdf_file": pdf_file, "pdf_url": pdf_url, - } + }, + [["pdf_file"]], ) files = extract_files(cast(Mapping[str, object], body), paths=[["pdf_file"]]) if files: diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py deleted file mode 100644 index c1e03c0..0000000 --- a/tests/test_deepcopy.py +++ /dev/null @@ -1,58 +0,0 @@ -from cas_parser._utils import deepcopy_minimal - - -def assert_different_identities(obj1: object, obj2: object) -> None: - assert obj1 == obj2 - assert id(obj1) != id(obj2) - - -def test_simple_dict() -> None: - obj1 = {"foo": "bar"} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_dict() -> None: - obj1 = {"foo": {"bar": True}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - - -def test_complex_nested_dict() -> None: - obj1 = {"foo": {"bar": [{"hello": "world"}]}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - assert_different_identities(obj1["foo"]["bar"], obj2["foo"]["bar"]) - assert_different_identities(obj1["foo"]["bar"][0], obj2["foo"]["bar"][0]) - - -def test_simple_list() -> None: - obj1 = ["a", "b", "c"] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_list() -> None: - obj1 = ["a", [1, 2, 3]] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1[1], obj2[1]) - - -class MyObject: ... - - -def test_ignores_other_types() -> None: - # custom classes - my_obj = MyObject() - obj1 = {"foo": my_obj} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert obj1["foo"] is my_obj - - # tuples - obj3 = ("a", "b") - obj4 = deepcopy_minimal(obj3) - assert obj3 is obj4 diff --git a/tests/test_files.py b/tests/test_files.py index 1f448b8..117534b 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -4,7 +4,8 @@ import pytest from dirty_equals import IsDict, IsList, IsBytes, IsTuple -from cas_parser._files import to_httpx_files, async_to_httpx_files +from cas_parser._files import to_httpx_files, deepcopy_with_paths, async_to_httpx_files +from cas_parser._utils import extract_files readme_path = Path(__file__).parent.parent.joinpath("README.md") @@ -49,3 +50,99 @@ def test_string_not_allowed() -> None: "file": "foo", # type: ignore } ) + + +def assert_different_identities(obj1: object, obj2: object) -> None: + assert obj1 == obj2 + assert obj1 is not obj2 + + +class TestDeepcopyWithPaths: + def test_copies_top_level_dict(self) -> None: + original = {"file": b"data", "other": "value"} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + + def test_file_value_is_same_reference(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + assert result["file"] is file_bytes + + def test_list_popped_wholesale(self) -> None: + files = [b"f1", b"f2"] + original = {"files": files, "title": "t"} + result = deepcopy_with_paths(original, [["files", ""]]) + assert_different_identities(result, original) + result_files = result["files"] + assert isinstance(result_files, list) + assert_different_identities(result_files, files) + + def test_nested_array_path_copies_list_and_elements(self) -> None: + elem1 = {"file": b"f1", "extra": 1} + elem2 = {"file": b"f2", "extra": 2} + original = {"items": [elem1, elem2]} + result = deepcopy_with_paths(original, [["items", "", "file"]]) + assert_different_identities(result, original) + result_items = result["items"] + assert isinstance(result_items, list) + assert_different_identities(result_items, original["items"]) + assert_different_identities(result_items[0], elem1) + assert_different_identities(result_items[1], elem2) + + def test_empty_paths_returns_same_object(self) -> None: + original = {"foo": "bar"} + result = deepcopy_with_paths(original, []) + assert result is original + + def test_multiple_paths(self) -> None: + f1 = b"file1" + f2 = b"file2" + original = {"a": f1, "b": f2, "c": "unchanged"} + result = deepcopy_with_paths(original, [["a"], ["b"]]) + assert_different_identities(result, original) + assert result["a"] is f1 + assert result["b"] is f2 + assert result["c"] is original["c"] + + def test_extract_files_does_not_mutate_original_top_level(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes, "other": "value"} + + copied = deepcopy_with_paths(original, [["file"]]) + extracted = extract_files(copied, paths=[["file"]]) + + assert extracted == [("file", file_bytes)] + assert original == {"file": file_bytes, "other": "value"} + assert copied == {"other": "value"} + + def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None: + file1 = b"f1" + file2 = b"f2" + original = { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + + copied = deepcopy_with_paths(original, [["items", "", "file"]]) + extracted = extract_files(copied, paths=[["items", "", "file"]]) + + assert extracted == [("items[][file]", file1), ("items[][file]", file2)] + assert original == { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + assert copied == { + "items": [ + {"extra": 1}, + {"extra": 2}, + ], + "title": "example", + } From 2e571178acf86c879c727a43cff97ab4e05c1e2d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:25:11 +0000 Subject: [PATCH 5/7] feat(api): api update --- .stats.yml | 4 +- src/cas_parser/resources/inbound_email.py | 92 +++++++------------ .../types/inbound_email_create_params.py | 26 +++--- .../types/inbound_email_create_response.py | 6 +- .../types/inbound_email_list_response.py | 6 +- .../types/inbound_email_retrieve_response.py | 6 +- tests/api_resources/test_inbound_email.py | 28 ++---- 7 files changed, 68 insertions(+), 100 deletions(-) diff --git a/.stats.yml b/.stats.yml index 25ece8f..9c6cf93 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 21 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-d868ff00b7b07f6b6802b00f22fad531a91a76bb219a634f3f90fe488bd499ba.yml -openapi_spec_hash: 20e9f2fc31feee78878cdf56e46dab60 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-78ef474b9e171a3eaa430a9dacdc2fa5c7f7d5f89147cb20573a355d3dbb9f0e.yml +openapi_spec_hash: 11b6e43ef4ed724f9804c9d790a4faee config_hash: 5509bb7a961ae2e79114b24c381606d4 diff --git a/src/cas_parser/resources/inbound_email.py b/src/cas_parser/resources/inbound_email.py index adc1c15..6028cba 100644 --- a/src/cas_parser/resources/inbound_email.py +++ b/src/cas_parser/resources/inbound_email.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Dict, List +from typing import Dict, List, Optional from typing_extensions import Literal import httpx @@ -73,9 +73,9 @@ def with_streaming_response(self) -> InboundEmailResourceWithStreamingResponse: def create( self, *, - callback_url: str, alias: str | Omit = omit, allowed_sources: List[Literal["cdsl", "nsdl", "cams", "kfintech"]] | Omit = omit, + callback_url: Optional[str] | Omit = omit, metadata: Dict[str, str] | Omit = omit, reference: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -87,38 +87,19 @@ def create( ) -> InboundEmailCreateResponse: """ Create a dedicated inbound email address for collecting CAS statements via email - forwarding. + forwarding. When an investor forwards a CAS email to this address, we verify the + sender and make the file available to you. - **How it works:** + `callback_url` is **optional**: - 1. Create an inbound email with your webhook URL - 2. Display the email address to your user (e.g., "Forward your CAS to - ie_xxx@import.casparser.in") - 3. When an investor forwards a CAS email, we verify the sender and deliver to - your webhook - - **Webhook Delivery:** - - - We POST to your `callback_url` with JSON body containing files (matching - EmailCASFile schema) - - Failed deliveries are retried automatically with exponential backoff - - **Inactivity:** - - - Inbound emails with no activity in 30 days are marked inactive - - Active inbound emails remain operational indefinitely + - **Set it** — we POST each parsed email to your webhook as it arrives. + - **Omit it** — retrieve files via `GET /v4/inbound-email/{id}/files` without + building a webhook consumer. Args: - callback_url: Webhook URL where we POST email notifications. Must be HTTPS in production (HTTP - allowed for localhost during development). - - alias: Optional custom email prefix for user-friendly addresses. - - - Must be 3-32 characters - - Alphanumeric + hyphens only - - Must start and end with letter/number - - Example: `john-portfolio@import.casparser.in` - - If omitted, generates random ID like `ie_abc123xyz@import.casparser.in` + alias: Optional custom email prefix (e.g. `john-portfolio@import.casparser.in`). 3-32 + chars, alphanumeric + hyphens, must start/end with a letter or number. If + omitted, a random ID is generated. allowed_sources: Filter emails by CAS provider. If omitted, accepts all providers. @@ -127,6 +108,10 @@ def create( - `cams` → donotreply@camsonline.com - `kfintech` → samfS@kfintech.com + callback_url: Optional webhook URL where we POST parsed emails. Must be HTTPS in production + (HTTP allowed for localhost). If omitted, retrieve files via + `GET /v4/inbound-email/{id}/files`. + metadata: Optional key-value pairs (max 10) to include in webhook payload. Useful for passing context like plan_type, campaign_id, etc. @@ -145,9 +130,9 @@ def create( "/v4/inbound-email", body=maybe_transform( { - "callback_url": callback_url, "alias": alias, "allowed_sources": allowed_sources, + "callback_url": callback_url, "metadata": metadata, "reference": reference, }, @@ -328,9 +313,9 @@ def with_streaming_response(self) -> AsyncInboundEmailResourceWithStreamingRespo async def create( self, *, - callback_url: str, alias: str | Omit = omit, allowed_sources: List[Literal["cdsl", "nsdl", "cams", "kfintech"]] | Omit = omit, + callback_url: Optional[str] | Omit = omit, metadata: Dict[str, str] | Omit = omit, reference: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -342,38 +327,19 @@ async def create( ) -> InboundEmailCreateResponse: """ Create a dedicated inbound email address for collecting CAS statements via email - forwarding. + forwarding. When an investor forwards a CAS email to this address, we verify the + sender and make the file available to you. - **How it works:** + `callback_url` is **optional**: - 1. Create an inbound email with your webhook URL - 2. Display the email address to your user (e.g., "Forward your CAS to - ie_xxx@import.casparser.in") - 3. When an investor forwards a CAS email, we verify the sender and deliver to - your webhook - - **Webhook Delivery:** - - - We POST to your `callback_url` with JSON body containing files (matching - EmailCASFile schema) - - Failed deliveries are retried automatically with exponential backoff - - **Inactivity:** - - - Inbound emails with no activity in 30 days are marked inactive - - Active inbound emails remain operational indefinitely + - **Set it** — we POST each parsed email to your webhook as it arrives. + - **Omit it** — retrieve files via `GET /v4/inbound-email/{id}/files` without + building a webhook consumer. Args: - callback_url: Webhook URL where we POST email notifications. Must be HTTPS in production (HTTP - allowed for localhost during development). - - alias: Optional custom email prefix for user-friendly addresses. - - - Must be 3-32 characters - - Alphanumeric + hyphens only - - Must start and end with letter/number - - Example: `john-portfolio@import.casparser.in` - - If omitted, generates random ID like `ie_abc123xyz@import.casparser.in` + alias: Optional custom email prefix (e.g. `john-portfolio@import.casparser.in`). 3-32 + chars, alphanumeric + hyphens, must start/end with a letter or number. If + omitted, a random ID is generated. allowed_sources: Filter emails by CAS provider. If omitted, accepts all providers. @@ -382,6 +348,10 @@ async def create( - `cams` → donotreply@camsonline.com - `kfintech` → samfS@kfintech.com + callback_url: Optional webhook URL where we POST parsed emails. Must be HTTPS in production + (HTTP allowed for localhost). If omitted, retrieve files via + `GET /v4/inbound-email/{id}/files`. + metadata: Optional key-value pairs (max 10) to include in webhook payload. Useful for passing context like plan_type, campaign_id, etc. @@ -400,9 +370,9 @@ async def create( "/v4/inbound-email", body=await async_maybe_transform( { - "callback_url": callback_url, "alias": alias, "allowed_sources": allowed_sources, + "callback_url": callback_url, "metadata": metadata, "reference": reference, }, diff --git a/src/cas_parser/types/inbound_email_create_params.py b/src/cas_parser/types/inbound_email_create_params.py index 356d7e1..62ecc96 100644 --- a/src/cas_parser/types/inbound_email_create_params.py +++ b/src/cas_parser/types/inbound_email_create_params.py @@ -2,27 +2,18 @@ from __future__ import annotations -from typing import Dict, List -from typing_extensions import Literal, Required, TypedDict +from typing import Dict, List, Optional +from typing_extensions import Literal, TypedDict __all__ = ["InboundEmailCreateParams"] class InboundEmailCreateParams(TypedDict, total=False): - callback_url: Required[str] - """ - Webhook URL where we POST email notifications. Must be HTTPS in production (HTTP - allowed for localhost during development). - """ - alias: str - """Optional custom email prefix for user-friendly addresses. + """Optional custom email prefix (e.g. `john-portfolio@import.casparser.in`). - - Must be 3-32 characters - - Alphanumeric + hyphens only - - Must start and end with letter/number - - Example: `john-portfolio@import.casparser.in` - - If omitted, generates random ID like `ie_abc123xyz@import.casparser.in` + 3-32 chars, alphanumeric + hyphens, must start/end with a letter or number. If + omitted, a random ID is generated. """ allowed_sources: List[Literal["cdsl", "nsdl", "cams", "kfintech"]] @@ -34,6 +25,13 @@ class InboundEmailCreateParams(TypedDict, total=False): - `kfintech` → samfS@kfintech.com """ + callback_url: Optional[str] + """Optional webhook URL where we POST parsed emails. + + Must be HTTPS in production (HTTP allowed for localhost). If omitted, retrieve + files via `GET /v4/inbound-email/{id}/files`. + """ + metadata: Dict[str, str] """ Optional key-value pairs (max 10) to include in webhook payload. Useful for diff --git a/src/cas_parser/types/inbound_email_create_response.py b/src/cas_parser/types/inbound_email_create_response.py index 29f89dc..77611a6 100644 --- a/src/cas_parser/types/inbound_email_create_response.py +++ b/src/cas_parser/types/inbound_email_create_response.py @@ -16,7 +16,11 @@ class InboundEmailCreateResponse(BaseModel): """Accepted CAS providers (empty = all)""" callback_url: Optional[str] = None - """Webhook URL for email notifications""" + """Webhook URL for email notifications. + + `null` means files are only retrievable via `GET /v4/inbound-email/{id}/files` + (pull delivery). + """ created_at: Optional[datetime] = None """When the mailbox was created""" diff --git a/src/cas_parser/types/inbound_email_list_response.py b/src/cas_parser/types/inbound_email_list_response.py index b1eea1b..2d700b6 100644 --- a/src/cas_parser/types/inbound_email_list_response.py +++ b/src/cas_parser/types/inbound_email_list_response.py @@ -16,7 +16,11 @@ class InboundEmail(BaseModel): """Accepted CAS providers (empty = all)""" callback_url: Optional[str] = None - """Webhook URL for email notifications""" + """Webhook URL for email notifications. + + `null` means files are only retrievable via `GET /v4/inbound-email/{id}/files` + (pull delivery). + """ created_at: Optional[datetime] = None """When the mailbox was created""" diff --git a/src/cas_parser/types/inbound_email_retrieve_response.py b/src/cas_parser/types/inbound_email_retrieve_response.py index 601fc87..cfcb7e5 100644 --- a/src/cas_parser/types/inbound_email_retrieve_response.py +++ b/src/cas_parser/types/inbound_email_retrieve_response.py @@ -16,7 +16,11 @@ class InboundEmailRetrieveResponse(BaseModel): """Accepted CAS providers (empty = all)""" callback_url: Optional[str] = None - """Webhook URL for email notifications""" + """Webhook URL for email notifications. + + `null` means files are only retrievable via `GET /v4/inbound-email/{id}/files` + (pull delivery). + """ created_at: Optional[datetime] = None """When the mailbox was created""" diff --git a/tests/api_resources/test_inbound_email.py b/tests/api_resources/test_inbound_email.py index 6970229..ca9e782 100644 --- a/tests/api_resources/test_inbound_email.py +++ b/tests/api_resources/test_inbound_email.py @@ -25,18 +25,16 @@ class TestInboundEmail: @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize def test_method_create(self, client: CasParser) -> None: - inbound_email = client.inbound_email.create( - callback_url="https://api.yourapp.com/webhooks/cas-email", - ) + inbound_email = client.inbound_email.create() assert_matches_type(InboundEmailCreateResponse, inbound_email, path=["response"]) @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize def test_method_create_with_all_params(self, client: CasParser) -> None: inbound_email = client.inbound_email.create( - callback_url="https://api.yourapp.com/webhooks/cas-email", alias="john-portfolio", allowed_sources=["cdsl", "nsdl"], + callback_url="https://api.yourapp.com/webhooks/cas-email", metadata={ "plan": "premium", "source": "onboarding", @@ -48,9 +46,7 @@ def test_method_create_with_all_params(self, client: CasParser) -> None: @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize def test_raw_response_create(self, client: CasParser) -> None: - response = client.inbound_email.with_raw_response.create( - callback_url="https://api.yourapp.com/webhooks/cas-email", - ) + response = client.inbound_email.with_raw_response.create() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -60,9 +56,7 @@ def test_raw_response_create(self, client: CasParser) -> None: @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize def test_streaming_response_create(self, client: CasParser) -> None: - with client.inbound_email.with_streaming_response.create( - callback_url="https://api.yourapp.com/webhooks/cas-email", - ) as response: + with client.inbound_email.with_streaming_response.create() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -202,18 +196,16 @@ class TestAsyncInboundEmail: @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize async def test_method_create(self, async_client: AsyncCasParser) -> None: - inbound_email = await async_client.inbound_email.create( - callback_url="https://api.yourapp.com/webhooks/cas-email", - ) + inbound_email = await async_client.inbound_email.create() assert_matches_type(InboundEmailCreateResponse, inbound_email, path=["response"]) @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize async def test_method_create_with_all_params(self, async_client: AsyncCasParser) -> None: inbound_email = await async_client.inbound_email.create( - callback_url="https://api.yourapp.com/webhooks/cas-email", alias="john-portfolio", allowed_sources=["cdsl", "nsdl"], + callback_url="https://api.yourapp.com/webhooks/cas-email", metadata={ "plan": "premium", "source": "onboarding", @@ -225,9 +217,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncCasParser) @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize async def test_raw_response_create(self, async_client: AsyncCasParser) -> None: - response = await async_client.inbound_email.with_raw_response.create( - callback_url="https://api.yourapp.com/webhooks/cas-email", - ) + response = await async_client.inbound_email.with_raw_response.create() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -237,9 +227,7 @@ async def test_raw_response_create(self, async_client: AsyncCasParser) -> None: @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize async def test_streaming_response_create(self, async_client: AsyncCasParser) -> None: - async with async_client.inbound_email.with_streaming_response.create( - callback_url="https://api.yourapp.com/webhooks/cas-email", - ) as response: + async with async_client.inbound_email.with_streaming_response.create() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" From 78f7f97e1f17e44bc4443c43e92baf1888b2808a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:25:18 +0000 Subject: [PATCH 6/7] feat(api): api update --- .stats.yml | 4 ++-- tests/api_resources/test_inbound_email.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.stats.yml b/.stats.yml index 9c6cf93..e26d438 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 21 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-78ef474b9e171a3eaa430a9dacdc2fa5c7f7d5f89147cb20573a355d3dbb9f0e.yml -openapi_spec_hash: 11b6e43ef4ed724f9804c9d790a4faee +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-e5c0c65637cdf3a6c4360b8193973b73a3d35ad1056ef607c3319ef03e591a55.yml +openapi_spec_hash: 7515d1e5fe3130b9f5411f7aacbc8a64 config_hash: 5509bb7a961ae2e79114b24c381606d4 diff --git a/tests/api_resources/test_inbound_email.py b/tests/api_resources/test_inbound_email.py index ca9e782..9b120e5 100644 --- a/tests/api_resources/test_inbound_email.py +++ b/tests/api_resources/test_inbound_email.py @@ -69,7 +69,7 @@ def test_streaming_response_create(self, client: CasParser) -> None: @parametrize def test_method_retrieve(self, client: CasParser) -> None: inbound_email = client.inbound_email.retrieve( - "ie_a1b2c3d4e5f6", + "inbound_email_id", ) assert_matches_type(InboundEmailRetrieveResponse, inbound_email, path=["response"]) @@ -77,7 +77,7 @@ def test_method_retrieve(self, client: CasParser) -> None: @parametrize def test_raw_response_retrieve(self, client: CasParser) -> None: response = client.inbound_email.with_raw_response.retrieve( - "ie_a1b2c3d4e5f6", + "inbound_email_id", ) assert response.is_closed is True @@ -89,7 +89,7 @@ def test_raw_response_retrieve(self, client: CasParser) -> None: @parametrize def test_streaming_response_retrieve(self, client: CasParser) -> None: with client.inbound_email.with_streaming_response.retrieve( - "ie_a1b2c3d4e5f6", + "inbound_email_id", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -240,7 +240,7 @@ async def test_streaming_response_create(self, async_client: AsyncCasParser) -> @parametrize async def test_method_retrieve(self, async_client: AsyncCasParser) -> None: inbound_email = await async_client.inbound_email.retrieve( - "ie_a1b2c3d4e5f6", + "inbound_email_id", ) assert_matches_type(InboundEmailRetrieveResponse, inbound_email, path=["response"]) @@ -248,7 +248,7 @@ async def test_method_retrieve(self, async_client: AsyncCasParser) -> None: @parametrize async def test_raw_response_retrieve(self, async_client: AsyncCasParser) -> None: response = await async_client.inbound_email.with_raw_response.retrieve( - "ie_a1b2c3d4e5f6", + "inbound_email_id", ) assert response.is_closed is True @@ -260,7 +260,7 @@ async def test_raw_response_retrieve(self, async_client: AsyncCasParser) -> None @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncCasParser) -> None: async with async_client.inbound_email.with_streaming_response.retrieve( - "ie_a1b2c3d4e5f6", + "inbound_email_id", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" From 4da987b0ae6b5b2ae6ff66cfe35056655f99a059 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:25:41 +0000 Subject: [PATCH 7/7] release: 1.8.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 20 ++++++++++++++++++++ pyproject.toml | 2 +- src/cas_parser/_version.py | 2 +- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index cce9d1c..c523ce1 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.7.0" + ".": "1.8.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index af49a3a..3ea04bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 1.8.0 (2026-04-19) + +Full Changelog: [v1.7.0...v1.8.0](https://github.com/CASParser/cas-parser-python/compare/v1.7.0...v1.8.0) + +### Features + +* **api:** api update ([78f7f97](https://github.com/CASParser/cas-parser-python/commit/78f7f97e1f17e44bc4443c43e92baf1888b2808a)) +* **api:** api update ([2e57117](https://github.com/CASParser/cas-parser-python/commit/2e571178acf86c879c727a43cff97ab4e05c1e2d)) + + +### Bug Fixes + +* **client:** preserve hardcoded query params when merging with user params ([3e5eea1](https://github.com/CASParser/cas-parser-python/commit/3e5eea1e92928bd98243022c607f6f15afa24d52)) +* ensure file data are only sent as 1 parameter ([4985a34](https://github.com/CASParser/cas-parser-python/commit/4985a349eee6f59007dfdf770c26deba75acc7dd)) + + +### Performance Improvements + +* **client:** optimize file structure copying in multipart requests ([1c854af](https://github.com/CASParser/cas-parser-python/commit/1c854af1baa2f1e702881692b65e9a85efffedd5)) + ## 1.7.0 (2026-03-27) Full Changelog: [v1.6.3...v1.7.0](https://github.com/CASParser/cas-parser-python/compare/v1.6.3...v1.7.0) diff --git a/pyproject.toml b/pyproject.toml index 724085d..99d4afd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cas-parser-python" -version = "1.7.0" +version = "1.8.0" description = "The official Python library for the cas-parser API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/cas_parser/_version.py b/src/cas_parser/_version.py index e314464..e5ca681 100644 --- a/src/cas_parser/_version.py +++ b/src/cas_parser/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "cas_parser" -__version__ = "1.7.0" # x-release-please-version +__version__ = "1.8.0" # x-release-please-version