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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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 dbddecff0095788ec44725b674f990b7e5aa1754 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:49 +0000 Subject: [PATCH 13/13] release: 1.2.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 27 +++++++++++++++++++++++++++ pyproject.toml | 2 +- src/cas_parser/_version.py | 2 +- 4 files changed, 30 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..15c3699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 1.2.0 (2026-01-01) + +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 ([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