From 1c3104b27350f4c906973bb56f89d5a16f55d35e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 17 Sep 2025 02:25:50 +0000 Subject: [PATCH 01/15] chore(internal): update pydantic dependency --- requirements-dev.lock | 7 +++++-- requirements.lock | 7 +++++-- src/cas_parser/_models.py | 14 ++++++++++---- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/requirements-dev.lock b/requirements-dev.lock index d000467..cd93fa9 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -88,9 +88,9 @@ pluggy==1.5.0 propcache==0.3.1 # via aiohttp # via yarl -pydantic==2.10.3 +pydantic==2.11.9 # via cas-parser-python -pydantic-core==2.27.1 +pydantic-core==2.33.2 # via pydantic pygments==2.18.0 # via rich @@ -126,6 +126,9 @@ typing-extensions==4.12.2 # via pydantic # via pydantic-core # via pyright + # via typing-inspection +typing-inspection==0.4.1 + # via pydantic virtualenv==20.24.5 # via nox yarl==1.20.0 diff --git a/requirements.lock b/requirements.lock index 46d36df..c02016b 100644 --- a/requirements.lock +++ b/requirements.lock @@ -55,9 +55,9 @@ multidict==6.4.4 propcache==0.3.1 # via aiohttp # via yarl -pydantic==2.10.3 +pydantic==2.11.9 # via cas-parser-python -pydantic-core==2.27.1 +pydantic-core==2.33.2 # via pydantic sniffio==1.3.0 # via anyio @@ -68,5 +68,8 @@ typing-extensions==4.12.2 # via multidict # via pydantic # via pydantic-core + # via typing-inspection +typing-inspection==0.4.1 + # via pydantic yarl==1.20.0 # via aiohttp diff --git a/src/cas_parser/_models.py b/src/cas_parser/_models.py index 3a6017e..6a3cd1d 100644 --- a/src/cas_parser/_models.py +++ b/src/cas_parser/_models.py @@ -256,7 +256,7 @@ def model_dump( mode: Literal["json", "python"] | str = "python", include: IncEx | None = None, exclude: IncEx | None = None, - by_alias: bool = False, + by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, @@ -264,6 +264,7 @@ def model_dump( warnings: bool | Literal["none", "warn", "error"] = True, context: dict[str, Any] | None = None, serialize_as_any: bool = False, + fallback: Callable[[Any], Any] | None = None, ) -> dict[str, Any]: """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump @@ -295,10 +296,12 @@ def model_dump( raise ValueError("context is only supported in Pydantic v2") if serialize_as_any != False: raise ValueError("serialize_as_any is only supported in Pydantic v2") + if fallback is not None: + raise ValueError("fallback is only supported in Pydantic v2") dumped = super().dict( # pyright: ignore[reportDeprecated] include=include, exclude=exclude, - by_alias=by_alias, + by_alias=by_alias if by_alias is not None else False, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, @@ -313,13 +316,14 @@ def model_dump_json( indent: int | None = None, include: IncEx | None = None, exclude: IncEx | None = None, - by_alias: bool = False, + by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, context: dict[str, Any] | None = None, + fallback: Callable[[Any], Any] | None = None, serialize_as_any: bool = False, ) -> str: """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump_json @@ -348,11 +352,13 @@ def model_dump_json( raise ValueError("context is only supported in Pydantic v2") if serialize_as_any != False: raise ValueError("serialize_as_any is only supported in Pydantic v2") + if fallback is not None: + raise ValueError("fallback is only supported in Pydantic v2") return super().json( # type: ignore[reportDeprecated] indent=indent, include=include, exclude=exclude, - by_alias=by_alias, + by_alias=by_alias if by_alias is not None else False, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, From e739e12ade4f91e52f0285c866354e970195aacf Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 02:29:50 +0000 Subject: [PATCH 02/15] chore(types): change optional parameter type from NotGiven to Omit --- src/cas_parser/__init__.py | 4 +- src/cas_parser/_base_client.py | 18 +++---- src/cas_parser/_client.py | 16 +++--- src/cas_parser/_qs.py | 14 ++--- src/cas_parser/_types.py | 29 ++++++---- src/cas_parser/_utils/_transform.py | 4 +- src/cas_parser/_utils/_utils.py | 8 +-- src/cas_parser/resources/cas_generator.py | 14 ++--- src/cas_parser/resources/cas_parser.py | 66 +++++++++++------------ tests/test_transform.py | 11 +++- 10 files changed, 100 insertions(+), 84 deletions(-) diff --git a/src/cas_parser/__init__.py b/src/cas_parser/__init__.py index a6c342f..1e1d246 100644 --- a/src/cas_parser/__init__.py +++ b/src/cas_parser/__init__.py @@ -3,7 +3,7 @@ import typing as _t from . import types -from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes +from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes, omit, not_given from ._utils import file_from_path from ._client import ( Client, @@ -48,7 +48,9 @@ "ProxiesTypes", "NotGiven", "NOT_GIVEN", + "not_given", "Omit", + "omit", "CasParserError", "APIError", "APIStatusError", diff --git a/src/cas_parser/_base_client.py b/src/cas_parser/_base_client.py index 8a47ab7..fc89c5a 100644 --- a/src/cas_parser/_base_client.py +++ b/src/cas_parser/_base_client.py @@ -42,7 +42,6 @@ from ._qs import Querystring from ._files import to_httpx_files, async_to_httpx_files from ._types import ( - NOT_GIVEN, Body, Omit, Query, @@ -57,6 +56,7 @@ RequestOptions, HttpxRequestFiles, ModelBuilderProtocol, + not_given, ) from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping from ._compat import PYDANTIC_V1, model_copy, model_dump @@ -145,9 +145,9 @@ def __init__( def __init__( self, *, - url: URL | NotGiven = NOT_GIVEN, - json: Body | NotGiven = NOT_GIVEN, - params: Query | NotGiven = NOT_GIVEN, + url: URL | NotGiven = not_given, + json: Body | NotGiven = not_given, + params: Query | NotGiven = not_given, ) -> None: self.url = url self.json = json @@ -595,7 +595,7 @@ def _maybe_override_cast_to(self, cast_to: type[ResponseT], options: FinalReques # we internally support defining a temporary header to override the # default `cast_to` type for use with `.with_raw_response` and `.with_streaming_response` # see _response.py for implementation details - override_cast_to = headers.pop(OVERRIDE_CAST_TO_HEADER, NOT_GIVEN) + override_cast_to = headers.pop(OVERRIDE_CAST_TO_HEADER, not_given) if is_given(override_cast_to): options.headers = headers return cast(Type[ResponseT], override_cast_to) @@ -825,7 +825,7 @@ def __init__( version: str, base_url: str | URL, max_retries: int = DEFAULT_MAX_RETRIES, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.Client | None = None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, @@ -1356,7 +1356,7 @@ def __init__( base_url: str | URL, _strict_response_validation: bool, max_retries: int = DEFAULT_MAX_RETRIES, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.AsyncClient | None = None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, @@ -1818,8 +1818,8 @@ def make_request_options( extra_query: Query | None = None, extra_body: Body | None = None, idempotency_key: str | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - post_parser: PostParser | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + post_parser: PostParser | NotGiven = not_given, ) -> RequestOptions: """Create a dict of type RequestOptions without keys of NotGiven values.""" options: RequestOptions = {} diff --git a/src/cas_parser/_client.py b/src/cas_parser/_client.py index 27572c6..19a598a 100644 --- a/src/cas_parser/_client.py +++ b/src/cas_parser/_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import Any, Union, Mapping +from typing import Any, Mapping from typing_extensions import Self, override import httpx @@ -11,13 +11,13 @@ from . import _exceptions from ._qs import Querystring from ._types import ( - NOT_GIVEN, Omit, Timeout, NotGiven, Transport, ProxiesTypes, RequestOptions, + not_given, ) from ._utils import is_given, get_async_library from ._version import __version__ @@ -56,7 +56,7 @@ def __init__( *, api_key: str | None = None, base_url: str | httpx.URL | None = None, - timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, max_retries: int = DEFAULT_MAX_RETRIES, default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, @@ -132,9 +132,9 @@ def copy( *, api_key: str | None = None, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.Client | None = None, - max_retries: int | NotGiven = NOT_GIVEN, + max_retries: int | NotGiven = not_given, default_headers: Mapping[str, str] | None = None, set_default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, @@ -226,7 +226,7 @@ def __init__( *, api_key: str | None = None, base_url: str | httpx.URL | None = None, - timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, max_retries: int = DEFAULT_MAX_RETRIES, default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, @@ -302,9 +302,9 @@ def copy( *, api_key: str | None = None, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.AsyncClient | None = None, - max_retries: int | NotGiven = NOT_GIVEN, + max_retries: int | NotGiven = not_given, default_headers: Mapping[str, str] | None = None, set_default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, diff --git a/src/cas_parser/_qs.py b/src/cas_parser/_qs.py index 274320c..ada6fd3 100644 --- a/src/cas_parser/_qs.py +++ b/src/cas_parser/_qs.py @@ -4,7 +4,7 @@ from urllib.parse import parse_qs, urlencode from typing_extensions import Literal, get_args -from ._types import NOT_GIVEN, NotGiven, NotGivenOr +from ._types import NotGiven, not_given from ._utils import flatten _T = TypeVar("_T") @@ -41,8 +41,8 @@ def stringify( self, params: Params, *, - array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, - nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, ) -> str: return urlencode( self.stringify_items( @@ -56,8 +56,8 @@ def stringify_items( self, params: Params, *, - array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, - nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, ) -> list[tuple[str, str]]: opts = Options( qs=self, @@ -143,8 +143,8 @@ def __init__( self, qs: Querystring = _qs, *, - array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, - nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, ) -> None: self.array_format = qs.array_format if isinstance(array_format, NotGiven) else array_format self.nested_format = qs.nested_format if isinstance(nested_format, NotGiven) else nested_format diff --git a/src/cas_parser/_types.py b/src/cas_parser/_types.py index 920e967..b45034b 100644 --- a/src/cas_parser/_types.py +++ b/src/cas_parser/_types.py @@ -117,18 +117,21 @@ class RequestOptions(TypedDict, total=False): # Sentinel class used until PEP 0661 is accepted class NotGiven: """ - A sentinel singleton class used to distinguish omitted keyword arguments - from those passed in with the value None (which may have different behavior). + For parameters with a meaningful None value, we need to distinguish between + the user explicitly passing None, and the user not passing the parameter at + all. + + User code shouldn't need to use not_given directly. For example: ```py - def get(timeout: Union[int, NotGiven, None] = NotGiven()) -> Response: ... + def create(timeout: Timeout | None | NotGiven = not_given): ... - get(timeout=1) # 1s timeout - get(timeout=None) # No timeout - get() # Default timeout behavior, which may not be statically known at the method definition. + create(timeout=1) # 1s timeout + create(timeout=None) # No timeout + create() # Default timeout behavior ``` """ @@ -140,13 +143,14 @@ def __repr__(self) -> str: return "NOT_GIVEN" -NotGivenOr = Union[_T, NotGiven] +not_given = NotGiven() +# for backwards compatibility: NOT_GIVEN = NotGiven() class Omit: - """In certain situations you need to be able to represent a case where a default value has - to be explicitly removed and `None` is not an appropriate substitute, for example: + """ + To explicitly omit something from being sent in a request, use `omit`. ```py # as the default `Content-Type` header is `application/json` that will be sent @@ -156,8 +160,8 @@ class Omit: # to look something like: 'multipart/form-data; boundary=0d8382fcf5f8c3be01ca2e11002d2983' client.post(..., headers={"Content-Type": "multipart/form-data"}) - # instead you can remove the default `application/json` header by passing Omit - client.post(..., headers={"Content-Type": Omit()}) + # instead you can remove the default `application/json` header by passing omit + client.post(..., headers={"Content-Type": omit}) ``` """ @@ -165,6 +169,9 @@ def __bool__(self) -> Literal[False]: return False +omit = Omit() + + @runtime_checkable class ModelBuilderProtocol(Protocol): @classmethod diff --git a/src/cas_parser/_utils/_transform.py b/src/cas_parser/_utils/_transform.py index c19124f..5207549 100644 --- a/src/cas_parser/_utils/_transform.py +++ b/src/cas_parser/_utils/_transform.py @@ -268,7 +268,7 @@ def _transform_typeddict( annotations = get_type_hints(expected_type, include_extras=True) for key, value in data.items(): if not is_given(value): - # we don't need to include `NotGiven` values here as they'll + # we don't need to include omitted values here as they'll # be stripped out before the request is sent anyway continue @@ -434,7 +434,7 @@ async def _async_transform_typeddict( annotations = get_type_hints(expected_type, include_extras=True) for key, value in data.items(): if not is_given(value): - # we don't need to include `NotGiven` values here as they'll + # we don't need to include omitted values here as they'll # be stripped out before the request is sent anyway continue diff --git a/src/cas_parser/_utils/_utils.py b/src/cas_parser/_utils/_utils.py index f081859..50d5926 100644 --- a/src/cas_parser/_utils/_utils.py +++ b/src/cas_parser/_utils/_utils.py @@ -21,7 +21,7 @@ import sniffio -from .._types import NotGiven, FileTypes, NotGivenOr, HeadersLike +from .._types import Omit, NotGiven, FileTypes, HeadersLike _T = TypeVar("_T") _TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) @@ -63,7 +63,7 @@ def _extract_items( try: key = path[index] except IndexError: - if isinstance(obj, NotGiven): + if not is_given(obj): # no value was provided - we can safely ignore return [] @@ -126,8 +126,8 @@ def _extract_items( return [] -def is_given(obj: NotGivenOr[_T]) -> TypeGuard[_T]: - return not isinstance(obj, NotGiven) +def is_given(obj: _T | NotGiven | Omit) -> TypeGuard[_T]: + return not isinstance(obj, NotGiven) and not isinstance(obj, Omit) # Type safe methods for narrowing types with TypeVars. diff --git a/src/cas_parser/resources/cas_generator.py b/src/cas_parser/resources/cas_generator.py index 511b893..26ff32a 100644 --- a/src/cas_parser/resources/cas_generator.py +++ b/src/cas_parser/resources/cas_generator.py @@ -7,7 +7,7 @@ import httpx from ..types import cas_generator_generate_cas_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -50,14 +50,14 @@ def generate_cas( from_date: str, password: str, to_date: str, - cas_authority: Literal["kfintech", "cams", "cdsl", "nsdl"] | NotGiven = NOT_GIVEN, - pan_no: str | NotGiven = NOT_GIVEN, + cas_authority: Literal["kfintech", "cams", "cdsl", "nsdl"] | Omit = omit, + pan_no: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> CasGeneratorGenerateCasResponse: """ This endpoint generates CAS (Consolidated Account Statement) documents by @@ -133,14 +133,14 @@ async def generate_cas( from_date: str, password: str, to_date: str, - cas_authority: Literal["kfintech", "cams", "cdsl", "nsdl"] | NotGiven = NOT_GIVEN, - pan_no: str | NotGiven = NOT_GIVEN, + cas_authority: Literal["kfintech", "cams", "cdsl", "nsdl"] | Omit = omit, + pan_no: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> CasGeneratorGenerateCasResponse: """ This endpoint generates CAS (Consolidated Account Statement) documents by diff --git a/src/cas_parser/resources/cas_parser.py b/src/cas_parser/resources/cas_parser.py index a64b7dd..e82b0e9 100644 --- a/src/cas_parser/resources/cas_parser.py +++ b/src/cas_parser/resources/cas_parser.py @@ -12,7 +12,7 @@ cas_parser_smart_parse_params, cas_parser_cams_kfintech_params, ) -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -51,15 +51,15 @@ def with_streaming_response(self) -> CasParserResourceWithStreamingResponse: def cams_kfintech( self, *, - password: str | NotGiven = NOT_GIVEN, - pdf_file: str | NotGiven = NOT_GIVEN, - pdf_url: str | NotGiven = NOT_GIVEN, + password: str | Omit = omit, + pdf_file: str | Omit = omit, + pdf_url: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> UnifiedResponse: """ This endpoint specifically parses CAMS/KFintech CAS (Consolidated Account @@ -107,15 +107,15 @@ def cams_kfintech( def cdsl( self, *, - password: str | NotGiven = NOT_GIVEN, - pdf_file: str | NotGiven = NOT_GIVEN, - pdf_url: str | NotGiven = NOT_GIVEN, + password: str | Omit = omit, + pdf_file: str | Omit = omit, + pdf_url: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> UnifiedResponse: """ This endpoint specifically parses CDSL CAS (Consolidated Account Statement) PDF @@ -163,15 +163,15 @@ def cdsl( def nsdl( self, *, - password: str | NotGiven = NOT_GIVEN, - pdf_file: str | NotGiven = NOT_GIVEN, - pdf_url: str | NotGiven = NOT_GIVEN, + password: str | Omit = omit, + pdf_file: str | Omit = omit, + pdf_url: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> UnifiedResponse: """ This endpoint specifically parses NSDL CAS (Consolidated Account Statement) PDF @@ -219,15 +219,15 @@ def nsdl( def smart_parse( self, *, - password: str | NotGiven = NOT_GIVEN, - pdf_file: str | NotGiven = NOT_GIVEN, - pdf_url: str | NotGiven = NOT_GIVEN, + password: str | Omit = omit, + pdf_file: str | Omit = omit, + pdf_url: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> UnifiedResponse: """ This endpoint parses CAS (Consolidated Account Statement) PDF files from NSDL, @@ -297,15 +297,15 @@ def with_streaming_response(self) -> AsyncCasParserResourceWithStreamingResponse async def cams_kfintech( self, *, - password: str | NotGiven = NOT_GIVEN, - pdf_file: str | NotGiven = NOT_GIVEN, - pdf_url: str | NotGiven = NOT_GIVEN, + password: str | Omit = omit, + pdf_file: str | Omit = omit, + pdf_url: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> UnifiedResponse: """ This endpoint specifically parses CAMS/KFintech CAS (Consolidated Account @@ -353,15 +353,15 @@ async def cams_kfintech( async def cdsl( self, *, - password: str | NotGiven = NOT_GIVEN, - pdf_file: str | NotGiven = NOT_GIVEN, - pdf_url: str | NotGiven = NOT_GIVEN, + password: str | Omit = omit, + pdf_file: str | Omit = omit, + pdf_url: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> UnifiedResponse: """ This endpoint specifically parses CDSL CAS (Consolidated Account Statement) PDF @@ -409,15 +409,15 @@ async def cdsl( async def nsdl( self, *, - password: str | NotGiven = NOT_GIVEN, - pdf_file: str | NotGiven = NOT_GIVEN, - pdf_url: str | NotGiven = NOT_GIVEN, + password: str | Omit = omit, + pdf_file: str | Omit = omit, + pdf_url: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> UnifiedResponse: """ This endpoint specifically parses NSDL CAS (Consolidated Account Statement) PDF @@ -465,15 +465,15 @@ async def nsdl( async def smart_parse( self, *, - password: str | NotGiven = NOT_GIVEN, - pdf_file: str | NotGiven = NOT_GIVEN, - pdf_url: str | NotGiven = NOT_GIVEN, + password: str | Omit = omit, + pdf_file: str | Omit = omit, + pdf_url: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> UnifiedResponse: """ This endpoint parses CAS (Consolidated Account Statement) PDF files from NSDL, diff --git a/tests/test_transform.py b/tests/test_transform.py index ce97c84..451ddf6 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -8,7 +8,7 @@ import pytest -from cas_parser._types import NOT_GIVEN, Base64FileInput +from cas_parser._types import Base64FileInput, omit, not_given from cas_parser._utils import ( PropertyInfo, transform as _transform, @@ -450,4 +450,11 @@ async def test_transform_skipping(use_async: bool) -> None: @pytest.mark.asyncio async def test_strips_notgiven(use_async: bool) -> None: assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} - assert await transform({"foo_bar": NOT_GIVEN}, Foo1, use_async) == {} + assert await transform({"foo_bar": not_given}, Foo1, use_async) == {} + + +@parametrize +@pytest.mark.asyncio +async def test_strips_omit(use_async: bool) -> None: + assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} + assert await transform({"foo_bar": omit}, Foo1, use_async) == {} From 35b17eb26264ab66e24b074bcb1790f6c33b7b9c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 20 Sep 2025 02:34:58 +0000 Subject: [PATCH 03/15] chore: do not install brew dependencies in ./scripts/bootstrap by default --- scripts/bootstrap | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/bootstrap b/scripts/bootstrap index e84fe62..b430fee 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,10 +4,18 @@ set -e cd "$(dirname "$0")/.." -if ! command -v rye >/dev/null 2>&1 && [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ]; then +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then brew bundle check >/dev/null 2>&1 || { - echo "==> Installing Homebrew dependencies…" - brew bundle + echo -n "==> Install Homebrew dependencies? (y/N): " + read -r response + case "$response" in + [yY][eE][sS]|[yY]) + brew bundle + ;; + *) + ;; + esac + echo } fi From f1838dcb901635626cc87cb55dfaa4ef33ba5092 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 00:18:21 +0000 Subject: [PATCH 04/15] feat(api): api update --- .stats.yml | 4 +- src/cas_parser/types/unified_response.py | 97 ++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 92721c7..06e7614 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-b7fdba3d3f97c7debc22c7ca30b828bce81bcd64648df8c94029b27a3321ebb9.yml -openapi_spec_hash: 03f1315f1d32ada42445ca920f047dff +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-9eaed98ce5934f11e901cef376a28257d2c196bd3dba7c690babc6741a730ded.yml +openapi_spec_hash: b76e4e830c4d03ba4cf9429bb9fb9c8a config_hash: cb5d75abef6264b5d86448caf7295afa diff --git a/src/cas_parser/types/unified_response.py b/src/cas_parser/types/unified_response.py index 7dc5439..4c17c58 100644 --- a/src/cas_parser/types/unified_response.py +++ b/src/cas_parser/types/unified_response.py @@ -18,6 +18,7 @@ "DematAccountHoldingsDematMutualFund", "DematAccountHoldingsEquity", "DematAccountHoldingsGovernmentSecurity", + "DematAccountLinkedHolder", "Insurance", "InsuranceLifeInsurancePolicy", "Investor", @@ -25,15 +26,21 @@ "MetaStatementPeriod", "MutualFund", "MutualFundAdditionalInfo", + "MutualFundLinkedHolder", "MutualFundScheme", "MutualFundSchemeAdditionalInfo", "MutualFundSchemeGain", "MutualFundSchemeTransaction", + "Np", + "NpFund", + "NpFundAdditionalInfo", + "NpLinkedHolder", "Summary", "SummaryAccounts", "SummaryAccountsDemat", "SummaryAccountsInsurance", "SummaryAccountsMutualFunds", + "SummaryAccountsNps", ] @@ -160,6 +167,14 @@ class DematAccountHoldings(BaseModel): government_securities: Optional[List[DematAccountHoldingsGovernmentSecurity]] = None +class DematAccountLinkedHolder(BaseModel): + name: Optional[str] = None + """Name of the account holder""" + + pan: Optional[str] = None + """PAN of the account holder""" + + class DematAccount(BaseModel): additional_info: Optional[DematAccountAdditionalInfo] = None """Additional information specific to the demat account type""" @@ -181,6 +196,9 @@ class DematAccount(BaseModel): holdings: Optional[DematAccountHoldings] = None + linked_holders: Optional[List[DematAccountLinkedHolder]] = None + """List of account holders linked to this demat account""" + value: Optional[float] = None """Total value of the demat account""" @@ -270,6 +288,14 @@ class MutualFundAdditionalInfo(BaseModel): """PAN KYC status""" +class MutualFundLinkedHolder(BaseModel): + name: Optional[str] = None + """Name of the account holder""" + + pan: Optional[str] = None + """PAN of the account holder""" + + class MutualFundSchemeAdditionalInfo(BaseModel): advisor: Optional[str] = None """Financial advisor name (CAMS/KFintech)""" @@ -370,6 +396,9 @@ class MutualFund(BaseModel): folio_number: Optional[str] = None """Folio number""" + linked_holders: Optional[List[MutualFundLinkedHolder]] = None + """List of account holders linked to this mutual fund folio""" + registrar: Optional[str] = None """Registrar and Transfer Agent name""" @@ -379,6 +408,61 @@ class MutualFund(BaseModel): """Total value of the folio""" +class NpFundAdditionalInfo(BaseModel): + manager: Optional[str] = None + """Fund manager name""" + + tier: Optional[Literal[1, 2]] = None + """NPS tier (Tier I or Tier II)""" + + +class NpFund(BaseModel): + additional_info: Optional[NpFundAdditionalInfo] = None + """Additional information specific to the NPS fund""" + + cost: Optional[float] = None + """Cost of investment""" + + name: Optional[str] = None + """Name of the NPS fund""" + + nav: Optional[float] = None + """Net Asset Value per unit""" + + units: Optional[float] = None + """Number of units held""" + + value: Optional[float] = None + """Current market value of the holding""" + + +class NpLinkedHolder(BaseModel): + name: Optional[str] = None + """Name of the account holder""" + + pan: Optional[str] = None + """PAN of the account holder""" + + +class Np(BaseModel): + additional_info: Optional[object] = None + """Additional information specific to the NPS account""" + + cra: Optional[str] = None + """Central Record Keeping Agency name""" + + funds: Optional[List[NpFund]] = None + + linked_holders: Optional[List[NpLinkedHolder]] = None + """List of account holders linked to this NPS account""" + + pran: Optional[str] = None + """Permanent Retirement Account Number (PRAN)""" + + value: Optional[float] = None + """Total value of the NPS account""" + + class SummaryAccountsDemat(BaseModel): count: Optional[int] = None """Number of demat accounts""" @@ -403,6 +487,14 @@ class SummaryAccountsMutualFunds(BaseModel): """Total value of mutual funds""" +class SummaryAccountsNps(BaseModel): + count: Optional[int] = None + """Number of NPS accounts""" + + total_value: Optional[float] = None + """Total value of NPS accounts""" + + class SummaryAccounts(BaseModel): demat: Optional[SummaryAccountsDemat] = None @@ -410,6 +502,8 @@ class SummaryAccounts(BaseModel): mutual_funds: Optional[SummaryAccountsMutualFunds] = None + nps: Optional[SummaryAccountsNps] = None + class Summary(BaseModel): accounts: Optional[SummaryAccounts] = None @@ -429,4 +523,7 @@ class UnifiedResponse(BaseModel): mutual_funds: Optional[List[MutualFund]] = None + nps: Optional[List[Np]] = None + """List of NPS accounts""" + summary: Optional[Summary] = None From 8c354893c00887af1da9c197dc21dd4d6f0033af Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 11 Oct 2025 02:11:43 +0000 Subject: [PATCH 05/15] chore(internal): detect missing future annotations with ruff --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 33ccf0d..dea9b6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -224,6 +224,8 @@ select = [ "B", # remove unused imports "F401", + # check for missing future annotations + "FA102", # bare except statements "E722", # unused arguments @@ -246,6 +248,8 @@ unfixable = [ "T203", ] +extend-safe-fixes = ["FA102"] + [tool.ruff.lint.flake8-tidy-imports.banned-api] "functools.lru_cache".msg = "This function does not retain type information for the wrapped function's arguments; The `lru_cache` function from `_utils` should be used instead" From e1b65fb2bd146a68ef50438899406ae2fb6178c3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Oct 2025 02:06:50 +0000 Subject: [PATCH 06/15] chore: bump `httpx-aiohttp` version to 0.1.9 --- pyproject.toml | 2 +- requirements-dev.lock | 2 +- requirements.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dea9b6b..39ff931 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Homepage = "https://github.com/CASParser/cas-parser-python" Repository = "https://github.com/CASParser/cas-parser-python" [project.optional-dependencies] -aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"] [tool.rye] managed = true diff --git a/requirements-dev.lock b/requirements-dev.lock index cd93fa9..e3df62e 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -56,7 +56,7 @@ httpx==0.28.1 # via cas-parser-python # via httpx-aiohttp # via respx -httpx-aiohttp==0.1.8 +httpx-aiohttp==0.1.9 # via cas-parser-python idna==3.4 # via anyio diff --git a/requirements.lock b/requirements.lock index c02016b..dde95d9 100644 --- a/requirements.lock +++ b/requirements.lock @@ -43,7 +43,7 @@ httpcore==1.0.9 httpx==0.28.1 # via cas-parser-python # via httpx-aiohttp -httpx-aiohttp==0.1.8 +httpx-aiohttp==0.1.9 # via cas-parser-python idna==3.4 # via anyio From 7090ef51af296fa6d6be8af8137543ef2023cbd7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 02:11:49 +0000 Subject: [PATCH 07/15] fix(client): close streams without requiring full consumption --- src/cas_parser/_streaming.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/cas_parser/_streaming.py b/src/cas_parser/_streaming.py index 9c9eb3e..48dca86 100644 --- a/src/cas_parser/_streaming.py +++ b/src/cas_parser/_streaming.py @@ -57,9 +57,8 @@ def __stream__(self) -> Iterator[_T]: for sse in iterator: yield process_data(data=sse.json(), cast_to=cast_to, response=response) - # Ensure the entire stream is consumed - for _sse in iterator: - ... + # As we might not fully consume the response stream, we need to close it explicitly + response.close() def __enter__(self) -> Self: return self @@ -121,9 +120,8 @@ async def __stream__(self) -> AsyncIterator[_T]: async for sse in iterator: yield process_data(data=sse.json(), cast_to=cast_to, response=response) - # Ensure the entire stream is consumed - async for _sse in iterator: - ... + # As we might not fully consume the response stream, we need to close it explicitly + await response.aclose() async def __aenter__(self) -> Self: return self From 2a58fc0e260b52ee314ac6d14676b2140711bd0b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 02:28:04 +0000 Subject: [PATCH 08/15] chore(internal/tests): avoid race condition with implicit client cleanup --- tests/test_client.py | 362 +++++++++++++++++++++++-------------------- 1 file changed, 198 insertions(+), 164 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index e5b787e..47523fb 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -59,51 +59,49 @@ def _get_open_connections(client: CasParser | AsyncCasParser) -> int: class TestCasParser: - client = CasParser(base_url=base_url, api_key=api_key, _strict_response_validation=True) - @pytest.mark.respx(base_url=base_url) - def test_raw_response(self, respx_mock: MockRouter) -> None: + def test_raw_response(self, respx_mock: MockRouter, client: CasParser) -> None: respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.post("/foo", cast_to=httpx.Response) + response = client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) - def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + def test_raw_response_for_binary(self, respx_mock: MockRouter, client: CasParser) -> None: respx_mock.post("/foo").mock( return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') ) - response = self.client.post("/foo", cast_to=httpx.Response) + response = client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} - def test_copy(self) -> None: - copied = self.client.copy() - assert id(copied) != id(self.client) + def test_copy(self, client: CasParser) -> None: + copied = client.copy() + assert id(copied) != id(client) - copied = self.client.copy(api_key="another My API Key") + copied = client.copy(api_key="another My API Key") assert copied.api_key == "another My API Key" - assert self.client.api_key == "My API Key" + assert client.api_key == "My API Key" - def test_copy_default_options(self) -> None: + def test_copy_default_options(self, client: CasParser) -> None: # options that have a default are overridden correctly - copied = self.client.copy(max_retries=7) + copied = client.copy(max_retries=7) assert copied.max_retries == 7 - assert self.client.max_retries == 2 + assert client.max_retries == 2 copied2 = copied.copy(max_retries=6) assert copied2.max_retries == 6 assert copied.max_retries == 7 # timeout - assert isinstance(self.client.timeout, httpx.Timeout) - copied = self.client.copy(timeout=None) + assert isinstance(client.timeout, httpx.Timeout) + copied = client.copy(timeout=None) assert copied.timeout is None - assert isinstance(self.client.timeout, httpx.Timeout) + assert isinstance(client.timeout, httpx.Timeout) def test_copy_default_headers(self) -> None: client = CasParser( @@ -138,6 +136,7 @@ def test_copy_default_headers(self) -> None: match="`default_headers` and `set_default_headers` arguments are mutually exclusive", ): client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + client.close() def test_copy_default_query(self) -> None: client = CasParser( @@ -175,13 +174,15 @@ def test_copy_default_query(self) -> None: ): client.copy(set_default_query={}, default_query={"foo": "Bar"}) - def test_copy_signature(self) -> None: + client.close() + + def test_copy_signature(self, client: CasParser) -> None: # ensure the same parameters that can be passed to the client are defined in the `.copy()` method init_signature = inspect.signature( # mypy doesn't like that we access the `__init__` property. - self.client.__init__, # type: ignore[misc] + client.__init__, # type: ignore[misc] ) - copy_signature = inspect.signature(self.client.copy) + copy_signature = inspect.signature(client.copy) exclude_params = {"transport", "proxies", "_strict_response_validation"} for name in init_signature.parameters.keys(): @@ -192,12 +193,12 @@ def test_copy_signature(self) -> None: assert copy_param is not None, f"copy() signature is missing the {name} param" @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") - def test_copy_build_request(self) -> None: + def test_copy_build_request(self, client: CasParser) -> None: options = FinalRequestOptions(method="get", url="/foo") def build_request(options: FinalRequestOptions) -> None: - client = self.client.copy() - client._build_request(options) + client_copy = client.copy() + client_copy._build_request(options) # ensure that the machinery is warmed up before tracing starts. build_request(options) @@ -254,14 +255,12 @@ def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.Statistic print(frame) raise AssertionError() - def test_request_timeout(self) -> None: - request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + def test_request_timeout(self, client: CasParser) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT - request = self.client._build_request( - FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) - ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(100.0) @@ -274,6 +273,8 @@ def test_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(0) + client.close() + def test_http_client_timeout_option(self) -> None: # custom timeout given to the httpx client should be used with httpx.Client(timeout=None) as http_client: @@ -285,6 +286,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(None) + client.close() + # no timeout given to the httpx client should not use the httpx default with httpx.Client() as http_client: client = CasParser( @@ -295,6 +298,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT + client.close() + # explicitly passing the default timeout currently results in it being ignored with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: client = CasParser( @@ -305,6 +310,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT # our default + client.close() + async def test_invalid_http_client(self) -> None: with pytest.raises(TypeError, match="Invalid `http_client` arg"): async with httpx.AsyncClient() as http_client: @@ -316,14 +323,14 @@ async def test_invalid_http_client(self) -> None: ) def test_default_headers_option(self) -> None: - client = CasParser( + test_client = CasParser( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} ) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "bar" assert request.headers.get("x-stainless-lang") == "python" - client2 = CasParser( + test_client2 = CasParser( base_url=base_url, api_key=api_key, _strict_response_validation=True, @@ -332,10 +339,13 @@ def test_default_headers_option(self) -> None: "X-Stainless-Lang": "my-overriding-header", }, ) - request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "stainless" assert request.headers.get("x-stainless-lang") == "my-overriding-header" + test_client.close() + test_client2.close() + def test_validate_headers(self) -> None: client = CasParser(base_url=base_url, api_key=api_key, _strict_response_validation=True) request = client._build_request(FinalRequestOptions(method="get", url="/foo")) @@ -364,8 +374,10 @@ def test_default_query_option(self) -> None: url = httpx.URL(request.url) assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} - def test_request_extra_json(self) -> None: - request = self.client._build_request( + client.close() + + def test_request_extra_json(self, client: CasParser) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -376,7 +388,7 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": False} - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -387,7 +399,7 @@ def test_request_extra_json(self) -> None: assert data == {"baz": False} # `extra_json` takes priority over `json_data` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -398,8 +410,8 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": None} - def test_request_extra_headers(self) -> None: - request = self.client._build_request( + def test_request_extra_headers(self, client: CasParser) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -409,7 +421,7 @@ def test_request_extra_headers(self) -> None: assert request.headers.get("X-Foo") == "Foo" # `extra_headers` takes priority over `default_headers` when keys clash - request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( FinalRequestOptions( method="post", url="/foo", @@ -420,8 +432,8 @@ def test_request_extra_headers(self) -> None: ) assert request.headers.get("X-Bar") == "false" - def test_request_extra_query(self) -> None: - request = self.client._build_request( + def test_request_extra_query(self, client: CasParser) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -434,7 +446,7 @@ def test_request_extra_query(self) -> None: assert params == {"my_query_param": "Foo"} # if both `query` and `extra_query` are given, they are merged - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -448,7 +460,7 @@ def test_request_extra_query(self) -> None: assert params == {"bar": "1", "foo": "2"} # `extra_query` takes priority over `query` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -491,7 +503,7 @@ def test_multipart_repeating_array(self, client: CasParser) -> None: ] @pytest.mark.respx(base_url=base_url) - def test_basic_union_response(self, respx_mock: MockRouter) -> None: + def test_basic_union_response(self, respx_mock: MockRouter, client: CasParser) -> None: class Model1(BaseModel): name: str @@ -500,12 +512,12 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" @pytest.mark.respx(base_url=base_url) - def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + def test_union_response_different_types(self, respx_mock: MockRouter, client: CasParser) -> None: """Union of objects with the same field name using a different type""" class Model1(BaseModel): @@ -516,18 +528,18 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model1) assert response.foo == 1 @pytest.mark.respx(base_url=base_url) - def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter, client: CasParser) -> None: """ Response that sets Content-Type to something other than application/json but returns json data """ @@ -543,7 +555,7 @@ class Model(BaseModel): ) ) - response = self.client.get("/foo", cast_to=Model) + response = client.get("/foo", cast_to=Model) assert isinstance(response, Model) assert response.foo == 2 @@ -555,6 +567,8 @@ def test_base_url_setter(self) -> None: assert client.base_url == "https://example.com/from_setter/" + client.close() + def test_base_url_env(self) -> None: with update_env(CAS_PARSER_BASE_URL="http://localhost:5000/from/env"): client = CasParser(api_key=api_key, _strict_response_validation=True) @@ -582,6 +596,7 @@ def test_base_url_trailing_slash(self, client: CasParser) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + client.close() @pytest.mark.parametrize( "client", @@ -605,6 +620,7 @@ def test_base_url_no_trailing_slash(self, client: CasParser) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + client.close() @pytest.mark.parametrize( "client", @@ -628,35 +644,36 @@ def test_absolute_request_url(self, client: CasParser) -> None: ), ) assert request.url == "https://myapi.com/foo" + client.close() def test_copied_client_does_not_close_http(self) -> None: - client = CasParser(base_url=base_url, api_key=api_key, _strict_response_validation=True) - assert not client.is_closed() + test_client = CasParser(base_url=base_url, api_key=api_key, _strict_response_validation=True) + assert not test_client.is_closed() - copied = client.copy() - assert copied is not client + copied = test_client.copy() + assert copied is not test_client del copied - assert not client.is_closed() + assert not test_client.is_closed() def test_client_context_manager(self) -> None: - client = CasParser(base_url=base_url, api_key=api_key, _strict_response_validation=True) - with client as c2: - assert c2 is client + test_client = CasParser(base_url=base_url, api_key=api_key, _strict_response_validation=True) + with test_client as c2: + assert c2 is test_client assert not c2.is_closed() - assert not client.is_closed() - assert client.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() @pytest.mark.respx(base_url=base_url) - def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + def test_client_response_validation_error(self, respx_mock: MockRouter, client: CasParser) -> None: class Model(BaseModel): foo: str respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) with pytest.raises(APIResponseValidationError) as exc: - self.client.get("/foo", cast_to=Model) + client.get("/foo", cast_to=Model) assert isinstance(exc.value.__cause__, ValidationError) @@ -676,11 +693,14 @@ class Model(BaseModel): with pytest.raises(APIResponseValidationError): strict_client.get("/foo", cast_to=Model) - client = CasParser(base_url=base_url, api_key=api_key, _strict_response_validation=False) + non_strict_client = CasParser(base_url=base_url, api_key=api_key, _strict_response_validation=False) - response = client.get("/foo", cast_to=Model) + response = non_strict_client.get("/foo", cast_to=Model) assert isinstance(response, str) # type: ignore[unreachable] + strict_client.close() + non_strict_client.close() + @pytest.mark.parametrize( "remaining_retries,retry_after,timeout", [ @@ -703,9 +723,9 @@ class Model(BaseModel): ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) - def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: - client = CasParser(base_url=base_url, api_key=api_key, _strict_response_validation=True) - + def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, client: CasParser + ) -> None: headers = httpx.Headers({"retry-after": retry_after}) options = FinalRequestOptions(method="get", url="/foo", max_retries=3) calculated = client._calculate_retry_timeout(remaining_retries, options, headers) @@ -719,7 +739,7 @@ def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, clien with pytest.raises(APITimeoutError): client.cas_parser.with_streaming_response.smart_parse().__enter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(client) == 0 @mock.patch("cas_parser._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) @@ -728,7 +748,7 @@ def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client with pytest.raises(APIStatusError): client.cas_parser.with_streaming_response.smart_parse().__enter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("cas_parser._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @@ -830,83 +850,77 @@ def test_default_client_creation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - def test_follow_redirects(self, respx_mock: MockRouter) -> None: + def test_follow_redirects(self, respx_mock: MockRouter, client: CasParser) -> None: # Test that the default follow_redirects=True allows following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) - response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + response = client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) assert response.status_code == 200 assert response.json() == {"status": "ok"} @pytest.mark.respx(base_url=base_url) - def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + def test_follow_redirects_disabled(self, respx_mock: MockRouter, client: CasParser) -> None: # Test that follow_redirects=False prevents following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) with pytest.raises(APIStatusError) as exc_info: - self.client.post( - "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response - ) + client.post("/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response) assert exc_info.value.response.status_code == 302 assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" class TestAsyncCasParser: - client = AsyncCasParser(base_url=base_url, api_key=api_key, _strict_response_validation=True) - @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_raw_response(self, respx_mock: MockRouter) -> None: + async def test_raw_response(self, respx_mock: MockRouter, async_client: AsyncCasParser) -> None: respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.post("/foo", cast_to=httpx.Response) + response = await async_client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + async def test_raw_response_for_binary(self, respx_mock: MockRouter, async_client: AsyncCasParser) -> None: respx_mock.post("/foo").mock( return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') ) - response = await self.client.post("/foo", cast_to=httpx.Response) + response = await async_client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} - def test_copy(self) -> None: - copied = self.client.copy() - assert id(copied) != id(self.client) + def test_copy(self, async_client: AsyncCasParser) -> None: + copied = async_client.copy() + assert id(copied) != id(async_client) - copied = self.client.copy(api_key="another My API Key") + copied = async_client.copy(api_key="another My API Key") assert copied.api_key == "another My API Key" - assert self.client.api_key == "My API Key" + assert async_client.api_key == "My API Key" - def test_copy_default_options(self) -> None: + def test_copy_default_options(self, async_client: AsyncCasParser) -> None: # options that have a default are overridden correctly - copied = self.client.copy(max_retries=7) + copied = async_client.copy(max_retries=7) assert copied.max_retries == 7 - assert self.client.max_retries == 2 + assert async_client.max_retries == 2 copied2 = copied.copy(max_retries=6) assert copied2.max_retries == 6 assert copied.max_retries == 7 # timeout - assert isinstance(self.client.timeout, httpx.Timeout) - copied = self.client.copy(timeout=None) + assert isinstance(async_client.timeout, httpx.Timeout) + copied = async_client.copy(timeout=None) assert copied.timeout is None - assert isinstance(self.client.timeout, httpx.Timeout) + assert isinstance(async_client.timeout, httpx.Timeout) - def test_copy_default_headers(self) -> None: + async def test_copy_default_headers(self) -> None: client = AsyncCasParser( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} ) @@ -939,8 +953,9 @@ def test_copy_default_headers(self) -> None: match="`default_headers` and `set_default_headers` arguments are mutually exclusive", ): client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + await client.close() - def test_copy_default_query(self) -> None: + async def test_copy_default_query(self) -> None: client = AsyncCasParser( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"} ) @@ -976,13 +991,15 @@ def test_copy_default_query(self) -> None: ): client.copy(set_default_query={}, default_query={"foo": "Bar"}) - def test_copy_signature(self) -> None: + await client.close() + + def test_copy_signature(self, async_client: AsyncCasParser) -> None: # ensure the same parameters that can be passed to the client are defined in the `.copy()` method init_signature = inspect.signature( # mypy doesn't like that we access the `__init__` property. - self.client.__init__, # type: ignore[misc] + async_client.__init__, # type: ignore[misc] ) - copy_signature = inspect.signature(self.client.copy) + copy_signature = inspect.signature(async_client.copy) exclude_params = {"transport", "proxies", "_strict_response_validation"} for name in init_signature.parameters.keys(): @@ -993,12 +1010,12 @@ def test_copy_signature(self) -> None: assert copy_param is not None, f"copy() signature is missing the {name} param" @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") - def test_copy_build_request(self) -> None: + def test_copy_build_request(self, async_client: AsyncCasParser) -> None: options = FinalRequestOptions(method="get", url="/foo") def build_request(options: FinalRequestOptions) -> None: - client = self.client.copy() - client._build_request(options) + client_copy = async_client.copy() + client_copy._build_request(options) # ensure that the machinery is warmed up before tracing starts. build_request(options) @@ -1055,12 +1072,12 @@ def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.Statistic print(frame) raise AssertionError() - async def test_request_timeout(self) -> None: - request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + async def test_request_timeout(self, async_client: AsyncCasParser) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo")) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT - request = self.client._build_request( + request = async_client._build_request( FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) ) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore @@ -1075,6 +1092,8 @@ async def test_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(0) + await client.close() + async def test_http_client_timeout_option(self) -> None: # custom timeout given to the httpx client should be used async with httpx.AsyncClient(timeout=None) as http_client: @@ -1086,6 +1105,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(None) + await client.close() + # no timeout given to the httpx client should not use the httpx default async with httpx.AsyncClient() as http_client: client = AsyncCasParser( @@ -1096,6 +1117,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT + await client.close() + # explicitly passing the default timeout currently results in it being ignored async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: client = AsyncCasParser( @@ -1106,6 +1129,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT # our default + await client.close() + def test_invalid_http_client(self) -> None: with pytest.raises(TypeError, match="Invalid `http_client` arg"): with httpx.Client() as http_client: @@ -1116,15 +1141,15 @@ def test_invalid_http_client(self) -> None: http_client=cast(Any, http_client), ) - def test_default_headers_option(self) -> None: - client = AsyncCasParser( + async def test_default_headers_option(self) -> None: + test_client = AsyncCasParser( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} ) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "bar" assert request.headers.get("x-stainless-lang") == "python" - client2 = AsyncCasParser( + test_client2 = AsyncCasParser( base_url=base_url, api_key=api_key, _strict_response_validation=True, @@ -1133,10 +1158,13 @@ def test_default_headers_option(self) -> None: "X-Stainless-Lang": "my-overriding-header", }, ) - request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "stainless" assert request.headers.get("x-stainless-lang") == "my-overriding-header" + await test_client.close() + await test_client2.close() + def test_validate_headers(self) -> None: client = AsyncCasParser(base_url=base_url, api_key=api_key, _strict_response_validation=True) request = client._build_request(FinalRequestOptions(method="get", url="/foo")) @@ -1147,7 +1175,7 @@ def test_validate_headers(self) -> None: client2 = AsyncCasParser(base_url=base_url, api_key=None, _strict_response_validation=True) _ = client2 - def test_default_query_option(self) -> None: + async def test_default_query_option(self) -> None: client = AsyncCasParser( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"} ) @@ -1165,8 +1193,10 @@ def test_default_query_option(self) -> None: url = httpx.URL(request.url) assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} - def test_request_extra_json(self) -> None: - request = self.client._build_request( + await client.close() + + def test_request_extra_json(self, client: CasParser) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1177,7 +1207,7 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": False} - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1188,7 +1218,7 @@ def test_request_extra_json(self) -> None: assert data == {"baz": False} # `extra_json` takes priority over `json_data` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1199,8 +1229,8 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": None} - def test_request_extra_headers(self) -> None: - request = self.client._build_request( + def test_request_extra_headers(self, client: CasParser) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1210,7 +1240,7 @@ def test_request_extra_headers(self) -> None: assert request.headers.get("X-Foo") == "Foo" # `extra_headers` takes priority over `default_headers` when keys clash - request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1221,8 +1251,8 @@ def test_request_extra_headers(self) -> None: ) assert request.headers.get("X-Bar") == "false" - def test_request_extra_query(self) -> None: - request = self.client._build_request( + def test_request_extra_query(self, client: CasParser) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1235,7 +1265,7 @@ def test_request_extra_query(self) -> None: assert params == {"my_query_param": "Foo"} # if both `query` and `extra_query` are given, they are merged - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1249,7 +1279,7 @@ def test_request_extra_query(self) -> None: assert params == {"bar": "1", "foo": "2"} # `extra_query` takes priority over `query` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1292,7 +1322,7 @@ def test_multipart_repeating_array(self, async_client: AsyncCasParser) -> None: ] @pytest.mark.respx(base_url=base_url) - async def test_basic_union_response(self, respx_mock: MockRouter) -> None: + async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncCasParser) -> None: class Model1(BaseModel): name: str @@ -1301,12 +1331,12 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" @pytest.mark.respx(base_url=base_url) - async def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + async def test_union_response_different_types(self, respx_mock: MockRouter, async_client: AsyncCasParser) -> None: """Union of objects with the same field name using a different type""" class Model1(BaseModel): @@ -1317,18 +1347,20 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model1) assert response.foo == 1 @pytest.mark.respx(base_url=base_url) - async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + async def test_non_application_json_content_type_for_json_data( + self, respx_mock: MockRouter, async_client: AsyncCasParser + ) -> None: """ Response that sets Content-Type to something other than application/json but returns json data """ @@ -1344,11 +1376,11 @@ class Model(BaseModel): ) ) - response = await self.client.get("/foo", cast_to=Model) + response = await async_client.get("/foo", cast_to=Model) assert isinstance(response, Model) assert response.foo == 2 - def test_base_url_setter(self) -> None: + async def test_base_url_setter(self) -> None: client = AsyncCasParser( base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True ) @@ -1358,7 +1390,9 @@ def test_base_url_setter(self) -> None: assert client.base_url == "https://example.com/from_setter/" - def test_base_url_env(self) -> None: + await client.close() + + async def test_base_url_env(self) -> None: with update_env(CAS_PARSER_BASE_URL="http://localhost:5000/from/env"): client = AsyncCasParser(api_key=api_key, _strict_response_validation=True) assert client.base_url == "http://localhost:5000/from/env/" @@ -1378,7 +1412,7 @@ def test_base_url_env(self) -> None: ], ids=["standard", "custom http client"], ) - def test_base_url_trailing_slash(self, client: AsyncCasParser) -> None: + async def test_base_url_trailing_slash(self, client: AsyncCasParser) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1387,6 +1421,7 @@ def test_base_url_trailing_slash(self, client: AsyncCasParser) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() @pytest.mark.parametrize( "client", @@ -1403,7 +1438,7 @@ def test_base_url_trailing_slash(self, client: AsyncCasParser) -> None: ], ids=["standard", "custom http client"], ) - def test_base_url_no_trailing_slash(self, client: AsyncCasParser) -> None: + async def test_base_url_no_trailing_slash(self, client: AsyncCasParser) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1412,6 +1447,7 @@ def test_base_url_no_trailing_slash(self, client: AsyncCasParser) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() @pytest.mark.parametrize( "client", @@ -1428,7 +1464,7 @@ def test_base_url_no_trailing_slash(self, client: AsyncCasParser) -> None: ], ids=["standard", "custom http client"], ) - def test_absolute_request_url(self, client: AsyncCasParser) -> None: + async def test_absolute_request_url(self, client: AsyncCasParser) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1437,37 +1473,37 @@ def test_absolute_request_url(self, client: AsyncCasParser) -> None: ), ) assert request.url == "https://myapi.com/foo" + await client.close() async def test_copied_client_does_not_close_http(self) -> None: - client = AsyncCasParser(base_url=base_url, api_key=api_key, _strict_response_validation=True) - assert not client.is_closed() + test_client = AsyncCasParser(base_url=base_url, api_key=api_key, _strict_response_validation=True) + assert not test_client.is_closed() - copied = client.copy() - assert copied is not client + copied = test_client.copy() + assert copied is not test_client del copied await asyncio.sleep(0.2) - assert not client.is_closed() + assert not test_client.is_closed() async def test_client_context_manager(self) -> None: - client = AsyncCasParser(base_url=base_url, api_key=api_key, _strict_response_validation=True) - async with client as c2: - assert c2 is client + test_client = AsyncCasParser(base_url=base_url, api_key=api_key, _strict_response_validation=True) + async with test_client as c2: + assert c2 is test_client assert not c2.is_closed() - assert not client.is_closed() - assert client.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + async def test_client_response_validation_error(self, respx_mock: MockRouter, async_client: AsyncCasParser) -> None: class Model(BaseModel): foo: str respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) with pytest.raises(APIResponseValidationError) as exc: - await self.client.get("/foo", cast_to=Model) + await async_client.get("/foo", cast_to=Model) assert isinstance(exc.value.__cause__, ValidationError) @@ -1478,7 +1514,6 @@ async def test_client_max_retries_validation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: class Model(BaseModel): name: str @@ -1490,11 +1525,14 @@ class Model(BaseModel): with pytest.raises(APIResponseValidationError): await strict_client.get("/foo", cast_to=Model) - client = AsyncCasParser(base_url=base_url, api_key=api_key, _strict_response_validation=False) + non_strict_client = AsyncCasParser(base_url=base_url, api_key=api_key, _strict_response_validation=False) - response = await client.get("/foo", cast_to=Model) + response = await non_strict_client.get("/foo", cast_to=Model) assert isinstance(response, str) # type: ignore[unreachable] + await strict_client.close() + await non_strict_client.close() + @pytest.mark.parametrize( "remaining_retries,retry_after,timeout", [ @@ -1517,13 +1555,12 @@ class Model(BaseModel): ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) - @pytest.mark.asyncio - async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: - client = AsyncCasParser(base_url=base_url, api_key=api_key, _strict_response_validation=True) - + async def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, async_client: AsyncCasParser + ) -> None: headers = httpx.Headers({"retry-after": retry_after}) options = FinalRequestOptions(method="get", url="/foo", max_retries=3) - calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + calculated = async_client._calculate_retry_timeout(remaining_retries, options, headers) assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] @mock.patch("cas_parser._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @@ -1536,7 +1573,7 @@ async def test_retrying_timeout_errors_doesnt_leak( with pytest.raises(APITimeoutError): await async_client.cas_parser.with_streaming_response.smart_parse().__aenter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(async_client) == 0 @mock.patch("cas_parser._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) @@ -1547,12 +1584,11 @@ async def test_retrying_status_errors_doesnt_leak( with pytest.raises(APIStatusError): await async_client.cas_parser.with_streaming_response.smart_parse().__aenter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(async_client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("cas_parser._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio @pytest.mark.parametrize("failure_mode", ["status", "exception"]) async def test_retries_taken( self, @@ -1584,7 +1620,6 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("cas_parser._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_omit_retry_count_header( self, async_client: AsyncCasParser, failures_before_success: int, respx_mock: MockRouter ) -> None: @@ -1610,7 +1645,6 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("cas_parser._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_overwrite_retry_count_header( self, async_client: AsyncCasParser, failures_before_success: int, respx_mock: MockRouter ) -> None: @@ -1660,26 +1694,26 @@ async def test_default_client_creation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - async def test_follow_redirects(self, respx_mock: MockRouter) -> None: + async def test_follow_redirects(self, respx_mock: MockRouter, async_client: AsyncCasParser) -> None: # Test that the default follow_redirects=True allows following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) - response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + response = await async_client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) assert response.status_code == 200 assert response.json() == {"status": "ok"} @pytest.mark.respx(base_url=base_url) - async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + async def test_follow_redirects_disabled(self, respx_mock: MockRouter, async_client: AsyncCasParser) -> None: # Test that follow_redirects=False prevents following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) with pytest.raises(APIStatusError) as exc_info: - await self.client.post( + await async_client.post( "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response ) From d2d29bcc46989573e27c2178785c6b38df65bd90 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 03:36:25 +0000 Subject: [PATCH 09/15] chore(internal): grammar fix (it's -> its) --- src/cas_parser/_utils/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cas_parser/_utils/_utils.py b/src/cas_parser/_utils/_utils.py index 50d5926..eec7f4a 100644 --- a/src/cas_parser/_utils/_utils.py +++ b/src/cas_parser/_utils/_utils.py @@ -133,7 +133,7 @@ def is_given(obj: _T | NotGiven | Omit) -> TypeGuard[_T]: # Type safe methods for narrowing types with TypeVars. # The default narrowing for isinstance(obj, dict) is dict[unknown, unknown], # however this cause Pyright to rightfully report errors. As we know we don't -# care about the contained types we can safely use `object` in it's place. +# care about the contained types we can safely use `object` in its place. # # There are two separate functions defined, `is_*` and `is_*_t` for different use cases. # `is_*` is for when you're dealing with an unknown input From 20bcea057ce1974149394c899581ed31ffb56a4a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 03:40:57 +0000 Subject: [PATCH 10/15] chore(internal): codegen related update --- README.md | 4 ++-- pyproject.toml | 5 ++--- src/cas_parser/_models.py | 11 ++++++++--- src/cas_parser/_utils/_sync.py | 34 +++------------------------------- tests/test_models.py | 8 ++++---- 5 files changed, 19 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index c13a2c4..798c044 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![PyPI version](https://img.shields.io/pypi/v/cas-parser-python.svg?label=pypi%20(stable))](https://pypi.org/project/cas-parser-python/) -The Cas Parser Python library provides convenient access to the Cas Parser REST API from any Python 3.8+ +The Cas Parser Python library provides convenient access to the Cas Parser REST API from any Python 3.9+ application. The library includes type definitions for all request params and response fields, and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). @@ -380,7 +380,7 @@ print(cas_parser.__version__) ## Requirements -Python 3.8 or higher. +Python 3.9 or higher. ## Contributing diff --git a/pyproject.toml b/pyproject.toml index 39ff931..a87c6f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,11 +15,10 @@ dependencies = [ "distro>=1.7.0, <2", "sniffio", ] -requires-python = ">= 3.8" +requires-python = ">= 3.9" classifiers = [ "Typing :: Typed", "Intended Audience :: Developers", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -141,7 +140,7 @@ filterwarnings = [ # there are a couple of flags that are still disabled by # default in strict mode as they are experimental and niche. typeCheckingMode = "strict" -pythonVersion = "3.8" +pythonVersion = "3.9" exclude = [ "_dev", diff --git a/src/cas_parser/_models.py b/src/cas_parser/_models.py index 6a3cd1d..fcec2cf 100644 --- a/src/cas_parser/_models.py +++ b/src/cas_parser/_models.py @@ -2,6 +2,7 @@ import os import inspect +import weakref from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast from datetime import date, datetime from typing_extensions import ( @@ -573,6 +574,9 @@ class CachedDiscriminatorType(Protocol): __discriminator__: DiscriminatorDetails +DISCRIMINATOR_CACHE: weakref.WeakKeyDictionary[type, DiscriminatorDetails] = weakref.WeakKeyDictionary() + + class DiscriminatorDetails: field_name: str """The name of the discriminator field in the variant class, e.g. @@ -615,8 +619,9 @@ def __init__( def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, ...]) -> DiscriminatorDetails | None: - if isinstance(union, CachedDiscriminatorType): - return union.__discriminator__ + cached = DISCRIMINATOR_CACHE.get(union) + if cached is not None: + return cached discriminator_field_name: str | None = None @@ -669,7 +674,7 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, discriminator_field=discriminator_field_name, discriminator_alias=discriminator_alias, ) - cast(CachedDiscriminatorType, union).__discriminator__ = details + DISCRIMINATOR_CACHE.setdefault(union, details) return details diff --git a/src/cas_parser/_utils/_sync.py b/src/cas_parser/_utils/_sync.py index ad7ec71..f6027c1 100644 --- a/src/cas_parser/_utils/_sync.py +++ b/src/cas_parser/_utils/_sync.py @@ -1,10 +1,8 @@ from __future__ import annotations -import sys import asyncio import functools -import contextvars -from typing import Any, TypeVar, Callable, Awaitable +from typing import TypeVar, Callable, Awaitable from typing_extensions import ParamSpec import anyio @@ -15,34 +13,11 @@ T_ParamSpec = ParamSpec("T_ParamSpec") -if sys.version_info >= (3, 9): - _asyncio_to_thread = asyncio.to_thread -else: - # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread - # for Python 3.8 support - async def _asyncio_to_thread( - func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs - ) -> Any: - """Asynchronously run function *func* in a separate thread. - - Any *args and **kwargs supplied for this function are directly passed - to *func*. Also, the current :class:`contextvars.Context` is propagated, - allowing context variables from the main thread to be accessed in the - separate thread. - - Returns a coroutine that can be awaited to get the eventual result of *func*. - """ - loop = asyncio.events.get_running_loop() - ctx = contextvars.copy_context() - func_call = functools.partial(ctx.run, func, *args, **kwargs) - return await loop.run_in_executor(None, func_call) - - async def to_thread( func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs ) -> T_Retval: if sniffio.current_async_library() == "asyncio": - return await _asyncio_to_thread(func, *args, **kwargs) + return await asyncio.to_thread(func, *args, **kwargs) return await anyio.to_thread.run_sync( functools.partial(func, *args, **kwargs), @@ -53,10 +28,7 @@ async def to_thread( def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: """ Take a blocking function and create an async one that receives the same - positional and keyword arguments. For python version 3.9 and above, it uses - asyncio.to_thread to run the function in a separate thread. For python version - 3.8, it uses locally defined copy of the asyncio.to_thread function which was - introduced in python 3.9. + positional and keyword arguments. Usage: diff --git a/tests/test_models.py b/tests/test_models.py index ffd0d05..82ce6d4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,7 +9,7 @@ from cas_parser._utils import PropertyInfo from cas_parser._compat import PYDANTIC_V1, parse_obj, model_dump, model_json -from cas_parser._models import BaseModel, construct_type +from cas_parser._models import DISCRIMINATOR_CACHE, BaseModel, construct_type class BasicModel(BaseModel): @@ -809,7 +809,7 @@ class B(BaseModel): UnionType = cast(Any, Union[A, B]) - assert not hasattr(UnionType, "__discriminator__") + assert not DISCRIMINATOR_CACHE.get(UnionType) m = construct_type( value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) @@ -818,7 +818,7 @@ class B(BaseModel): assert m.type == "b" assert m.data == "foo" # type: ignore[comparison-overlap] - discriminator = UnionType.__discriminator__ + discriminator = DISCRIMINATOR_CACHE.get(UnionType) assert discriminator is not None m = construct_type( @@ -830,7 +830,7 @@ class B(BaseModel): # if the discriminator details object stays the same between invocations then # we hit the cache - assert UnionType.__discriminator__ is discriminator + assert DISCRIMINATOR_CACHE.get(UnionType) is discriminator @pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") From 8e6c5b210e14602af113fa9fef5c789d6238419a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 03:33:46 +0000 Subject: [PATCH 11/15] chore(internal): codegen related update --- src/cas_parser/_models.py | 41 +++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/cas_parser/_models.py b/src/cas_parser/_models.py index fcec2cf..ca9500b 100644 --- a/src/cas_parser/_models.py +++ b/src/cas_parser/_models.py @@ -257,15 +257,16 @@ def model_dump( mode: Literal["json", "python"] | str = "python", include: IncEx | None = None, exclude: IncEx | None = None, + context: Any | None = None, by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, + exclude_computed_fields: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, - context: dict[str, Any] | None = None, - serialize_as_any: bool = False, fallback: Callable[[Any], Any] | None = None, + serialize_as_any: bool = False, ) -> dict[str, Any]: """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump @@ -273,16 +274,24 @@ def model_dump( Args: mode: The mode in which `to_python` should run. - If mode is 'json', the dictionary will only contain JSON serializable types. - If mode is 'python', the dictionary may contain any Python objects. - include: A list of fields to include in the output. - exclude: A list of fields to exclude from the output. + If mode is 'json', the output will only contain JSON serializable types. + If mode is 'python', the output may contain non-JSON-serializable Python objects. + include: A set of fields to include in the output. + exclude: A set of fields to exclude from the output. + context: Additional context to pass to the serializer. by_alias: Whether to use the field's alias in the dictionary key if defined. - exclude_unset: Whether to exclude fields that are unset or None from the output. - exclude_defaults: Whether to exclude fields that are set to their default value from the output. - exclude_none: Whether to exclude fields that have a value of `None` from the output. - round_trip: Whether to enable serialization and deserialization round-trip support. - warnings: Whether to log warnings when invalid fields are encountered. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that are set to their default value. + exclude_none: Whether to exclude fields that have a value of `None`. + exclude_computed_fields: Whether to exclude computed fields. + While this can be useful for round-tripping, it is usually recommended to use the dedicated + `round_trip` parameter instead. + round_trip: If True, dumped values should be valid as input for non-idempotent types such as Json[T]. + warnings: How to handle serialization errors. False/"none" ignores them, True/"warn" logs errors, + "error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError]. + fallback: A function to call when an unknown value is encountered. If not provided, + a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised. + serialize_as_any: Whether to serialize fields with duck-typing serialization behavior. Returns: A dictionary representation of the model. @@ -299,6 +308,8 @@ def model_dump( raise ValueError("serialize_as_any is only supported in Pydantic v2") if fallback is not None: raise ValueError("fallback is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") dumped = super().dict( # pyright: ignore[reportDeprecated] include=include, exclude=exclude, @@ -315,15 +326,17 @@ def model_dump_json( self, *, indent: int | None = None, + ensure_ascii: bool = False, include: IncEx | None = None, exclude: IncEx | None = None, + context: Any | None = None, by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, + exclude_computed_fields: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, - context: dict[str, Any] | None = None, fallback: Callable[[Any], Any] | None = None, serialize_as_any: bool = False, ) -> str: @@ -355,6 +368,10 @@ def model_dump_json( raise ValueError("serialize_as_any is only supported in Pydantic v2") if fallback is not None: raise ValueError("fallback is only supported in Pydantic v2") + if ensure_ascii != False: + raise ValueError("ensure_ascii is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") return super().json( # type: ignore[reportDeprecated] indent=indent, include=include, From 3fda81deb938a9b689cbb04f839e3b815259a9c5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 23:17:33 +0000 Subject: [PATCH 12/15] feat(api): api update --- .stats.yml | 4 +- LICENSE | 2 +- README.md | 3 +- pyproject.toml | 17 +- requirements-dev.lock | 112 +++-- requirements.lock | 39 +- scripts/lint | 9 +- src/cas_parser/_base_client.py | 10 +- src/cas_parser/_client.py | 133 +++-- src/cas_parser/_streaming.py | 22 +- src/cas_parser/_types.py | 5 +- src/cas_parser/types/unified_response.py | 597 ++++++++++++++++++++++- 12 files changed, 808 insertions(+), 145 deletions(-) diff --git a/.stats.yml b/.stats.yml index 06e7614..48b33b3 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-9eaed98ce5934f11e901cef376a28257d2c196bd3dba7c690babc6741a730ded.yml -openapi_spec_hash: b76e4e830c4d03ba4cf9429bb9fb9c8a +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-38618cc5c938e87eeacf4893d6a6ba4e6ef7da390e6283dc7b50b484a7b97165.yml +openapi_spec_hash: b9e439ecee904ded01aa34efdee88856 config_hash: cb5d75abef6264b5d86448caf7295afa diff --git a/LICENSE b/LICENSE index f1756ce..6bbb512 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2025 Cas Parser + Copyright 2026 Cas Parser Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 798c044..bfab47b 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ pip install cas-parser-python[aiohttp] Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: ```python +import os import asyncio from cas_parser import DefaultAioHttpClient from cas_parser import AsyncCasParser @@ -92,7 +93,7 @@ from cas_parser import AsyncCasParser async def main() -> None: async with AsyncCasParser( - api_key="My API Key", + api_key=os.environ.get("CAS_PARSER_API_KEY"), # This is the default and can be omitted http_client=DefaultAioHttpClient(), ) as client: unified_response = await client.cas_parser.smart_parse( diff --git a/pyproject.toml b/pyproject.toml index a87c6f9..f286b5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,14 +7,16 @@ license = "Apache-2.0" authors = [ { name = "Cas Parser", email = "sameer@casparser.in" }, ] + dependencies = [ - "httpx>=0.23.0, <1", - "pydantic>=1.9.0, <3", - "typing-extensions>=4.10, <5", - "anyio>=3.5.0, <5", - "distro>=1.7.0, <2", - "sniffio", + "httpx>=0.23.0, <1", + "pydantic>=1.9.0, <3", + "typing-extensions>=4.10, <5", + "anyio>=3.5.0, <5", + "distro>=1.7.0, <2", + "sniffio", ] + requires-python = ">= 3.9" classifiers = [ "Typing :: Typed", @@ -24,6 +26,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: MacOS", @@ -45,7 +48,7 @@ managed = true # version pins are in requirements-dev.lock dev-dependencies = [ "pyright==1.1.399", - "mypy", + "mypy==1.17", "respx", "pytest", "pytest-asyncio", diff --git a/requirements-dev.lock b/requirements-dev.lock index e3df62e..1a3f9c1 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -12,40 +12,45 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.8 +aiohttp==3.13.2 # via cas-parser-python # via httpx-aiohttp -aiosignal==1.3.2 +aiosignal==1.4.0 # via aiohttp -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.4.0 +anyio==4.12.0 # via cas-parser-python # via httpx -argcomplete==3.1.2 +argcomplete==3.6.3 # via nox async-timeout==5.0.1 # via aiohttp -attrs==25.3.0 +attrs==25.4.0 # via aiohttp -certifi==2023.7.22 + # via nox +backports-asyncio-runner==1.2.0 + # via pytest-asyncio +certifi==2025.11.12 # via httpcore # via httpx -colorlog==6.7.0 +colorlog==6.10.1 + # via nox +dependency-groups==1.3.1 # via nox -dirty-equals==0.6.0 -distlib==0.3.7 +dirty-equals==0.11 +distlib==0.4.0 # via virtualenv -distro==1.8.0 +distro==1.9.0 # via cas-parser-python -exceptiongroup==1.2.2 +exceptiongroup==1.3.1 # via anyio # via pytest -execnet==2.1.1 +execnet==2.1.2 # via pytest-xdist -filelock==3.12.4 +filelock==3.19.1 # via virtualenv -frozenlist==1.6.2 +frozenlist==1.8.0 # via aiohttp # via aiosignal h11==0.16.0 @@ -58,80 +63,87 @@ httpx==0.28.1 # via respx httpx-aiohttp==0.1.9 # via cas-parser-python -idna==3.4 +humanize==4.13.0 + # via nox +idna==3.11 # via anyio # via httpx # via yarl -importlib-metadata==7.0.0 -iniconfig==2.0.0 +importlib-metadata==8.7.0 +iniconfig==2.1.0 # via pytest markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py -multidict==6.4.4 +multidict==6.7.0 # via aiohttp # via yarl -mypy==1.14.1 -mypy-extensions==1.0.0 +mypy==1.17.0 +mypy-extensions==1.1.0 # via mypy -nodeenv==1.8.0 +nodeenv==1.9.1 # via pyright -nox==2023.4.22 -packaging==23.2 +nox==2025.11.12 +packaging==25.0 + # via dependency-groups # via nox # via pytest -platformdirs==3.11.0 +pathspec==0.12.1 + # via mypy +platformdirs==4.4.0 # via virtualenv -pluggy==1.5.0 +pluggy==1.6.0 # via pytest -propcache==0.3.1 +propcache==0.4.1 # via aiohttp # via yarl -pydantic==2.11.9 +pydantic==2.12.5 # via cas-parser-python -pydantic-core==2.33.2 +pydantic-core==2.41.5 # via pydantic -pygments==2.18.0 +pygments==2.19.2 + # via pytest # via rich pyright==1.1.399 -pytest==8.3.3 +pytest==8.4.2 # via pytest-asyncio # via pytest-xdist -pytest-asyncio==0.24.0 -pytest-xdist==3.7.0 -python-dateutil==2.8.2 +pytest-asyncio==1.2.0 +pytest-xdist==3.8.0 +python-dateutil==2.9.0.post0 # via time-machine -pytz==2023.3.post1 - # via dirty-equals respx==0.22.0 -rich==13.7.1 -ruff==0.9.4 -setuptools==68.2.2 - # via nodeenv -six==1.16.0 +rich==14.2.0 +ruff==0.14.7 +six==1.17.0 # via python-dateutil -sniffio==1.3.0 - # via anyio +sniffio==1.3.1 # via cas-parser-python -time-machine==2.9.0 -tomli==2.0.2 +time-machine==2.19.0 +tomli==2.3.0 + # via dependency-groups # via mypy + # via nox # via pytest -typing-extensions==4.12.2 +typing-extensions==4.15.0 + # via aiosignal # via anyio # via cas-parser-python + # via exceptiongroup # via multidict # via mypy # via pydantic # via pydantic-core # via pyright + # via pytest-asyncio # via typing-inspection -typing-inspection==0.4.1 + # via virtualenv +typing-inspection==0.4.2 # via pydantic -virtualenv==20.24.5 +virtualenv==20.35.4 # via nox -yarl==1.20.0 +yarl==1.22.0 # via aiohttp -zipp==3.17.0 +zipp==3.23.0 # via importlib-metadata diff --git a/requirements.lock b/requirements.lock index dde95d9..4fdd1ca 100644 --- a/requirements.lock +++ b/requirements.lock @@ -12,28 +12,28 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.8 +aiohttp==3.13.2 # via cas-parser-python # via httpx-aiohttp -aiosignal==1.3.2 +aiosignal==1.4.0 # via aiohttp -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.4.0 +anyio==4.12.0 # via cas-parser-python # via httpx async-timeout==5.0.1 # via aiohttp -attrs==25.3.0 +attrs==25.4.0 # via aiohttp -certifi==2023.7.22 +certifi==2025.11.12 # via httpcore # via httpx -distro==1.8.0 +distro==1.9.0 # via cas-parser-python -exceptiongroup==1.2.2 +exceptiongroup==1.3.1 # via anyio -frozenlist==1.6.2 +frozenlist==1.8.0 # via aiohttp # via aiosignal h11==0.16.0 @@ -45,31 +45,32 @@ httpx==0.28.1 # via httpx-aiohttp httpx-aiohttp==0.1.9 # via cas-parser-python -idna==3.4 +idna==3.11 # via anyio # via httpx # via yarl -multidict==6.4.4 +multidict==6.7.0 # via aiohttp # via yarl -propcache==0.3.1 +propcache==0.4.1 # via aiohttp # via yarl -pydantic==2.11.9 +pydantic==2.12.5 # via cas-parser-python -pydantic-core==2.33.2 +pydantic-core==2.41.5 # via pydantic -sniffio==1.3.0 - # via anyio +sniffio==1.3.1 # via cas-parser-python -typing-extensions==4.12.2 +typing-extensions==4.15.0 + # via aiosignal # via anyio # via cas-parser-python + # via exceptiongroup # via multidict # via pydantic # via pydantic-core # via typing-inspection -typing-inspection==0.4.1 +typing-inspection==0.4.2 # via pydantic -yarl==1.20.0 +yarl==1.22.0 # via aiohttp diff --git a/scripts/lint b/scripts/lint index d325f0b..e1bf7a7 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,8 +4,13 @@ set -e cd "$(dirname "$0")/.." -echo "==> Running lints" -rye run lint +if [ "$1" = "--fix" ]; then + echo "==> Running lints with --fix" + rye run fix:ruff +else + echo "==> Running lints" + rye run lint +fi echo "==> Making sure it imports" rye run python -c 'import cas_parser' diff --git a/src/cas_parser/_base_client.py b/src/cas_parser/_base_client.py index fc89c5a..9cfe0c2 100644 --- a/src/cas_parser/_base_client.py +++ b/src/cas_parser/_base_client.py @@ -1247,9 +1247,12 @@ def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + opts = FinalRequestOptions.construct( + method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + ) return self.request(cast_to, opts) def put( @@ -1767,9 +1770,12 @@ async def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + opts = FinalRequestOptions.construct( + method="patch", url=path, json_data=body, files=await async_to_httpx_files(files), **options + ) return await self.request(cast_to, opts) async def put( diff --git a/src/cas_parser/_client.py b/src/cas_parser/_client.py index 19a598a..f0d7ed2 100644 --- a/src/cas_parser/_client.py +++ b/src/cas_parser/_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import Any, Mapping +from typing import TYPE_CHECKING, Any, Mapping from typing_extensions import Self, override import httpx @@ -20,8 +20,8 @@ not_given, ) from ._utils import is_given, get_async_library +from ._compat import cached_property from ._version import __version__ -from .resources import cas_parser, cas_generator from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import APIStatusError, CasParserError from ._base_client import ( @@ -30,6 +30,11 @@ AsyncAPIClient, ) +if TYPE_CHECKING: + from .resources import cas_parser, cas_generator + from .resources.cas_parser import CasParserResource, AsyncCasParserResource + from .resources.cas_generator import CasGeneratorResource, AsyncCasGeneratorResource + __all__ = [ "Timeout", "Transport", @@ -43,11 +48,6 @@ class CasParser(SyncAPIClient): - cas_parser: cas_parser.CasParserResource - cas_generator: cas_generator.CasGeneratorResource - with_raw_response: CasParserWithRawResponse - with_streaming_response: CasParserWithStreamedResponse - # client options api_key: str @@ -102,10 +102,25 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.cas_parser = cas_parser.CasParserResource(self) - self.cas_generator = cas_generator.CasGeneratorResource(self) - self.with_raw_response = CasParserWithRawResponse(self) - self.with_streaming_response = CasParserWithStreamedResponse(self) + @cached_property + def cas_parser(self) -> CasParserResource: + from .resources.cas_parser import CasParserResource + + return CasParserResource(self) + + @cached_property + def cas_generator(self) -> CasGeneratorResource: + from .resources.cas_generator import CasGeneratorResource + + return CasGeneratorResource(self) + + @cached_property + def with_raw_response(self) -> CasParserWithRawResponse: + return CasParserWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> CasParserWithStreamedResponse: + return CasParserWithStreamedResponse(self) @property @override @@ -213,11 +228,6 @@ def _make_status_error( class AsyncCasParser(AsyncAPIClient): - cas_parser: cas_parser.AsyncCasParserResource - cas_generator: cas_generator.AsyncCasGeneratorResource - with_raw_response: AsyncCasParserWithRawResponse - with_streaming_response: AsyncCasParserWithStreamedResponse - # client options api_key: str @@ -272,10 +282,25 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.cas_parser = cas_parser.AsyncCasParserResource(self) - self.cas_generator = cas_generator.AsyncCasGeneratorResource(self) - self.with_raw_response = AsyncCasParserWithRawResponse(self) - self.with_streaming_response = AsyncCasParserWithStreamedResponse(self) + @cached_property + def cas_parser(self) -> AsyncCasParserResource: + from .resources.cas_parser import AsyncCasParserResource + + return AsyncCasParserResource(self) + + @cached_property + def cas_generator(self) -> AsyncCasGeneratorResource: + from .resources.cas_generator import AsyncCasGeneratorResource + + return AsyncCasGeneratorResource(self) + + @cached_property + def with_raw_response(self) -> AsyncCasParserWithRawResponse: + return AsyncCasParserWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncCasParserWithStreamedResponse: + return AsyncCasParserWithStreamedResponse(self) @property @override @@ -383,27 +408,79 @@ def _make_status_error( class CasParserWithRawResponse: + _client: CasParser + def __init__(self, client: CasParser) -> None: - self.cas_parser = cas_parser.CasParserResourceWithRawResponse(client.cas_parser) - self.cas_generator = cas_generator.CasGeneratorResourceWithRawResponse(client.cas_generator) + self._client = client + + @cached_property + def cas_parser(self) -> cas_parser.CasParserResourceWithRawResponse: + from .resources.cas_parser import CasParserResourceWithRawResponse + + return CasParserResourceWithRawResponse(self._client.cas_parser) + + @cached_property + def cas_generator(self) -> cas_generator.CasGeneratorResourceWithRawResponse: + from .resources.cas_generator import CasGeneratorResourceWithRawResponse + + return CasGeneratorResourceWithRawResponse(self._client.cas_generator) class AsyncCasParserWithRawResponse: + _client: AsyncCasParser + def __init__(self, client: AsyncCasParser) -> None: - self.cas_parser = cas_parser.AsyncCasParserResourceWithRawResponse(client.cas_parser) - self.cas_generator = cas_generator.AsyncCasGeneratorResourceWithRawResponse(client.cas_generator) + self._client = client + + @cached_property + def cas_parser(self) -> cas_parser.AsyncCasParserResourceWithRawResponse: + from .resources.cas_parser import AsyncCasParserResourceWithRawResponse + + return AsyncCasParserResourceWithRawResponse(self._client.cas_parser) + + @cached_property + def cas_generator(self) -> cas_generator.AsyncCasGeneratorResourceWithRawResponse: + from .resources.cas_generator import AsyncCasGeneratorResourceWithRawResponse + + return AsyncCasGeneratorResourceWithRawResponse(self._client.cas_generator) class CasParserWithStreamedResponse: + _client: CasParser + def __init__(self, client: CasParser) -> None: - self.cas_parser = cas_parser.CasParserResourceWithStreamingResponse(client.cas_parser) - self.cas_generator = cas_generator.CasGeneratorResourceWithStreamingResponse(client.cas_generator) + self._client = client + + @cached_property + def cas_parser(self) -> cas_parser.CasParserResourceWithStreamingResponse: + from .resources.cas_parser import CasParserResourceWithStreamingResponse + + return CasParserResourceWithStreamingResponse(self._client.cas_parser) + + @cached_property + def cas_generator(self) -> cas_generator.CasGeneratorResourceWithStreamingResponse: + from .resources.cas_generator import CasGeneratorResourceWithStreamingResponse + + return CasGeneratorResourceWithStreamingResponse(self._client.cas_generator) class AsyncCasParserWithStreamedResponse: + _client: AsyncCasParser + def __init__(self, client: AsyncCasParser) -> None: - self.cas_parser = cas_parser.AsyncCasParserResourceWithStreamingResponse(client.cas_parser) - self.cas_generator = cas_generator.AsyncCasGeneratorResourceWithStreamingResponse(client.cas_generator) + self._client = client + + @cached_property + def cas_parser(self) -> cas_parser.AsyncCasParserResourceWithStreamingResponse: + from .resources.cas_parser import AsyncCasParserResourceWithStreamingResponse + + return AsyncCasParserResourceWithStreamingResponse(self._client.cas_parser) + + @cached_property + def cas_generator(self) -> cas_generator.AsyncCasGeneratorResourceWithStreamingResponse: + from .resources.cas_generator import AsyncCasGeneratorResourceWithStreamingResponse + + return AsyncCasGeneratorResourceWithStreamingResponse(self._client.cas_generator) Client = CasParser diff --git a/src/cas_parser/_streaming.py b/src/cas_parser/_streaming.py index 48dca86..00e2105 100644 --- a/src/cas_parser/_streaming.py +++ b/src/cas_parser/_streaming.py @@ -54,11 +54,12 @@ def __stream__(self) -> Iterator[_T]: process_data = self._client._process_response_data iterator = self._iter_events() - for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # As we might not fully consume the response stream, we need to close it explicitly - response.close() + try: + for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + response.close() def __enter__(self) -> Self: return self @@ -117,11 +118,12 @@ async def __stream__(self) -> AsyncIterator[_T]: process_data = self._client._process_response_data iterator = self._iter_events() - async for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # As we might not fully consume the response stream, we need to close it explicitly - await response.aclose() + try: + async for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + await response.aclose() async def __aenter__(self) -> Self: return self diff --git a/src/cas_parser/_types.py b/src/cas_parser/_types.py index b45034b..2c15258 100644 --- a/src/cas_parser/_types.py +++ b/src/cas_parser/_types.py @@ -243,6 +243,9 @@ class HttpxSendArgs(TypedDict, total=False): if TYPE_CHECKING: # This works because str.__contains__ does not accept object (either in typeshed or at runtime) # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285 + # + # Note: index() and count() methods are intentionally omitted to allow pyright to properly + # infer TypedDict types when dict literals are used in lists assigned to SequenceNotStr. class SequenceNotStr(Protocol[_T_co]): @overload def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... @@ -251,8 +254,6 @@ def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... def __contains__(self, value: object, /) -> bool: ... def __len__(self) -> int: ... def __iter__(self) -> Iterator[_T_co]: ... - def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ... - def count(self, value: Any, /) -> int: ... def __reversed__(self) -> Iterator[_T_co]: ... else: # just point this to a normal `Sequence` at runtime to avoid having to special case diff --git a/src/cas_parser/types/unified_response.py b/src/cas_parser/types/unified_response.py index 4c17c58..2a8ab94 100644 --- a/src/cas_parser/types/unified_response.py +++ b/src/cas_parser/types/unified_response.py @@ -14,10 +14,25 @@ "DematAccountAdditionalInfo", "DematAccountHoldings", "DematAccountHoldingsAif", + "DematAccountHoldingsAifAdditionalInfo", + "DematAccountHoldingsAifTransaction", + "DematAccountHoldingsAifTransactionAdditionalInfo", "DematAccountHoldingsCorporateBond", + "DematAccountHoldingsCorporateBondAdditionalInfo", + "DematAccountHoldingsCorporateBondTransaction", + "DematAccountHoldingsCorporateBondTransactionAdditionalInfo", "DematAccountHoldingsDematMutualFund", + "DematAccountHoldingsDematMutualFundAdditionalInfo", + "DematAccountHoldingsDematMutualFundTransaction", + "DematAccountHoldingsDematMutualFundTransactionAdditionalInfo", "DematAccountHoldingsEquity", + "DematAccountHoldingsEquityAdditionalInfo", + "DematAccountHoldingsEquityTransaction", + "DematAccountHoldingsEquityTransactionAdditionalInfo", "DematAccountHoldingsGovernmentSecurity", + "DematAccountHoldingsGovernmentSecurityAdditionalInfo", + "DematAccountHoldingsGovernmentSecurityTransaction", + "DematAccountHoldingsGovernmentSecurityTransactionAdditionalInfo", "DematAccountLinkedHolder", "Insurance", "InsuranceLifeInsurancePolicy", @@ -31,6 +46,7 @@ "MutualFundSchemeAdditionalInfo", "MutualFundSchemeGain", "MutualFundSchemeTransaction", + "MutualFundSchemeTransactionAdditionalInfo", "Np", "NpFund", "NpFundAdditionalInfo", @@ -45,6 +61,8 @@ class DematAccountAdditionalInfo(BaseModel): + """Additional information specific to the demat account type""" + bo_status: Optional[str] = None """Beneficiary Owner status (CDSL)""" @@ -70,8 +88,101 @@ class DematAccountAdditionalInfo(BaseModel): """Account status (CDSL)""" +class DematAccountHoldingsAifAdditionalInfo(BaseModel): + """Additional information specific to the AIF""" + + close_units: Optional[float] = None + """Closing balance units for the statement period (beta)""" + + open_units: Optional[float] = None + """Opening balance units for the statement period (beta)""" + + +class DematAccountHoldingsAifTransactionAdditionalInfo(BaseModel): + """Additional transaction-specific fields that vary by source""" + + capital_withdrawal: Optional[float] = None + """Capital withdrawal amount (CDSL MF transactions)""" + + credit: Optional[float] = None + """Units credited (demat transactions)""" + + debit: Optional[float] = None + """Units debited (demat transactions)""" + + income_distribution: Optional[float] = None + """Income distribution amount (CDSL MF transactions)""" + + order_no: Optional[str] = None + """Order/transaction reference number (demat transactions)""" + + price: Optional[float] = None + """Price per unit (NSDL/CDSL MF transactions)""" + + stamp_duty: Optional[float] = None + """Stamp duty charged""" + + +class DematAccountHoldingsAifTransaction(BaseModel): + """ + Unified transaction schema for all holding types (MF folios, equities, bonds, etc.) + """ + + additional_info: Optional[DematAccountHoldingsAifTransactionAdditionalInfo] = None + """Additional transaction-specific fields that vary by source""" + + amount: Optional[float] = None + """Transaction amount in currency (computed from units × price/NAV)""" + + balance: Optional[float] = None + """Balance units after transaction""" + + date: Optional[datetime.date] = None + """Transaction date (YYYY-MM-DD)""" + + description: Optional[str] = None + """Transaction description/particulars""" + + dividend_rate: Optional[float] = None + """Dividend rate (for DIVIDEND_PAYOUT transactions)""" + + nav: Optional[float] = None + """NAV/price per unit on transaction date""" + + type: Optional[ + Literal[ + "PURCHASE", + "PURCHASE_SIP", + "REDEMPTION", + "SWITCH_IN", + "SWITCH_IN_MERGER", + "SWITCH_OUT", + "SWITCH_OUT_MERGER", + "DIVIDEND_PAYOUT", + "DIVIDEND_REINVEST", + "SEGREGATION", + "STAMP_DUTY_TAX", + "TDS_TAX", + "STT_TAX", + "MISC", + "REVERSAL", + "UNKNOWN", + ] + ] = None + """Transaction type. + + Possible values are PURCHASE, PURCHASE_SIP, REDEMPTION, SWITCH_IN, + SWITCH_IN_MERGER, SWITCH_OUT, SWITCH_OUT_MERGER, DIVIDEND_PAYOUT, + DIVIDEND_REINVEST, SEGREGATION, STAMP_DUTY_TAX, TDS_TAX, STT_TAX, MISC, + REVERSAL, UNKNOWN. + """ + + units: Optional[float] = None + """Number of units involved in transaction""" + + class DematAccountHoldingsAif(BaseModel): - additional_info: Optional[object] = None + additional_info: Optional[DematAccountHoldingsAifAdditionalInfo] = None """Additional information specific to the AIF""" isin: Optional[str] = None @@ -80,6 +191,9 @@ class DematAccountHoldingsAif(BaseModel): name: Optional[str] = None """Name of the AIF""" + transactions: Optional[List[DematAccountHoldingsAifTransaction]] = None + """List of transactions for this holding (beta)""" + units: Optional[float] = None """Number of units held""" @@ -87,8 +201,101 @@ class DematAccountHoldingsAif(BaseModel): """Current market value of the holding""" +class DematAccountHoldingsCorporateBondAdditionalInfo(BaseModel): + """Additional information specific to the corporate bond""" + + close_units: Optional[float] = None + """Closing balance units for the statement period (beta)""" + + open_units: Optional[float] = None + """Opening balance units for the statement period (beta)""" + + +class DematAccountHoldingsCorporateBondTransactionAdditionalInfo(BaseModel): + """Additional transaction-specific fields that vary by source""" + + capital_withdrawal: Optional[float] = None + """Capital withdrawal amount (CDSL MF transactions)""" + + credit: Optional[float] = None + """Units credited (demat transactions)""" + + debit: Optional[float] = None + """Units debited (demat transactions)""" + + income_distribution: Optional[float] = None + """Income distribution amount (CDSL MF transactions)""" + + order_no: Optional[str] = None + """Order/transaction reference number (demat transactions)""" + + price: Optional[float] = None + """Price per unit (NSDL/CDSL MF transactions)""" + + stamp_duty: Optional[float] = None + """Stamp duty charged""" + + +class DematAccountHoldingsCorporateBondTransaction(BaseModel): + """ + Unified transaction schema for all holding types (MF folios, equities, bonds, etc.) + """ + + additional_info: Optional[DematAccountHoldingsCorporateBondTransactionAdditionalInfo] = None + """Additional transaction-specific fields that vary by source""" + + amount: Optional[float] = None + """Transaction amount in currency (computed from units × price/NAV)""" + + balance: Optional[float] = None + """Balance units after transaction""" + + date: Optional[datetime.date] = None + """Transaction date (YYYY-MM-DD)""" + + description: Optional[str] = None + """Transaction description/particulars""" + + dividend_rate: Optional[float] = None + """Dividend rate (for DIVIDEND_PAYOUT transactions)""" + + nav: Optional[float] = None + """NAV/price per unit on transaction date""" + + type: Optional[ + Literal[ + "PURCHASE", + "PURCHASE_SIP", + "REDEMPTION", + "SWITCH_IN", + "SWITCH_IN_MERGER", + "SWITCH_OUT", + "SWITCH_OUT_MERGER", + "DIVIDEND_PAYOUT", + "DIVIDEND_REINVEST", + "SEGREGATION", + "STAMP_DUTY_TAX", + "TDS_TAX", + "STT_TAX", + "MISC", + "REVERSAL", + "UNKNOWN", + ] + ] = None + """Transaction type. + + Possible values are PURCHASE, PURCHASE_SIP, REDEMPTION, SWITCH_IN, + SWITCH_IN_MERGER, SWITCH_OUT, SWITCH_OUT_MERGER, DIVIDEND_PAYOUT, + DIVIDEND_REINVEST, SEGREGATION, STAMP_DUTY_TAX, TDS_TAX, STT_TAX, MISC, + REVERSAL, UNKNOWN. + """ + + units: Optional[float] = None + """Number of units involved in transaction""" + + class DematAccountHoldingsCorporateBond(BaseModel): - additional_info: Optional[object] = None + additional_info: Optional[DematAccountHoldingsCorporateBondAdditionalInfo] = None """Additional information specific to the corporate bond""" isin: Optional[str] = None @@ -97,6 +304,9 @@ class DematAccountHoldingsCorporateBond(BaseModel): name: Optional[str] = None """Name of the corporate bond""" + transactions: Optional[List[DematAccountHoldingsCorporateBondTransaction]] = None + """List of transactions for this holding (beta)""" + units: Optional[float] = None """Number of units held""" @@ -104,8 +314,101 @@ class DematAccountHoldingsCorporateBond(BaseModel): """Current market value of the holding""" +class DematAccountHoldingsDematMutualFundAdditionalInfo(BaseModel): + """Additional information specific to the mutual fund""" + + close_units: Optional[float] = None + """Closing balance units for the statement period (beta)""" + + open_units: Optional[float] = None + """Opening balance units for the statement period (beta)""" + + +class DematAccountHoldingsDematMutualFundTransactionAdditionalInfo(BaseModel): + """Additional transaction-specific fields that vary by source""" + + capital_withdrawal: Optional[float] = None + """Capital withdrawal amount (CDSL MF transactions)""" + + credit: Optional[float] = None + """Units credited (demat transactions)""" + + debit: Optional[float] = None + """Units debited (demat transactions)""" + + income_distribution: Optional[float] = None + """Income distribution amount (CDSL MF transactions)""" + + order_no: Optional[str] = None + """Order/transaction reference number (demat transactions)""" + + price: Optional[float] = None + """Price per unit (NSDL/CDSL MF transactions)""" + + stamp_duty: Optional[float] = None + """Stamp duty charged""" + + +class DematAccountHoldingsDematMutualFundTransaction(BaseModel): + """ + Unified transaction schema for all holding types (MF folios, equities, bonds, etc.) + """ + + additional_info: Optional[DematAccountHoldingsDematMutualFundTransactionAdditionalInfo] = None + """Additional transaction-specific fields that vary by source""" + + amount: Optional[float] = None + """Transaction amount in currency (computed from units × price/NAV)""" + + balance: Optional[float] = None + """Balance units after transaction""" + + date: Optional[datetime.date] = None + """Transaction date (YYYY-MM-DD)""" + + description: Optional[str] = None + """Transaction description/particulars""" + + dividend_rate: Optional[float] = None + """Dividend rate (for DIVIDEND_PAYOUT transactions)""" + + nav: Optional[float] = None + """NAV/price per unit on transaction date""" + + type: Optional[ + Literal[ + "PURCHASE", + "PURCHASE_SIP", + "REDEMPTION", + "SWITCH_IN", + "SWITCH_IN_MERGER", + "SWITCH_OUT", + "SWITCH_OUT_MERGER", + "DIVIDEND_PAYOUT", + "DIVIDEND_REINVEST", + "SEGREGATION", + "STAMP_DUTY_TAX", + "TDS_TAX", + "STT_TAX", + "MISC", + "REVERSAL", + "UNKNOWN", + ] + ] = None + """Transaction type. + + Possible values are PURCHASE, PURCHASE_SIP, REDEMPTION, SWITCH_IN, + SWITCH_IN_MERGER, SWITCH_OUT, SWITCH_OUT_MERGER, DIVIDEND_PAYOUT, + DIVIDEND_REINVEST, SEGREGATION, STAMP_DUTY_TAX, TDS_TAX, STT_TAX, MISC, + REVERSAL, UNKNOWN. + """ + + units: Optional[float] = None + """Number of units involved in transaction""" + + class DematAccountHoldingsDematMutualFund(BaseModel): - additional_info: Optional[object] = None + additional_info: Optional[DematAccountHoldingsDematMutualFundAdditionalInfo] = None """Additional information specific to the mutual fund""" isin: Optional[str] = None @@ -114,6 +417,9 @@ class DematAccountHoldingsDematMutualFund(BaseModel): name: Optional[str] = None """Name of the mutual fund""" + transactions: Optional[List[DematAccountHoldingsDematMutualFundTransaction]] = None + """List of transactions for this holding (beta)""" + units: Optional[float] = None """Number of units held""" @@ -121,8 +427,101 @@ class DematAccountHoldingsDematMutualFund(BaseModel): """Current market value of the holding""" +class DematAccountHoldingsEquityAdditionalInfo(BaseModel): + """Additional information specific to the equity""" + + close_units: Optional[float] = None + """Closing balance units for the statement period (beta)""" + + open_units: Optional[float] = None + """Opening balance units for the statement period (beta)""" + + +class DematAccountHoldingsEquityTransactionAdditionalInfo(BaseModel): + """Additional transaction-specific fields that vary by source""" + + capital_withdrawal: Optional[float] = None + """Capital withdrawal amount (CDSL MF transactions)""" + + credit: Optional[float] = None + """Units credited (demat transactions)""" + + debit: Optional[float] = None + """Units debited (demat transactions)""" + + income_distribution: Optional[float] = None + """Income distribution amount (CDSL MF transactions)""" + + order_no: Optional[str] = None + """Order/transaction reference number (demat transactions)""" + + price: Optional[float] = None + """Price per unit (NSDL/CDSL MF transactions)""" + + stamp_duty: Optional[float] = None + """Stamp duty charged""" + + +class DematAccountHoldingsEquityTransaction(BaseModel): + """ + Unified transaction schema for all holding types (MF folios, equities, bonds, etc.) + """ + + additional_info: Optional[DematAccountHoldingsEquityTransactionAdditionalInfo] = None + """Additional transaction-specific fields that vary by source""" + + amount: Optional[float] = None + """Transaction amount in currency (computed from units × price/NAV)""" + + balance: Optional[float] = None + """Balance units after transaction""" + + date: Optional[datetime.date] = None + """Transaction date (YYYY-MM-DD)""" + + description: Optional[str] = None + """Transaction description/particulars""" + + dividend_rate: Optional[float] = None + """Dividend rate (for DIVIDEND_PAYOUT transactions)""" + + nav: Optional[float] = None + """NAV/price per unit on transaction date""" + + type: Optional[ + Literal[ + "PURCHASE", + "PURCHASE_SIP", + "REDEMPTION", + "SWITCH_IN", + "SWITCH_IN_MERGER", + "SWITCH_OUT", + "SWITCH_OUT_MERGER", + "DIVIDEND_PAYOUT", + "DIVIDEND_REINVEST", + "SEGREGATION", + "STAMP_DUTY_TAX", + "TDS_TAX", + "STT_TAX", + "MISC", + "REVERSAL", + "UNKNOWN", + ] + ] = None + """Transaction type. + + Possible values are PURCHASE, PURCHASE_SIP, REDEMPTION, SWITCH_IN, + SWITCH_IN_MERGER, SWITCH_OUT, SWITCH_OUT_MERGER, DIVIDEND_PAYOUT, + DIVIDEND_REINVEST, SEGREGATION, STAMP_DUTY_TAX, TDS_TAX, STT_TAX, MISC, + REVERSAL, UNKNOWN. + """ + + units: Optional[float] = None + """Number of units involved in transaction""" + + class DematAccountHoldingsEquity(BaseModel): - additional_info: Optional[object] = None + additional_info: Optional[DematAccountHoldingsEquityAdditionalInfo] = None """Additional information specific to the equity""" isin: Optional[str] = None @@ -131,6 +530,9 @@ class DematAccountHoldingsEquity(BaseModel): name: Optional[str] = None """Name of the equity""" + transactions: Optional[List[DematAccountHoldingsEquityTransaction]] = None + """List of transactions for this holding (beta)""" + units: Optional[float] = None """Number of units held""" @@ -138,8 +540,101 @@ class DematAccountHoldingsEquity(BaseModel): """Current market value of the holding""" +class DematAccountHoldingsGovernmentSecurityAdditionalInfo(BaseModel): + """Additional information specific to the government security""" + + close_units: Optional[float] = None + """Closing balance units for the statement period (beta)""" + + open_units: Optional[float] = None + """Opening balance units for the statement period (beta)""" + + +class DematAccountHoldingsGovernmentSecurityTransactionAdditionalInfo(BaseModel): + """Additional transaction-specific fields that vary by source""" + + capital_withdrawal: Optional[float] = None + """Capital withdrawal amount (CDSL MF transactions)""" + + credit: Optional[float] = None + """Units credited (demat transactions)""" + + debit: Optional[float] = None + """Units debited (demat transactions)""" + + income_distribution: Optional[float] = None + """Income distribution amount (CDSL MF transactions)""" + + order_no: Optional[str] = None + """Order/transaction reference number (demat transactions)""" + + price: Optional[float] = None + """Price per unit (NSDL/CDSL MF transactions)""" + + stamp_duty: Optional[float] = None + """Stamp duty charged""" + + +class DematAccountHoldingsGovernmentSecurityTransaction(BaseModel): + """ + Unified transaction schema for all holding types (MF folios, equities, bonds, etc.) + """ + + additional_info: Optional[DematAccountHoldingsGovernmentSecurityTransactionAdditionalInfo] = None + """Additional transaction-specific fields that vary by source""" + + amount: Optional[float] = None + """Transaction amount in currency (computed from units × price/NAV)""" + + balance: Optional[float] = None + """Balance units after transaction""" + + date: Optional[datetime.date] = None + """Transaction date (YYYY-MM-DD)""" + + description: Optional[str] = None + """Transaction description/particulars""" + + dividend_rate: Optional[float] = None + """Dividend rate (for DIVIDEND_PAYOUT transactions)""" + + nav: Optional[float] = None + """NAV/price per unit on transaction date""" + + type: Optional[ + Literal[ + "PURCHASE", + "PURCHASE_SIP", + "REDEMPTION", + "SWITCH_IN", + "SWITCH_IN_MERGER", + "SWITCH_OUT", + "SWITCH_OUT_MERGER", + "DIVIDEND_PAYOUT", + "DIVIDEND_REINVEST", + "SEGREGATION", + "STAMP_DUTY_TAX", + "TDS_TAX", + "STT_TAX", + "MISC", + "REVERSAL", + "UNKNOWN", + ] + ] = None + """Transaction type. + + Possible values are PURCHASE, PURCHASE_SIP, REDEMPTION, SWITCH_IN, + SWITCH_IN_MERGER, SWITCH_OUT, SWITCH_OUT_MERGER, DIVIDEND_PAYOUT, + DIVIDEND_REINVEST, SEGREGATION, STAMP_DUTY_TAX, TDS_TAX, STT_TAX, MISC, + REVERSAL, UNKNOWN. + """ + + units: Optional[float] = None + """Number of units involved in transaction""" + + class DematAccountHoldingsGovernmentSecurity(BaseModel): - additional_info: Optional[object] = None + additional_info: Optional[DematAccountHoldingsGovernmentSecurityAdditionalInfo] = None """Additional information specific to the government security""" isin: Optional[str] = None @@ -148,6 +643,9 @@ class DematAccountHoldingsGovernmentSecurity(BaseModel): name: Optional[str] = None """Name of the government security""" + transactions: Optional[List[DematAccountHoldingsGovernmentSecurityTransaction]] = None + """List of transactions for this holding (beta)""" + units: Optional[float] = None """Number of units held""" @@ -278,6 +776,8 @@ class Meta(BaseModel): class MutualFundAdditionalInfo(BaseModel): + """Additional folio information""" + kyc: Optional[str] = None """KYC status of the folio""" @@ -297,6 +797,8 @@ class MutualFundLinkedHolder(BaseModel): class MutualFundSchemeAdditionalInfo(BaseModel): + """Additional information specific to the scheme""" + advisor: Optional[str] = None """Financial advisor name (CAMS/KFintech)""" @@ -304,10 +806,10 @@ class MutualFundSchemeAdditionalInfo(BaseModel): """AMFI code for the scheme (CAMS/KFintech)""" close_units: Optional[float] = None - """Closing balance units (CAMS/KFintech)""" + """Closing balance units for the statement period""" open_units: Optional[float] = None - """Opening balance units (CAMS/KFintech)""" + """Opening balance units for the statement period""" rta_code: Optional[str] = None """RTA code for the scheme (CAMS/KFintech)""" @@ -321,36 +823,87 @@ class MutualFundSchemeGain(BaseModel): """Percentage gain or loss""" +class MutualFundSchemeTransactionAdditionalInfo(BaseModel): + """Additional transaction-specific fields that vary by source""" + + capital_withdrawal: Optional[float] = None + """Capital withdrawal amount (CDSL MF transactions)""" + + credit: Optional[float] = None + """Units credited (demat transactions)""" + + debit: Optional[float] = None + """Units debited (demat transactions)""" + + income_distribution: Optional[float] = None + """Income distribution amount (CDSL MF transactions)""" + + order_no: Optional[str] = None + """Order/transaction reference number (demat transactions)""" + + price: Optional[float] = None + """Price per unit (NSDL/CDSL MF transactions)""" + + stamp_duty: Optional[float] = None + """Stamp duty charged""" + + class MutualFundSchemeTransaction(BaseModel): + """ + Unified transaction schema for all holding types (MF folios, equities, bonds, etc.) + """ + + additional_info: Optional[MutualFundSchemeTransactionAdditionalInfo] = None + """Additional transaction-specific fields that vary by source""" + amount: Optional[float] = None - """Transaction amount""" + """Transaction amount in currency (computed from units × price/NAV)""" balance: Optional[float] = None """Balance units after transaction""" date: Optional[datetime.date] = None - """Transaction date""" + """Transaction date (YYYY-MM-DD)""" description: Optional[str] = None - """Transaction description""" + """Transaction description/particulars""" dividend_rate: Optional[float] = None - """Dividend rate (for dividend transactions)""" + """Dividend rate (for DIVIDEND_PAYOUT transactions)""" nav: Optional[float] = None - """NAV on transaction date""" - - type: Optional[str] = None - """Transaction type detected based on description. - - Possible values are - PURCHASE,PURCHASE_SIP,REDEMPTION,SWITCH_IN,SWITCH_IN_MERGER,SWITCH_OUT,SWITCH_OUT_MERGER,DIVIDEND_PAYOUT,DIVIDEND_REINVESTMENT,SEGREGATION,STAMP_DUTY_TAX,TDS_TAX,STT_TAX,MISC. - If dividend_rate is present, then possible values are dividend_rate is - applicable only for DIVIDEND_PAYOUT and DIVIDEND_REINVESTMENT. + """NAV/price per unit on transaction date""" + + type: Optional[ + Literal[ + "PURCHASE", + "PURCHASE_SIP", + "REDEMPTION", + "SWITCH_IN", + "SWITCH_IN_MERGER", + "SWITCH_OUT", + "SWITCH_OUT_MERGER", + "DIVIDEND_PAYOUT", + "DIVIDEND_REINVEST", + "SEGREGATION", + "STAMP_DUTY_TAX", + "TDS_TAX", + "STT_TAX", + "MISC", + "REVERSAL", + "UNKNOWN", + ] + ] = None + """Transaction type. + + Possible values are PURCHASE, PURCHASE_SIP, REDEMPTION, SWITCH_IN, + SWITCH_IN_MERGER, SWITCH_OUT, SWITCH_OUT_MERGER, DIVIDEND_PAYOUT, + DIVIDEND_REINVEST, SEGREGATION, STAMP_DUTY_TAX, TDS_TAX, STT_TAX, MISC, + REVERSAL, UNKNOWN. """ units: Optional[float] = None - """Number of units involved""" + """Number of units involved in transaction""" class MutualFundScheme(BaseModel): @@ -409,6 +962,8 @@ class MutualFund(BaseModel): class NpFundAdditionalInfo(BaseModel): + """Additional information specific to the NPS fund""" + manager: Optional[str] = None """Fund manager name""" From bd6977a8a78c4a1633e4e6a1dc1d3335b1aa6611 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 02:17:29 +0000 Subject: [PATCH 13/15] feat(api): api update --- .stats.yml | 6 +- api.md | 12 - src/cas_parser/_client.py | 39 +-- src/cas_parser/resources/__init__.py | 14 -- src/cas_parser/resources/cas_generator.py | 225 ------------------ src/cas_parser/types/__init__.py | 2 - .../cas_generator_generate_cas_params.py | 30 --- .../cas_generator_generate_cas_response.py | 13 - tests/api_resources/test_cas_generator.py | 136 ----------- 9 files changed, 4 insertions(+), 473 deletions(-) delete mode 100644 src/cas_parser/resources/cas_generator.py delete mode 100644 src/cas_parser/types/cas_generator_generate_cas_params.py delete mode 100644 src/cas_parser/types/cas_generator_generate_cas_response.py delete mode 100644 tests/api_resources/test_cas_generator.py diff --git a/.stats.yml b/.stats.yml index 48b33b3..4465de7 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 5 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-38618cc5c938e87eeacf4893d6a6ba4e6ef7da390e6283dc7b50b484a7b97165.yml -openapi_spec_hash: b9e439ecee904ded01aa34efdee88856 +configured_endpoints: 4 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-7e6397bddc220d1a59b5e2c7e7c3ff38f1a6eb174f4e383e03bc49cf78c8c44f.yml +openapi_spec_hash: cb852eeb4ce89c80f4246815cbe21f72 config_hash: cb5d75abef6264b5d86448caf7295afa diff --git a/api.md b/api.md index 9f56f41..7a55253 100644 --- a/api.md +++ b/api.md @@ -12,15 +12,3 @@ Methods: - client.cas_parser.cdsl(\*\*params) -> UnifiedResponse - client.cas_parser.nsdl(\*\*params) -> UnifiedResponse - client.cas_parser.smart_parse(\*\*params) -> UnifiedResponse - -# CasGenerator - -Types: - -```python -from cas_parser.types import CasGeneratorGenerateCasResponse -``` - -Methods: - -- client.cas_generator.generate_cas(\*\*params) -> CasGeneratorGenerateCasResponse diff --git a/src/cas_parser/_client.py b/src/cas_parser/_client.py index f0d7ed2..b84a489 100644 --- a/src/cas_parser/_client.py +++ b/src/cas_parser/_client.py @@ -31,9 +31,8 @@ ) if TYPE_CHECKING: - from .resources import cas_parser, cas_generator + from .resources import cas_parser from .resources.cas_parser import CasParserResource, AsyncCasParserResource - from .resources.cas_generator import CasGeneratorResource, AsyncCasGeneratorResource __all__ = [ "Timeout", @@ -108,12 +107,6 @@ def cas_parser(self) -> CasParserResource: return CasParserResource(self) - @cached_property - def cas_generator(self) -> CasGeneratorResource: - from .resources.cas_generator import CasGeneratorResource - - return CasGeneratorResource(self) - @cached_property def with_raw_response(self) -> CasParserWithRawResponse: return CasParserWithRawResponse(self) @@ -288,12 +281,6 @@ def cas_parser(self) -> AsyncCasParserResource: return AsyncCasParserResource(self) - @cached_property - def cas_generator(self) -> AsyncCasGeneratorResource: - from .resources.cas_generator import AsyncCasGeneratorResource - - return AsyncCasGeneratorResource(self) - @cached_property def with_raw_response(self) -> AsyncCasParserWithRawResponse: return AsyncCasParserWithRawResponse(self) @@ -419,12 +406,6 @@ def cas_parser(self) -> cas_parser.CasParserResourceWithRawResponse: return CasParserResourceWithRawResponse(self._client.cas_parser) - @cached_property - def cas_generator(self) -> cas_generator.CasGeneratorResourceWithRawResponse: - from .resources.cas_generator import CasGeneratorResourceWithRawResponse - - return CasGeneratorResourceWithRawResponse(self._client.cas_generator) - class AsyncCasParserWithRawResponse: _client: AsyncCasParser @@ -438,12 +419,6 @@ def cas_parser(self) -> cas_parser.AsyncCasParserResourceWithRawResponse: return AsyncCasParserResourceWithRawResponse(self._client.cas_parser) - @cached_property - def cas_generator(self) -> cas_generator.AsyncCasGeneratorResourceWithRawResponse: - from .resources.cas_generator import AsyncCasGeneratorResourceWithRawResponse - - return AsyncCasGeneratorResourceWithRawResponse(self._client.cas_generator) - class CasParserWithStreamedResponse: _client: CasParser @@ -457,12 +432,6 @@ def cas_parser(self) -> cas_parser.CasParserResourceWithStreamingResponse: return CasParserResourceWithStreamingResponse(self._client.cas_parser) - @cached_property - def cas_generator(self) -> cas_generator.CasGeneratorResourceWithStreamingResponse: - from .resources.cas_generator import CasGeneratorResourceWithStreamingResponse - - return CasGeneratorResourceWithStreamingResponse(self._client.cas_generator) - class AsyncCasParserWithStreamedResponse: _client: AsyncCasParser @@ -476,12 +445,6 @@ def cas_parser(self) -> cas_parser.AsyncCasParserResourceWithStreamingResponse: return AsyncCasParserResourceWithStreamingResponse(self._client.cas_parser) - @cached_property - def cas_generator(self) -> cas_generator.AsyncCasGeneratorResourceWithStreamingResponse: - from .resources.cas_generator import AsyncCasGeneratorResourceWithStreamingResponse - - return AsyncCasGeneratorResourceWithStreamingResponse(self._client.cas_generator) - Client = CasParser diff --git a/src/cas_parser/resources/__init__.py b/src/cas_parser/resources/__init__.py index f1bb2bf..5da0162 100644 --- a/src/cas_parser/resources/__init__.py +++ b/src/cas_parser/resources/__init__.py @@ -8,14 +8,6 @@ CasParserResourceWithStreamingResponse, AsyncCasParserResourceWithStreamingResponse, ) -from .cas_generator import ( - CasGeneratorResource, - AsyncCasGeneratorResource, - CasGeneratorResourceWithRawResponse, - AsyncCasGeneratorResourceWithRawResponse, - CasGeneratorResourceWithStreamingResponse, - AsyncCasGeneratorResourceWithStreamingResponse, -) __all__ = [ "CasParserResource", @@ -24,10 +16,4 @@ "AsyncCasParserResourceWithRawResponse", "CasParserResourceWithStreamingResponse", "AsyncCasParserResourceWithStreamingResponse", - "CasGeneratorResource", - "AsyncCasGeneratorResource", - "CasGeneratorResourceWithRawResponse", - "AsyncCasGeneratorResourceWithRawResponse", - "CasGeneratorResourceWithStreamingResponse", - "AsyncCasGeneratorResourceWithStreamingResponse", ] diff --git a/src/cas_parser/resources/cas_generator.py b/src/cas_parser/resources/cas_generator.py deleted file mode 100644 index 26ff32a..0000000 --- a/src/cas_parser/resources/cas_generator.py +++ /dev/null @@ -1,225 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Literal - -import httpx - -from ..types import cas_generator_generate_cas_params -from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from .._utils import maybe_transform, async_maybe_transform -from .._compat import cached_property -from .._resource import SyncAPIResource, AsyncAPIResource -from .._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from .._base_client import make_request_options -from ..types.cas_generator_generate_cas_response import CasGeneratorGenerateCasResponse - -__all__ = ["CasGeneratorResource", "AsyncCasGeneratorResource"] - - -class CasGeneratorResource(SyncAPIResource): - @cached_property - def with_raw_response(self) -> CasGeneratorResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/CASParser/cas-parser-python#accessing-raw-response-data-eg-headers - """ - return CasGeneratorResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> CasGeneratorResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/CASParser/cas-parser-python#with_streaming_response - """ - return CasGeneratorResourceWithStreamingResponse(self) - - def generate_cas( - self, - *, - email: str, - from_date: str, - password: str, - to_date: str, - cas_authority: Literal["kfintech", "cams", "cdsl", "nsdl"] | Omit = omit, - pan_no: str | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> CasGeneratorGenerateCasResponse: - """ - This endpoint generates CAS (Consolidated Account Statement) documents by - submitting a mailback request to the specified CAS authority. Currently only - supports KFintech, with plans to support CAMS, CDSL, and NSDL in the future. - - Args: - email: Email address to receive the CAS document - - from_date: Start date for the CAS period (format YYYY-MM-DD) - - password: Password to protect the generated CAS PDF - - to_date: End date for the CAS period (format YYYY-MM-DD) - - cas_authority: CAS authority to generate the document from (currently only kfintech is - supported) - - pan_no: PAN number (optional for some CAS authorities) - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._post( - "/v4/generate", - body=maybe_transform( - { - "email": email, - "from_date": from_date, - "password": password, - "to_date": to_date, - "cas_authority": cas_authority, - "pan_no": pan_no, - }, - cas_generator_generate_cas_params.CasGeneratorGenerateCasParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=CasGeneratorGenerateCasResponse, - ) - - -class AsyncCasGeneratorResource(AsyncAPIResource): - @cached_property - def with_raw_response(self) -> AsyncCasGeneratorResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/CASParser/cas-parser-python#accessing-raw-response-data-eg-headers - """ - return AsyncCasGeneratorResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncCasGeneratorResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/CASParser/cas-parser-python#with_streaming_response - """ - return AsyncCasGeneratorResourceWithStreamingResponse(self) - - async def generate_cas( - self, - *, - email: str, - from_date: str, - password: str, - to_date: str, - cas_authority: Literal["kfintech", "cams", "cdsl", "nsdl"] | Omit = omit, - pan_no: str | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> CasGeneratorGenerateCasResponse: - """ - This endpoint generates CAS (Consolidated Account Statement) documents by - submitting a mailback request to the specified CAS authority. Currently only - supports KFintech, with plans to support CAMS, CDSL, and NSDL in the future. - - Args: - email: Email address to receive the CAS document - - from_date: Start date for the CAS period (format YYYY-MM-DD) - - password: Password to protect the generated CAS PDF - - to_date: End date for the CAS period (format YYYY-MM-DD) - - cas_authority: CAS authority to generate the document from (currently only kfintech is - supported) - - pan_no: PAN number (optional for some CAS authorities) - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._post( - "/v4/generate", - body=await async_maybe_transform( - { - "email": email, - "from_date": from_date, - "password": password, - "to_date": to_date, - "cas_authority": cas_authority, - "pan_no": pan_no, - }, - cas_generator_generate_cas_params.CasGeneratorGenerateCasParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=CasGeneratorGenerateCasResponse, - ) - - -class CasGeneratorResourceWithRawResponse: - def __init__(self, cas_generator: CasGeneratorResource) -> None: - self._cas_generator = cas_generator - - self.generate_cas = to_raw_response_wrapper( - cas_generator.generate_cas, - ) - - -class AsyncCasGeneratorResourceWithRawResponse: - def __init__(self, cas_generator: AsyncCasGeneratorResource) -> None: - self._cas_generator = cas_generator - - self.generate_cas = async_to_raw_response_wrapper( - cas_generator.generate_cas, - ) - - -class CasGeneratorResourceWithStreamingResponse: - def __init__(self, cas_generator: CasGeneratorResource) -> None: - self._cas_generator = cas_generator - - self.generate_cas = to_streamed_response_wrapper( - cas_generator.generate_cas, - ) - - -class AsyncCasGeneratorResourceWithStreamingResponse: - def __init__(self, cas_generator: AsyncCasGeneratorResource) -> None: - self._cas_generator = cas_generator - - self.generate_cas = async_to_streamed_response_wrapper( - cas_generator.generate_cas, - ) diff --git a/src/cas_parser/types/__init__.py b/src/cas_parser/types/__init__.py index 4dbdba1..fcdbc0b 100644 --- a/src/cas_parser/types/__init__.py +++ b/src/cas_parser/types/__init__.py @@ -7,5 +7,3 @@ from .cas_parser_nsdl_params import CasParserNsdlParams as CasParserNsdlParams from .cas_parser_smart_parse_params import CasParserSmartParseParams as CasParserSmartParseParams from .cas_parser_cams_kfintech_params import CasParserCamsKfintechParams as CasParserCamsKfintechParams -from .cas_generator_generate_cas_params import CasGeneratorGenerateCasParams as CasGeneratorGenerateCasParams -from .cas_generator_generate_cas_response import CasGeneratorGenerateCasResponse as CasGeneratorGenerateCasResponse diff --git a/src/cas_parser/types/cas_generator_generate_cas_params.py b/src/cas_parser/types/cas_generator_generate_cas_params.py deleted file mode 100644 index 253dcea..0000000 --- a/src/cas_parser/types/cas_generator_generate_cas_params.py +++ /dev/null @@ -1,30 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Literal, Required, TypedDict - -__all__ = ["CasGeneratorGenerateCasParams"] - - -class CasGeneratorGenerateCasParams(TypedDict, total=False): - email: Required[str] - """Email address to receive the CAS document""" - - from_date: Required[str] - """Start date for the CAS period (format YYYY-MM-DD)""" - - password: Required[str] - """Password to protect the generated CAS PDF""" - - to_date: Required[str] - """End date for the CAS period (format YYYY-MM-DD)""" - - cas_authority: Literal["kfintech", "cams", "cdsl", "nsdl"] - """ - CAS authority to generate the document from (currently only kfintech is - supported) - """ - - pan_no: str - """PAN number (optional for some CAS authorities)""" diff --git a/src/cas_parser/types/cas_generator_generate_cas_response.py b/src/cas_parser/types/cas_generator_generate_cas_response.py deleted file mode 100644 index e781ef9..0000000 --- a/src/cas_parser/types/cas_generator_generate_cas_response.py +++ /dev/null @@ -1,13 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Optional - -from .._models import BaseModel - -__all__ = ["CasGeneratorGenerateCasResponse"] - - -class CasGeneratorGenerateCasResponse(BaseModel): - msg: Optional[str] = None - - status: Optional[str] = None diff --git a/tests/api_resources/test_cas_generator.py b/tests/api_resources/test_cas_generator.py deleted file mode 100644 index d0d591d..0000000 --- a/tests/api_resources/test_cas_generator.py +++ /dev/null @@ -1,136 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from cas_parser import CasParser, AsyncCasParser -from tests.utils import assert_matches_type -from cas_parser.types import CasGeneratorGenerateCasResponse - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestCasGenerator: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_generate_cas(self, client: CasParser) -> None: - cas_generator = client.cas_generator.generate_cas( - email="user@example.com", - from_date="2023-01-01", - password="Abcdefghi12$", - to_date="2023-12-31", - ) - assert_matches_type(CasGeneratorGenerateCasResponse, cas_generator, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_generate_cas_with_all_params(self, client: CasParser) -> None: - cas_generator = client.cas_generator.generate_cas( - email="user@example.com", - from_date="2023-01-01", - password="Abcdefghi12$", - to_date="2023-12-31", - cas_authority="kfintech", - pan_no="ABCDE1234F", - ) - assert_matches_type(CasGeneratorGenerateCasResponse, cas_generator, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_generate_cas(self, client: CasParser) -> None: - response = client.cas_generator.with_raw_response.generate_cas( - email="user@example.com", - from_date="2023-01-01", - password="Abcdefghi12$", - to_date="2023-12-31", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - cas_generator = response.parse() - assert_matches_type(CasGeneratorGenerateCasResponse, cas_generator, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_generate_cas(self, client: CasParser) -> None: - with client.cas_generator.with_streaming_response.generate_cas( - email="user@example.com", - from_date="2023-01-01", - password="Abcdefghi12$", - to_date="2023-12-31", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - cas_generator = response.parse() - assert_matches_type(CasGeneratorGenerateCasResponse, cas_generator, path=["response"]) - - assert cast(Any, response.is_closed) is True - - -class TestAsyncCasGenerator: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_generate_cas(self, async_client: AsyncCasParser) -> None: - cas_generator = await async_client.cas_generator.generate_cas( - email="user@example.com", - from_date="2023-01-01", - password="Abcdefghi12$", - to_date="2023-12-31", - ) - assert_matches_type(CasGeneratorGenerateCasResponse, cas_generator, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_generate_cas_with_all_params(self, async_client: AsyncCasParser) -> None: - cas_generator = await async_client.cas_generator.generate_cas( - email="user@example.com", - from_date="2023-01-01", - password="Abcdefghi12$", - to_date="2023-12-31", - cas_authority="kfintech", - pan_no="ABCDE1234F", - ) - assert_matches_type(CasGeneratorGenerateCasResponse, cas_generator, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_generate_cas(self, async_client: AsyncCasParser) -> None: - response = await async_client.cas_generator.with_raw_response.generate_cas( - email="user@example.com", - from_date="2023-01-01", - password="Abcdefghi12$", - to_date="2023-12-31", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - cas_generator = await response.parse() - assert_matches_type(CasGeneratorGenerateCasResponse, cas_generator, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_generate_cas(self, async_client: AsyncCasParser) -> None: - async with async_client.cas_generator.with_streaming_response.generate_cas( - email="user@example.com", - from_date="2023-01-01", - password="Abcdefghi12$", - to_date="2023-12-31", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - cas_generator = await response.parse() - assert_matches_type(CasGeneratorGenerateCasResponse, cas_generator, path=["response"]) - - assert cast(Any, response.is_closed) is True From 93a9613c79ec70869cf11dd0b9bac0a8c6194a31 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 02:17:31 +0000 Subject: [PATCH 14/15] feat(api): api update --- .github/workflows/ci.yml | 6 +- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/release-doctor.yml | 2 +- .stats.yml | 4 +- README.md | 9 ++ src/cas_parser/_base_client.py | 145 +++++++++++++++++++-- src/cas_parser/_models.py | 17 ++- src/cas_parser/_types.py | 9 ++ tests/test_client.py | 187 ++++++++++++++++++++++++++- 9 files changed, 360 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a9eb32..e945b15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/cas-parser-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | @@ -44,7 +44,7 @@ jobs: id-token: write runs-on: ${{ github.repository == 'stainless-sdks/cas-parser-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | @@ -81,7 +81,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/cas-parser-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index f0a5b3c..9a3087b 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index ea04f96..a77924a 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'CASParser/cas-parser-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check release environment run: | diff --git a/.stats.yml b/.stats.yml index 4465de7..968a0a4 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 4 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-7e6397bddc220d1a59b5e2c7e7c3ff38f1a6eb174f4e383e03bc49cf78c8c44f.yml -openapi_spec_hash: cb852eeb4ce89c80f4246815cbe21f72 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-ce2296c4b14d27c141bb2745607d2456c923fdca3ae0a0a0800c26e564333850.yml +openapi_spec_hash: 8eb586ccf16b534c0c15ff6a22274c7d config_hash: cb5d75abef6264b5d86448caf7295afa diff --git a/README.md b/README.md index bfab47b..d3f8ab4 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,15 @@ and offers both synchronous and asynchronous clients powered by [httpx](https:// It is generated with [Stainless](https://www.stainless.com/). +## MCP Server + +Use the Cas Parser MCP Server to enable AI assistants to interact with this API, allowing them to explore endpoints, make test requests, and use documentation to help integrate this SDK into your application. + +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=cas-parser-node-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImNhcy1wYXJzZXItbm9kZS1tY3AiXX0) +[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22cas-parser-node-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22cas-parser-node-mcp%22%5D%7D) + +> Note: You may need to set environment variables in your MCP client. + ## Documentation The REST API documentation can be found on [docs.casparser.in](https://docs.casparser.in/reference). The full API of this library can be found in [api.md](api.md). diff --git a/src/cas_parser/_base_client.py b/src/cas_parser/_base_client.py index 9cfe0c2..da01b6f 100644 --- a/src/cas_parser/_base_client.py +++ b/src/cas_parser/_base_client.py @@ -9,6 +9,7 @@ import inspect import logging import platform +import warnings import email.utils from types import TracebackType from random import random @@ -51,9 +52,11 @@ ResponseT, AnyMapping, PostParser, + BinaryTypes, RequestFiles, HttpxSendArgs, RequestOptions, + AsyncBinaryTypes, HttpxRequestFiles, ModelBuilderProtocol, not_given, @@ -477,8 +480,19 @@ def _build_request( retries_taken: int = 0, ) -> httpx.Request: if log.isEnabledFor(logging.DEBUG): - log.debug("Request options: %s", model_dump(options, exclude_unset=True)) - + log.debug( + "Request options: %s", + model_dump( + options, + exclude_unset=True, + # Pydantic v1 can't dump every type we support in content, so we exclude it for now. + exclude={ + "content", + } + if PYDANTIC_V1 + else {}, + ), + ) kwargs: dict[str, Any] = {} json_data = options.json_data @@ -532,7 +546,13 @@ def _build_request( is_body_allowed = options.method.lower() != "get" if is_body_allowed: - if isinstance(json_data, bytes): + if options.content is not None and json_data is not None: + raise TypeError("Passing both `content` and `json_data` is not supported") + if options.content is not None and files is not None: + raise TypeError("Passing both `content` and `files` is not supported") + if options.content is not None: + kwargs["content"] = options.content + elif isinstance(json_data, bytes): kwargs["content"] = json_data else: kwargs["json"] = json_data if is_given(json_data) else None @@ -1194,6 +1214,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[False] = False, @@ -1206,6 +1227,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[True], @@ -1219,6 +1241,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool, @@ -1231,13 +1254,25 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool = False, stream_cls: type[_StreamT] | None = None, ) -> ResponseT | _StreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=to_httpx_files(files), **options + method="post", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) @@ -1247,11 +1282,23 @@ def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + method="patch", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return self.request(cast_to, opts) @@ -1261,11 +1308,23 @@ def put( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=to_httpx_files(files), **options + method="put", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return self.request(cast_to, opts) @@ -1275,9 +1334,19 @@ def delete( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) return self.request(cast_to, opts) def get_api_list( @@ -1717,6 +1786,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[False] = False, @@ -1729,6 +1799,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[True], @@ -1742,6 +1813,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool, @@ -1754,13 +1826,25 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool = False, stream_cls: type[_AsyncStreamT] | None = None, ) -> ResponseT | _AsyncStreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="post", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) @@ -1770,11 +1854,28 @@ async def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="patch", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="patch", + url=path, + json_data=body, + content=content, + files=await async_to_httpx_files(files), + **options, ) return await self.request(cast_to, opts) @@ -1784,11 +1885,23 @@ async def put( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="put", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts) @@ -1798,9 +1911,19 @@ async def delete( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) return await self.request(cast_to, opts) def get_api_list( diff --git a/src/cas_parser/_models.py b/src/cas_parser/_models.py index ca9500b..29070e0 100644 --- a/src/cas_parser/_models.py +++ b/src/cas_parser/_models.py @@ -3,7 +3,20 @@ import os import inspect import weakref -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast +from typing import ( + IO, + TYPE_CHECKING, + Any, + Type, + Union, + Generic, + TypeVar, + Callable, + Iterable, + Optional, + AsyncIterable, + cast, +) from datetime import date, datetime from typing_extensions import ( List, @@ -787,6 +800,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): timeout: float | Timeout | None files: HttpxRequestFiles | None idempotency_key: str + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] json_data: Body extra_json: AnyMapping follow_redirects: bool @@ -805,6 +819,7 @@ class FinalRequestOptions(pydantic.BaseModel): post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() follow_redirects: Union[bool, None] = None + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None # It should be noted that we cannot use `json` here as that would override # a BaseModel method in an incompatible fashion. json_data: Union[Body, None] = None diff --git a/src/cas_parser/_types.py b/src/cas_parser/_types.py index 2c15258..3f7802c 100644 --- a/src/cas_parser/_types.py +++ b/src/cas_parser/_types.py @@ -13,9 +13,11 @@ Mapping, TypeVar, Callable, + Iterable, Iterator, Optional, Sequence, + AsyncIterable, ) from typing_extensions import ( Set, @@ -56,6 +58,13 @@ else: Base64FileInput = Union[IO[bytes], PathLike] FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8. + + +# Used for sending raw binary data / streaming data in request bodies +# e.g. for file uploads without multipart encoding +BinaryTypes = Union[bytes, bytearray, IO[bytes], Iterable[bytes]] +AsyncBinaryTypes = Union[bytes, bytearray, IO[bytes], AsyncIterable[bytes]] + FileTypes = Union[ # file (or bytes) FileContent, diff --git a/tests/test_client.py b/tests/test_client.py index 47523fb..6f70fd4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,10 +8,11 @@ import json import asyncio import inspect +import dataclasses import tracemalloc -from typing import Any, Union, cast +from typing import Any, Union, TypeVar, Callable, Iterable, Iterator, Optional, Coroutine, cast from unittest import mock -from typing_extensions import Literal +from typing_extensions import Literal, AsyncIterator, override import httpx import pytest @@ -36,6 +37,7 @@ from .utils import update_env +T = TypeVar("T") base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") api_key = "My API Key" @@ -50,6 +52,57 @@ def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float: return 0.1 +def mirror_request_content(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=request.content) + + +# note: we can't use the httpx.MockTransport class as it consumes the request +# body itself, which means we can't test that the body is read lazily +class MockTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): + def __init__( + self, + handler: Callable[[httpx.Request], httpx.Response] + | Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]], + ) -> None: + self.handler = handler + + @override + def handle_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert not inspect.iscoroutinefunction(self.handler), "handler must not be a coroutine function" + assert inspect.isfunction(self.handler), "handler must be a function" + return self.handler(request) + + @override + async def handle_async_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert inspect.iscoroutinefunction(self.handler), "handler must be a coroutine function" + return await self.handler(request) + + +@dataclasses.dataclass +class Counter: + value: int = 0 + + +def _make_sync_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> Iterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + +async def _make_async_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> AsyncIterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + def _get_open_connections(client: CasParser | AsyncCasParser) -> int: transport = client._client._transport assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) @@ -502,6 +555,70 @@ def test_multipart_repeating_array(self, client: CasParser) -> None: b"", ] + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload(self, respx_mock: MockRouter, client: CasParser) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + def test_binary_content_upload_with_iterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_sync_iterator([file_content], counter=counter) + + def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=request.read()) + + with CasParser( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(transport=MockTransport(handler=mock_handler)), + ) as client: + response = client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload_with_body_is_deprecated(self, respx_mock: MockRouter, client: CasParser) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + @pytest.mark.respx(base_url=base_url) def test_basic_union_response(self, respx_mock: MockRouter, client: CasParser) -> None: class Model1(BaseModel): @@ -1321,6 +1438,72 @@ def test_multipart_repeating_array(self, async_client: AsyncCasParser) -> None: b"", ] + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload(self, respx_mock: MockRouter, async_client: AsyncCasParser) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = await async_client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + async def test_binary_content_upload_with_asynciterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_async_iterator([file_content], counter=counter) + + async def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=await request.aread()) + + async with AsyncCasParser( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(transport=MockTransport(handler=mock_handler)), + ) as client: + response = await client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload_with_body_is_deprecated( + self, respx_mock: MockRouter, async_client: AsyncCasParser + ) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = await async_client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + @pytest.mark.respx(base_url=base_url) async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncCasParser) -> None: class Model1(BaseModel): From 12b2bedb85b2456a73880694e12d2fc243d3d438 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 02:17:48 +0000 Subject: [PATCH 15/15] release: 1.2.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/cas_parser/_version.py | 2 +- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2601677..d0ab664 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.1.0" + ".": "1.2.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index eff9d05..156d0dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 1.2.0 (2026-01-20) + +Full Changelog: [v1.1.0...v1.2.0](https://github.com/CASParser/cas-parser-python/compare/v1.1.0...v1.2.0) + +### Features + +* **api:** api update ([93a9613](https://github.com/CASParser/cas-parser-python/commit/93a9613c79ec70869cf11dd0b9bac0a8c6194a31)) +* **api:** api update ([bd6977a](https://github.com/CASParser/cas-parser-python/commit/bd6977a8a78c4a1633e4e6a1dc1d3335b1aa6611)) +* **api:** api update ([3fda81d](https://github.com/CASParser/cas-parser-python/commit/3fda81deb938a9b689cbb04f839e3b815259a9c5)) +* **api:** api update ([f1838dc](https://github.com/CASParser/cas-parser-python/commit/f1838dcb901635626cc87cb55dfaa4ef33ba5092)) + + +### Bug Fixes + +* **client:** close streams without requiring full consumption ([7090ef5](https://github.com/CASParser/cas-parser-python/commit/7090ef51af296fa6d6be8af8137543ef2023cbd7)) + + +### Chores + +* bump `httpx-aiohttp` version to 0.1.9 ([e1b65fb](https://github.com/CASParser/cas-parser-python/commit/e1b65fb2bd146a68ef50438899406ae2fb6178c3)) +* do not install brew dependencies in ./scripts/bootstrap by default ([35b17eb](https://github.com/CASParser/cas-parser-python/commit/35b17eb26264ab66e24b074bcb1790f6c33b7b9c)) +* **internal/tests:** avoid race condition with implicit client cleanup ([2a58fc0](https://github.com/CASParser/cas-parser-python/commit/2a58fc0e260b52ee314ac6d14676b2140711bd0b)) +* **internal:** codegen related update ([8e6c5b2](https://github.com/CASParser/cas-parser-python/commit/8e6c5b210e14602af113fa9fef5c789d6238419a)) +* **internal:** codegen related update ([20bcea0](https://github.com/CASParser/cas-parser-python/commit/20bcea057ce1974149394c899581ed31ffb56a4a)) +* **internal:** detect missing future annotations with ruff ([8c35489](https://github.com/CASParser/cas-parser-python/commit/8c354893c00887af1da9c197dc21dd4d6f0033af)) +* **internal:** grammar fix (it's -> its) ([d2d29bc](https://github.com/CASParser/cas-parser-python/commit/d2d29bcc46989573e27c2178785c6b38df65bd90)) +* **internal:** update pydantic dependency ([1c3104b](https://github.com/CASParser/cas-parser-python/commit/1c3104b27350f4c906973bb56f89d5a16f55d35e)) +* **types:** change optional parameter type from NotGiven to Omit ([e739e12](https://github.com/CASParser/cas-parser-python/commit/e739e12ade4f91e52f0285c866354e970195aacf)) + ## 1.1.0 (2025-09-06) Full Changelog: [v1.0.2...v1.1.0](https://github.com/CASParser/cas-parser-python/compare/v1.0.2...v1.1.0) diff --git a/pyproject.toml b/pyproject.toml index f286b5e..d00318b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cas-parser-python" -version = "1.1.0" +version = "1.2.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 69821a2..6ae1318 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.1.0" # x-release-please-version +__version__ = "1.2.0" # x-release-please-version