diff --git a/.fern/metadata.json b/.fern/metadata.json index f64bd97..dfdcaa5 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -1,7 +1,7 @@ { - "cliVersion": "5.6.0", + "cliVersion": "5.50.0", "generatorName": "fernapi/fern-python-sdk", - "generatorVersion": "4.64.1", + "generatorVersion": "5.14.20", "generatorConfig": { "inline_request_params": true, "client": { @@ -23,6 +23,10 @@ } ] }, - "originGitCommit": "73dfd3abce414c6aab288b3bc0e265f30c14f821", - "sdkVersion": "1.2.8" + "originGitCommit": "ea3c4e4fae960ffc12f83f0a22a210732f454d33", + "originGitCommitIsDirty": false, + "invokedBy": "ci", + "requestedVersion": "1.2.9", + "ciProvider": "github", + "sdkVersion": "1.2.9" } \ No newline at end of file diff --git a/.fern/replay.lock b/.fern/replay.lock index 7435f5a..8bd3f28 100644 --- a/.fern/replay.lock +++ b/.fern/replay.lock @@ -12,5 +12,11 @@ generations: cli_version: unknown generator_versions: fernapi/fern-python-sdk: 4.64.1 -current_generation: 35cd99d8ce4b3a7e348b322eccec390797b1d9d2 + - commit_sha: 9e97b8776e7cbe6801ef34317379ce3d209efc97 + tree_hash: 7fdc8da45ab09537df41245f56308091cad8c7d9 + timestamp: 2026-07-13T20:49:23.249Z + cli_version: unknown + generator_versions: + fernapi/fern-python-sdk: 5.14.20 +current_generation: 9e97b8776e7cbe6801ef34317379ce3d209efc97 patches: [] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..af948ce --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,125 @@ +# Contributing + +Thanks for your interest in contributing to this SDK! This document provides guidelines for contributing to the project. + +## Getting Started + +### Prerequisites + +- Python 3.9+ +- pip +- poetry + +### Installation + +Install the project dependencies: + +```bash +poetry install +``` + +### Building + +Build the project: + +```bash +poetry build +``` + +### Testing + +Run the test suite: + +```bash +poetry run pytest +``` + +### Linting and Formatting + +Check code style: + +```bash +poetry run ruff check . +poetry run ruff format . +``` + +### Type Checking + +Run the type checker: + +```bash +poetry run mypy . +``` + +## About Generated Code + +**Important**: Most files in this SDK are automatically generated by [Fern](https://buildwithfern.com) from the API definition. Direct modifications to generated files will be overwritten the next time the SDK is generated. + +### Generated Files + +The following directories contain generated code: +- `src/` - API client classes and types +- Most Python files in the project + +### How to Customize + +If you need to customize the SDK, you have two options: + +#### Option 1: Use `.fernignore` + +For custom code that should persist across SDK regenerations: + +1. Create a `.fernignore` file in the project root +2. Add file patterns for files you want to preserve (similar to `.gitignore` syntax) +3. Add your custom code to those files + +Files listed in `.fernignore` will not be overwritten when the SDK is regenerated. + +For more information, see the [Fern documentation on custom code](https://buildwithfern.com/learn/sdks/overview/custom-code). + +#### Option 2: Contribute to the Generator + +If you want to change how code is generated for all users of this SDK: + +1. The Python SDK generator lives in the [Fern repository](https://github.com/fern-api/fern) +2. Generator code is located at `generators/python-v2/` +3. Follow the [Fern contributing guidelines](https://github.com/fern-api/fern/blob/main/CONTRIBUTING.md) +4. Submit a pull request with your changes to the generator + +This approach is best for: +- Bug fixes in generated code +- New features that would benefit all users +- Improvements to code generation patterns + +## Making Changes + +### Workflow + +1. Create a new branch for your changes +2. Make your modifications +3. Run tests to ensure nothing breaks: `poetry run pytest` +4. Run linting and formatting: `poetry run ruff check .` and `poetry run ruff format .` +5. Run type checking: `poetry run mypy .` +6. Build the project: `poetry build` +7. Commit your changes with a clear commit message +8. Push your branch and create a pull request + +### Commit Messages + +Write clear, descriptive commit messages that explain what changed and why. + +### Code Style + +This project uses automated code formatting and linting. Run `poetry run ruff format .` and `poetry run ruff check .` before committing to ensure your code meets the project's style guidelines. + +## Questions or Issues? + +If you have questions or run into issues: + +1. Check the [Fern documentation](https://buildwithfern.com) +2. Search existing [GitHub issues](https://github.com/fern-api/fern/issues) +3. Open a new issue if your question hasn't been addressed + +## License + +By contributing to this project, you agree that your contributions will be licensed under the same license as the project. diff --git a/pyproject.toml b/pyproject.toml index f26ef18..7937418 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ dynamic = ["version"] [tool.poetry] name = "schematichq" -version = "1.2.8" +version = "1.2.9" description = "" readme = "README.md" authors = [] diff --git a/requirements.txt b/requirements.txt index e80f640..443b1af 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ httpx>=0.21.2 pydantic>= 1.9.2 -pydantic-core>=2.18.2 +pydantic-core>=2.18.2,<3.0.0 typing_extensions>= 4.0.0 diff --git a/src/schematic/__init__.py b/src/schematic/__init__.py index 2eea406..1551ca5 100644 --- a/src/schematic/__init__.py +++ b/src/schematic/__init__.py @@ -422,6 +422,7 @@ scheduledcheckout, webhooks, ) + from ._default_clients import DefaultAioHttpClient, DefaultAsyncHttpxClient from .accesstokens import IssueTemporaryAccessTokenResponse from .accounts import ( CountAccountMembersParams, @@ -1038,6 +1039,8 @@ "DataExportStatus": ".types", "DataExportType": ".types", "DatastreamCompanyPlan": ".types", + "DefaultAioHttpClient": "._default_clients", + "DefaultAsyncHttpxClient": "._default_clients", "DeleteApiKeyResponse": ".accounts", "DeleteBillingCouponResponse": ".billing", "DeleteBillingCustomerResponse": ".billing", @@ -1815,6 +1818,8 @@ def __dir__(): "DataExportStatus", "DataExportType", "DatastreamCompanyPlan", + "DefaultAioHttpClient", + "DefaultAsyncHttpxClient", "DeleteApiKeyResponse", "DeleteBillingCouponResponse", "DeleteBillingCustomerResponse", diff --git a/src/schematic/_default_clients.py b/src/schematic/_default_clients.py new file mode 100644 index 0000000..51b434f --- /dev/null +++ b/src/schematic/_default_clients.py @@ -0,0 +1,30 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import httpx + +SDK_DEFAULT_TIMEOUT = 60 + +try: + import httpx_aiohttp # type: ignore[import-not-found] +except ImportError: + + class DefaultAioHttpClient(httpx.AsyncClient): # type: ignore + def __init__(self, **kwargs: typing.Any) -> None: + raise RuntimeError("To use the aiohttp client, install the aiohttp extra: pip install schematichq[aiohttp]") + +else: + + class DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore + def __init__(self, **kwargs: typing.Any) -> None: + kwargs.setdefault("timeout", SDK_DEFAULT_TIMEOUT) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) + + +class DefaultAsyncHttpxClient(httpx.AsyncClient): + def __init__(self, **kwargs: typing.Any) -> None: + kwargs.setdefault("timeout", SDK_DEFAULT_TIMEOUT) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) diff --git a/src/schematic/accounts/raw_client.py b/src/schematic/accounts/raw_client.py index 8e3bf7d..508bde5 100644 --- a/src/schematic/accounts/raw_client.py +++ b/src/schematic/accounts/raw_client.py @@ -8,7 +8,7 @@ from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.datetime_utils import serialize_datetime from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -187,7 +187,7 @@ def get_account_member( OK """ _response = self._client_wrapper.httpx_client.request( - f"account-members/{jsonable_encoder(account_member_id)}", + f"account-members/{encode_path_param(account_member_id)}", method="GET", request_options=request_options, ) @@ -636,7 +636,7 @@ def get_api_key( OK """ _response = self._client_wrapper.httpx_client.request( - f"api-keys/{jsonable_encoder(api_key_id)}", + f"api-keys/{encode_path_param(api_key_id)}", method="GET", request_options=request_options, ) @@ -734,7 +734,7 @@ def update_api_key( OK """ _response = self._client_wrapper.httpx_client.request( - f"api-keys/{jsonable_encoder(api_key_id)}", + f"api-keys/{encode_path_param(api_key_id)}", method="PUT", json={ "description": description, @@ -842,7 +842,7 @@ def delete_api_key( OK """ _response = self._client_wrapper.httpx_client.request( - f"api-keys/{jsonable_encoder(api_key_id)}", + f"api-keys/{encode_path_param(api_key_id)}", method="DELETE", request_options=request_options, ) @@ -1192,7 +1192,7 @@ def get_audit_log( OK """ _response = self._client_wrapper.httpx_client.request( - f"audit-log/{jsonable_encoder(audit_log_id)}", + f"audit-log/{encode_path_param(audit_log_id)}", method="GET", request_options=request_options, ) @@ -1636,7 +1636,7 @@ def get_environment( OK """ _response = self._client_wrapper.httpx_client.request( - f"environments/{jsonable_encoder(environment_id)}", + f"environments/{encode_path_param(environment_id)}", method="GET", request_options=request_options, ) @@ -1734,7 +1734,7 @@ def update_environment( OK """ _response = self._client_wrapper.httpx_client.request( - f"environments/{jsonable_encoder(environment_id)}", + f"environments/{encode_path_param(environment_id)}", method="PUT", json={ "environment_type": environment_type, @@ -1842,7 +1842,7 @@ def delete_environment( OK """ _response = self._client_wrapper.httpx_client.request( - f"environments/{jsonable_encoder(environment_id)}", + f"environments/{encode_path_param(environment_id)}", method="DELETE", request_options=request_options, ) @@ -2250,7 +2250,7 @@ async def get_account_member( OK """ _response = await self._client_wrapper.httpx_client.request( - f"account-members/{jsonable_encoder(account_member_id)}", + f"account-members/{encode_path_param(account_member_id)}", method="GET", request_options=request_options, ) @@ -2699,7 +2699,7 @@ async def get_api_key( OK """ _response = await self._client_wrapper.httpx_client.request( - f"api-keys/{jsonable_encoder(api_key_id)}", + f"api-keys/{encode_path_param(api_key_id)}", method="GET", request_options=request_options, ) @@ -2797,7 +2797,7 @@ async def update_api_key( OK """ _response = await self._client_wrapper.httpx_client.request( - f"api-keys/{jsonable_encoder(api_key_id)}", + f"api-keys/{encode_path_param(api_key_id)}", method="PUT", json={ "description": description, @@ -2905,7 +2905,7 @@ async def delete_api_key( OK """ _response = await self._client_wrapper.httpx_client.request( - f"api-keys/{jsonable_encoder(api_key_id)}", + f"api-keys/{encode_path_param(api_key_id)}", method="DELETE", request_options=request_options, ) @@ -3255,7 +3255,7 @@ async def get_audit_log( OK """ _response = await self._client_wrapper.httpx_client.request( - f"audit-log/{jsonable_encoder(audit_log_id)}", + f"audit-log/{encode_path_param(audit_log_id)}", method="GET", request_options=request_options, ) @@ -3699,7 +3699,7 @@ async def get_environment( OK """ _response = await self._client_wrapper.httpx_client.request( - f"environments/{jsonable_encoder(environment_id)}", + f"environments/{encode_path_param(environment_id)}", method="GET", request_options=request_options, ) @@ -3797,7 +3797,7 @@ async def update_environment( OK """ _response = await self._client_wrapper.httpx_client.request( - f"environments/{jsonable_encoder(environment_id)}", + f"environments/{encode_path_param(environment_id)}", method="PUT", json={ "environment_type": environment_type, @@ -3905,7 +3905,7 @@ async def delete_environment( OK """ _response = await self._client_wrapper.httpx_client.request( - f"environments/{jsonable_encoder(environment_id)}", + f"environments/{encode_path_param(environment_id)}", method="DELETE", request_options=request_options, ) diff --git a/src/schematic/base_client.py b/src/schematic/base_client.py index 0a0a5c3..d07a74f 100644 --- a/src/schematic/base_client.py +++ b/src/schematic/base_client.py @@ -59,6 +59,9 @@ class BaseSchematic: timeout : typing.Optional[float] The timeout to be used, in seconds, for requests. By default the timeout is 60 seconds, unless a custom httpx client is used, in which case this default is not enforced. + max_retries : typing.Optional[int] + The default maximum number of retries for failed requests. Defaults to 2. Per-request `max_retries` in `request_options` takes precedence over this value. + follow_redirects : typing.Optional[bool] Whether the default httpx client follows redirects or not, this is irrelevant if a custom httpx client is passed in. @@ -85,6 +88,7 @@ def __init__( api_key: str, headers: typing.Optional[typing.Dict[str, str]] = None, timeout: typing.Optional[float] = None, + max_retries: typing.Optional[int] = None, follow_redirects: typing.Optional[bool] = True, httpx_client: typing.Optional[httpx.Client] = None, logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, @@ -92,6 +96,7 @@ def __init__( _defaulted_timeout = ( timeout if timeout is not None else 60 if httpx_client is None else httpx_client.timeout.read ) + _defaulted_max_retries = max_retries if max_retries is not None else 2 self._client_wrapper = SyncClientWrapper( base_url=_get_base_url(base_url=base_url, environment=environment), api_key=api_key, @@ -102,6 +107,7 @@ def __init__( if follow_redirects is not None else httpx.Client(timeout=_defaulted_timeout), timeout=_defaulted_timeout, + max_retries=_defaulted_max_retries, logging=logging, ) self._raw_client = RawBaseSchematic(client_wrapper=self._client_wrapper) @@ -321,6 +327,24 @@ def webhooks(self): return self._webhooks +def _make_default_async_client( + timeout: typing.Optional[float], + follow_redirects: typing.Optional[bool], +) -> httpx.AsyncClient: + try: + import httpx_aiohttp # type: ignore[import-not-found] + except ImportError: + pass + else: + if follow_redirects is not None: + return httpx_aiohttp.HttpxAiohttpClient(timeout=timeout, follow_redirects=follow_redirects) + return httpx_aiohttp.HttpxAiohttpClient(timeout=timeout) + + if follow_redirects is not None: + return httpx.AsyncClient(timeout=timeout, follow_redirects=follow_redirects) + return httpx.AsyncClient(timeout=timeout) + + class AsyncBaseSchematic: """ Use this class to access the different functions within the SDK. You can instantiate any number of clients with different configuration that will propagate to these functions. @@ -346,6 +370,9 @@ class AsyncBaseSchematic: timeout : typing.Optional[float] The timeout to be used, in seconds, for requests. By default the timeout is 60 seconds, unless a custom httpx client is used, in which case this default is not enforced. + max_retries : typing.Optional[int] + The default maximum number of retries for failed requests. Defaults to 2. Per-request `max_retries` in `request_options` takes precedence over this value. + follow_redirects : typing.Optional[bool] Whether the default httpx client follows redirects or not, this is irrelevant if a custom httpx client is passed in. @@ -372,6 +399,7 @@ def __init__( api_key: str, headers: typing.Optional[typing.Dict[str, str]] = None, timeout: typing.Optional[float] = None, + max_retries: typing.Optional[int] = None, follow_redirects: typing.Optional[bool] = True, httpx_client: typing.Optional[httpx.AsyncClient] = None, logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, @@ -379,16 +407,16 @@ def __init__( _defaulted_timeout = ( timeout if timeout is not None else 60 if httpx_client is None else httpx_client.timeout.read ) + _defaulted_max_retries = max_retries if max_retries is not None else 2 self._client_wrapper = AsyncClientWrapper( base_url=_get_base_url(base_url=base_url, environment=environment), api_key=api_key, headers=headers, httpx_client=httpx_client if httpx_client is not None - else httpx.AsyncClient(timeout=_defaulted_timeout, follow_redirects=follow_redirects) - if follow_redirects is not None - else httpx.AsyncClient(timeout=_defaulted_timeout), + else _make_default_async_client(timeout=_defaulted_timeout, follow_redirects=follow_redirects), timeout=_defaulted_timeout, + max_retries=_defaulted_max_retries, logging=logging, ) self._raw_client = AsyncRawBaseSchematic(client_wrapper=self._client_wrapper) diff --git a/src/schematic/billing/raw_client.py b/src/schematic/billing/raw_client.py index 7ca819e..abbc0c3 100644 --- a/src/schematic/billing/raw_client.py +++ b/src/schematic/billing/raw_client.py @@ -7,7 +7,7 @@ from ..core.api_error import ApiError as core_api_error_ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -339,7 +339,7 @@ def delete_billing_coupon( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/coupons/{jsonable_encoder(billing_id)}", + f"billing/coupons/{encode_path_param(billing_id)}", method="DELETE", request_options=request_options, ) @@ -439,7 +439,7 @@ def delete_billing_customer( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/customer/{jsonable_encoder(billing_id)}", + f"billing/customer/{encode_path_param(billing_id)}", method="DELETE", request_options=request_options, ) @@ -1206,7 +1206,7 @@ def delete_billing_invoice( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/invoices/{jsonable_encoder(billing_id)}", + f"billing/invoices/{encode_path_param(billing_id)}", method="DELETE", request_options=request_options, ) @@ -1818,7 +1818,7 @@ def delete_payment_method_by_external_id( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/payment-methods/{jsonable_encoder(billing_id)}", + f"billing/payment-methods/{encode_path_param(billing_id)}", method="DELETE", request_options=request_options, ) @@ -2272,7 +2272,7 @@ def delete_billing_product( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/product/{jsonable_encoder(billing_id)}", + f"billing/product/{encode_path_param(billing_id)}", method="DELETE", request_options=request_options, ) @@ -2549,7 +2549,7 @@ def delete_product_price( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/product/prices/{jsonable_encoder(billing_id)}", + f"billing/product/prices/{encode_path_param(billing_id)}", method="DELETE", request_options=request_options, ) @@ -3554,7 +3554,7 @@ async def delete_billing_coupon( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/coupons/{jsonable_encoder(billing_id)}", + f"billing/coupons/{encode_path_param(billing_id)}", method="DELETE", request_options=request_options, ) @@ -3654,7 +3654,7 @@ async def delete_billing_customer( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/customer/{jsonable_encoder(billing_id)}", + f"billing/customer/{encode_path_param(billing_id)}", method="DELETE", request_options=request_options, ) @@ -4421,7 +4421,7 @@ async def delete_billing_invoice( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/invoices/{jsonable_encoder(billing_id)}", + f"billing/invoices/{encode_path_param(billing_id)}", method="DELETE", request_options=request_options, ) @@ -5033,7 +5033,7 @@ async def delete_payment_method_by_external_id( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/payment-methods/{jsonable_encoder(billing_id)}", + f"billing/payment-methods/{encode_path_param(billing_id)}", method="DELETE", request_options=request_options, ) @@ -5487,7 +5487,7 @@ async def delete_billing_product( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/product/{jsonable_encoder(billing_id)}", + f"billing/product/{encode_path_param(billing_id)}", method="DELETE", request_options=request_options, ) @@ -5764,7 +5764,7 @@ async def delete_product_price( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/product/prices/{jsonable_encoder(billing_id)}", + f"billing/product/prices/{encode_path_param(billing_id)}", method="DELETE", request_options=request_options, ) diff --git a/src/schematic/checkout/raw_client.py b/src/schematic/checkout/raw_client.py index cce65b0..7d9b8e2 100644 --- a/src/schematic/checkout/raw_client.py +++ b/src/schematic/checkout/raw_client.py @@ -7,7 +7,7 @@ from ..core.api_error import ApiError as core_api_error_ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -1003,7 +1003,7 @@ def update_customer_subscription_trial_end( OK """ _response = self._client_wrapper.httpx_client.request( - f"subscription/{jsonable_encoder(subscription_id)}/edit-trial-end", + f"subscription/{encode_path_param(subscription_id)}/edit-trial-end", method="PUT", json={ "trial_end": trial_end, @@ -2059,7 +2059,7 @@ async def update_customer_subscription_trial_end( OK """ _response = await self._client_wrapper.httpx_client.request( - f"subscription/{jsonable_encoder(subscription_id)}/edit-trial-end", + f"subscription/{encode_path_param(subscription_id)}/edit-trial-end", method="PUT", json={ "trial_end": trial_end, diff --git a/src/schematic/companies/raw_client.py b/src/schematic/companies/raw_client.py index 46d70e8..8a8d119 100644 --- a/src/schematic/companies/raw_client.py +++ b/src/schematic/companies/raw_client.py @@ -7,7 +7,7 @@ from ..core.api_error import ApiError as core_api_error_ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -431,7 +431,7 @@ def get_company( OK """ _response = self._client_wrapper.httpx_client.request( - f"companies/{jsonable_encoder(company_id)}", + f"companies/{encode_path_param(company_id)}", method="GET", request_options=request_options, ) @@ -529,7 +529,7 @@ def delete_company( OK """ _response = self._client_wrapper.httpx_client.request( - f"companies/{jsonable_encoder(company_id)}", + f"companies/{encode_path_param(company_id)}", method="DELETE", params={ "cancel_subscription": cancel_subscription, @@ -1608,7 +1608,7 @@ def delete_company_membership( OK """ _response = self._client_wrapper.httpx_client.request( - f"company-memberships/{jsonable_encoder(company_membership_id)}", + f"company-memberships/{encode_path_param(company_membership_id)}", method="DELETE", request_options=request_options, ) @@ -2455,7 +2455,7 @@ def get_entity_trait_definition( OK """ _response = self._client_wrapper.httpx_client.request( - f"entity-trait-definitions/{jsonable_encoder(entity_trait_definition_id)}", + f"entity-trait-definitions/{encode_path_param(entity_trait_definition_id)}", method="GET", request_options=request_options, ) @@ -2553,7 +2553,7 @@ def update_entity_trait_definition( OK """ _response = self._client_wrapper.httpx_client.request( - f"entity-trait-definitions/{jsonable_encoder(entity_trait_definition_id)}", + f"entity-trait-definitions/{encode_path_param(entity_trait_definition_id)}", method="PUT", json={ "display_name": display_name, @@ -3042,7 +3042,7 @@ def get_plan_change( OK """ _response = self._client_wrapper.httpx_client.request( - f"plan-changes/{jsonable_encoder(plan_change_id)}", + f"plan-changes/{encode_path_param(plan_change_id)}", method="GET", request_options=request_options, ) @@ -3258,7 +3258,7 @@ def get_plan_trait( OK """ _response = self._client_wrapper.httpx_client.request( - f"plan-traits/{jsonable_encoder(plan_trait_id)}", + f"plan-traits/{encode_path_param(plan_trait_id)}", method="GET", request_options=request_options, ) @@ -4006,7 +4006,7 @@ def get_user( OK """ _response = self._client_wrapper.httpx_client.request( - f"users/{jsonable_encoder(user_id)}", + f"users/{encode_path_param(user_id)}", method="GET", request_options=request_options, ) @@ -4095,7 +4095,7 @@ def delete_user( OK """ _response = self._client_wrapper.httpx_client.request( - f"users/{jsonable_encoder(user_id)}", + f"users/{encode_path_param(user_id)}", method="DELETE", request_options=request_options, ) @@ -5020,7 +5020,7 @@ async def get_company( OK """ _response = await self._client_wrapper.httpx_client.request( - f"companies/{jsonable_encoder(company_id)}", + f"companies/{encode_path_param(company_id)}", method="GET", request_options=request_options, ) @@ -5118,7 +5118,7 @@ async def delete_company( OK """ _response = await self._client_wrapper.httpx_client.request( - f"companies/{jsonable_encoder(company_id)}", + f"companies/{encode_path_param(company_id)}", method="DELETE", params={ "cancel_subscription": cancel_subscription, @@ -6197,7 +6197,7 @@ async def delete_company_membership( OK """ _response = await self._client_wrapper.httpx_client.request( - f"company-memberships/{jsonable_encoder(company_membership_id)}", + f"company-memberships/{encode_path_param(company_membership_id)}", method="DELETE", request_options=request_options, ) @@ -7044,7 +7044,7 @@ async def get_entity_trait_definition( OK """ _response = await self._client_wrapper.httpx_client.request( - f"entity-trait-definitions/{jsonable_encoder(entity_trait_definition_id)}", + f"entity-trait-definitions/{encode_path_param(entity_trait_definition_id)}", method="GET", request_options=request_options, ) @@ -7142,7 +7142,7 @@ async def update_entity_trait_definition( OK """ _response = await self._client_wrapper.httpx_client.request( - f"entity-trait-definitions/{jsonable_encoder(entity_trait_definition_id)}", + f"entity-trait-definitions/{encode_path_param(entity_trait_definition_id)}", method="PUT", json={ "display_name": display_name, @@ -7631,7 +7631,7 @@ async def get_plan_change( OK """ _response = await self._client_wrapper.httpx_client.request( - f"plan-changes/{jsonable_encoder(plan_change_id)}", + f"plan-changes/{encode_path_param(plan_change_id)}", method="GET", request_options=request_options, ) @@ -7847,7 +7847,7 @@ async def get_plan_trait( OK """ _response = await self._client_wrapper.httpx_client.request( - f"plan-traits/{jsonable_encoder(plan_trait_id)}", + f"plan-traits/{encode_path_param(plan_trait_id)}", method="GET", request_options=request_options, ) @@ -8595,7 +8595,7 @@ async def get_user( OK """ _response = await self._client_wrapper.httpx_client.request( - f"users/{jsonable_encoder(user_id)}", + f"users/{encode_path_param(user_id)}", method="GET", request_options=request_options, ) @@ -8684,7 +8684,7 @@ async def delete_user( OK """ _response = await self._client_wrapper.httpx_client.request( - f"users/{jsonable_encoder(user_id)}", + f"users/{encode_path_param(user_id)}", method="DELETE", request_options=request_options, ) diff --git a/src/schematic/components/raw_client.py b/src/schematic/components/raw_client.py index 25ea2d5..4857021 100644 --- a/src/schematic/components/raw_client.py +++ b/src/schematic/components/raw_client.py @@ -6,7 +6,7 @@ from ..core.api_error import ApiError as core_api_error_ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -285,7 +285,7 @@ def get_component( OK """ _response = self._client_wrapper.httpx_client.request( - f"components/{jsonable_encoder(component_id)}", + f"components/{encode_path_param(component_id)}", method="GET", request_options=request_options, ) @@ -389,7 +389,7 @@ def update_component( OK """ _response = self._client_wrapper.httpx_client.request( - f"components/{jsonable_encoder(component_id)}", + f"components/{encode_path_param(component_id)}", method="PUT", json={ "ast": ast, @@ -499,7 +499,7 @@ def delete_component( OK """ _response = self._client_wrapper.httpx_client.request( - f"components/{jsonable_encoder(component_id)}", + f"components/{encode_path_param(component_id)}", method="DELETE", request_options=request_options, ) @@ -1049,7 +1049,7 @@ async def get_component( OK """ _response = await self._client_wrapper.httpx_client.request( - f"components/{jsonable_encoder(component_id)}", + f"components/{encode_path_param(component_id)}", method="GET", request_options=request_options, ) @@ -1153,7 +1153,7 @@ async def update_component( OK """ _response = await self._client_wrapper.httpx_client.request( - f"components/{jsonable_encoder(component_id)}", + f"components/{encode_path_param(component_id)}", method="PUT", json={ "ast": ast, @@ -1263,7 +1263,7 @@ async def delete_component( OK """ _response = await self._client_wrapper.httpx_client.request( - f"components/{jsonable_encoder(component_id)}", + f"components/{encode_path_param(component_id)}", method="DELETE", request_options=request_options, ) diff --git a/src/schematic/core/__init__.py b/src/schematic/core/__init__.py index 4fb6e12..5bc159a 100644 --- a/src/schematic/core/__init__.py +++ b/src/schematic/core/__init__.py @@ -12,7 +12,7 @@ from .file import File, convert_file_dict_to_httpx_tuples, with_content_type from .http_client import AsyncHttpClient, HttpClient from .http_response import AsyncHttpResponse, HttpResponse - from .jsonable_encoder import jsonable_encoder + from .jsonable_encoder import encode_path_param, jsonable_encoder from .logging import ConsoleLogger, ILogger, LogConfig, LogLevel, Logger, create_logger from .parse_error import ParsingError from .pydantic_utilities import ( @@ -53,6 +53,7 @@ "convert_and_respect_annotation_metadata": ".serialization", "convert_file_dict_to_httpx_tuples": ".file", "create_logger": ".logging", + "encode_path_param": ".jsonable_encoder", "encode_query": ".query_encoder", "jsonable_encoder": ".jsonable_encoder", "parse_obj_as": ".pydantic_utilities", @@ -112,6 +113,7 @@ def __dir__(): "convert_and_respect_annotation_metadata", "convert_file_dict_to_httpx_tuples", "create_logger", + "encode_path_param", "encode_query", "jsonable_encoder", "parse_obj_as", diff --git a/src/schematic/core/client_wrapper.py b/src/schematic/core/client_wrapper.py index beba213..83065ca 100644 --- a/src/schematic/core/client_wrapper.py +++ b/src/schematic/core/client_wrapper.py @@ -15,24 +15,26 @@ def __init__( headers: typing.Optional[typing.Dict[str, str]] = None, base_url: str, timeout: typing.Optional[float] = None, + max_retries: int = 2, logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, ): self.api_key = api_key self._headers = headers self._base_url = base_url self._timeout = timeout + self._max_retries = max_retries self._logging = logging def get_headers(self) -> typing.Dict[str, str]: import platform headers: typing.Dict[str, str] = { - "User-Agent": "schematichq/1.2.8", + "User-Agent": "schematichq/1.2.9", "X-Fern-Language": "Python", "X-Fern-Runtime": f"python/{platform.python_version()}", "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", "X-Fern-SDK-Name": "schematichq", - "X-Fern-SDK-Version": "1.2.8", + "X-Fern-SDK-Version": "1.2.9", **(self.get_custom_headers() or {}), } headers["X-Schematic-Api-Key"] = self.api_key @@ -47,6 +49,9 @@ def get_base_url(self) -> str: def get_timeout(self) -> typing.Optional[float]: return self._timeout + def get_max_retries(self) -> int: + return self._max_retries + class SyncClientWrapper(BaseClientWrapper): def __init__( @@ -56,15 +61,24 @@ def __init__( headers: typing.Optional[typing.Dict[str, str]] = None, base_url: str, timeout: typing.Optional[float] = None, + max_retries: int = 2, logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, httpx_client: httpx.Client, ): - super().__init__(api_key=api_key, headers=headers, base_url=base_url, timeout=timeout, logging=logging) + super().__init__( + api_key=api_key, + headers=headers, + base_url=base_url, + timeout=timeout, + max_retries=max_retries, + logging=logging, + ) self.httpx_client = HttpClient( httpx_client=httpx_client, base_headers=self.get_headers, base_timeout=self.get_timeout, base_url=self.get_base_url, + base_max_retries=self.get_max_retries(), logging_config=self._logging, ) @@ -77,17 +91,26 @@ def __init__( headers: typing.Optional[typing.Dict[str, str]] = None, base_url: str, timeout: typing.Optional[float] = None, + max_retries: int = 2, logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, async_token: typing.Optional[typing.Callable[[], typing.Awaitable[str]]] = None, httpx_client: httpx.AsyncClient, ): - super().__init__(api_key=api_key, headers=headers, base_url=base_url, timeout=timeout, logging=logging) + super().__init__( + api_key=api_key, + headers=headers, + base_url=base_url, + timeout=timeout, + max_retries=max_retries, + logging=logging, + ) self._async_token = async_token self.httpx_client = AsyncHttpClient( httpx_client=httpx_client, base_headers=self.get_headers, base_timeout=self.get_timeout, base_url=self.get_base_url, + base_max_retries=self.get_max_retries(), async_base_headers=self.async_get_headers, logging_config=self._logging, ) diff --git a/src/schematic/core/http_client.py b/src/schematic/core/http_client.py index ee93758..f686c57 100644 --- a/src/schematic/core/http_client.py +++ b/src/schematic/core/http_client.py @@ -118,9 +118,14 @@ def _retry_timeout(response: httpx.Response, retries: int) -> float: return _add_symmetric_jitter(backoff) +def _retry_timeout_from_retries(retries: int) -> float: + """Determine retry timeout using exponential backoff when no response is available.""" + backoff = min(INITIAL_RETRY_DELAY_SECONDS * pow(2.0, retries), MAX_RETRY_DELAY_SECONDS) + return _add_symmetric_jitter(backoff) + + def _should_retry(response: httpx.Response) -> bool: - retryable_400s = [429, 408, 409] - return response.status_code >= 500 or response.status_code in retryable_400s + return response.status_code >= 500 or response.status_code in [429, 408, 409] _SENSITIVE_HEADERS = frozenset( @@ -265,11 +270,13 @@ def __init__( base_timeout: typing.Callable[[], typing.Optional[float]], base_headers: typing.Callable[[], typing.Dict[str, str]], base_url: typing.Optional[typing.Callable[[], str]] = None, + base_max_retries: int = 2, logging_config: typing.Optional[typing.Union[LogConfig, Logger]] = None, ): self.base_url = base_url self.base_timeout = base_timeout self.base_headers = base_headers + self.base_max_retries = base_max_retries self.httpx_client = httpx_client self.logger = create_logger(logging_config) @@ -364,19 +371,44 @@ def request( has_body=json_body is not None or data_body is not None, ) - response = self.httpx_client.request( - method=method, - url=_request_url, - headers=_request_headers, - params=_encoded_params if _encoded_params else None, - json=json_body, - data=data_body, - content=content, - files=request_files, - timeout=timeout, + max_retries: int = ( + request_options.get("max_retries", self.base_max_retries) + if request_options is not None + else self.base_max_retries ) - max_retries: int = request_options.get("max_retries", 2) if request_options is not None else 2 + try: + response = self.httpx_client.request( + method=method, + url=_request_url, + headers=_request_headers, + params=_encoded_params if _encoded_params else None, + json=json_body, + data=data_body, + content=content, + files=request_files, + timeout=timeout, + ) + except (httpx.ConnectError, httpx.RemoteProtocolError): + if retries < max_retries: + time.sleep(_retry_timeout_from_retries(retries=retries)) + return self.request( + path=path, + method=method, + base_url=base_url, + params=params, + json=json, + data=data, + content=content, + files=files, + headers=headers, + request_options=request_options, + retries=retries + 1, + omit=omit, + force_multipart=force_multipart, + ) + raise + if _should_retry(response=response): if retries < max_retries: time.sleep(_retry_timeout(response=response, retries=retries)) @@ -386,12 +418,14 @@ def request( base_url=base_url, params=params, json=json, + data=data, content=content, files=files, headers=headers, request_options=request_options, retries=retries + 1, omit=omit, + force_multipart=force_multipart, ) if self.logger.is_debug(): @@ -518,12 +552,14 @@ def __init__( base_timeout: typing.Callable[[], typing.Optional[float]], base_headers: typing.Callable[[], typing.Dict[str, str]], base_url: typing.Optional[typing.Callable[[], str]] = None, + base_max_retries: int = 2, async_base_headers: typing.Optional[typing.Callable[[], typing.Awaitable[typing.Dict[str, str]]]] = None, logging_config: typing.Optional[typing.Union[LogConfig, Logger]] = None, ): self.base_url = base_url self.base_timeout = base_timeout self.base_headers = base_headers + self.base_max_retries = base_max_retries self.async_base_headers = async_base_headers self.httpx_client = httpx_client self.logger = create_logger(logging_config) @@ -627,19 +663,44 @@ async def request( has_body=json_body is not None or data_body is not None, ) - response = await self.httpx_client.request( - method=method, - url=_request_url, - headers=_request_headers, - params=_encoded_params if _encoded_params else None, - json=json_body, - data=data_body, - content=content, - files=request_files, - timeout=timeout, + max_retries: int = ( + request_options.get("max_retries", self.base_max_retries) + if request_options is not None + else self.base_max_retries ) - max_retries: int = request_options.get("max_retries", 2) if request_options is not None else 2 + try: + response = await self.httpx_client.request( + method=method, + url=_request_url, + headers=_request_headers, + params=_encoded_params if _encoded_params else None, + json=json_body, + data=data_body, + content=content, + files=request_files, + timeout=timeout, + ) + except (httpx.ConnectError, httpx.RemoteProtocolError): + if retries < max_retries: + await asyncio.sleep(_retry_timeout_from_retries(retries=retries)) + return await self.request( + path=path, + method=method, + base_url=base_url, + params=params, + json=json, + data=data, + content=content, + files=files, + headers=headers, + request_options=request_options, + retries=retries + 1, + omit=omit, + force_multipart=force_multipart, + ) + raise + if _should_retry(response=response): if retries < max_retries: await asyncio.sleep(_retry_timeout(response=response, retries=retries)) @@ -649,12 +710,14 @@ async def request( base_url=base_url, params=params, json=json, + data=data, content=content, files=files, headers=headers, request_options=request_options, retries=retries + 1, omit=omit, + force_multipart=force_multipart, ) if self.logger.is_debug(): diff --git a/src/schematic/core/http_sse/_api.py b/src/schematic/core/http_sse/_api.py index f900b3b..fd13730 100644 --- a/src/schematic/core/http_sse/_api.py +++ b/src/schematic/core/http_sse/_api.py @@ -1,14 +1,17 @@ # This file was auto-generated by Fern from our API Definition. +import codecs import re from contextlib import asynccontextmanager, contextmanager -from typing import Any, AsyncGenerator, AsyncIterator, Iterator, cast +from typing import Any, AsyncGenerator, AsyncIterator, Iterator import httpx from ._decoders import SSEDecoder from ._exceptions import SSEError from ._models import ServerSentEvent +MAX_LINE_SIZE: int = 1_048_576 # 1 MiB + class EventSource: def __init__(self, response: httpx.Response) -> None: @@ -45,46 +48,101 @@ def _get_charset(self) -> str: def response(self) -> httpx.Response: return self._response + @staticmethod + def _normalize_sse_line_endings(buf: str) -> str: + """Normalize line endings per the SSE spec (\\r\\n → \\n, bare \\r → \\n). + + A trailing \\r is preserved because it may pair with a leading \\n in + the next chunk to form a single \\r\\n terminator. + """ + buf = buf.replace("\r\n", "\n") + if buf.endswith("\r"): + return buf[:-1].replace("\r", "\n") + "\r" + return buf.replace("\r", "\n") + def iter_sse(self) -> Iterator[ServerSentEvent]: self._check_content_type() decoder = SSEDecoder() charset = self._get_charset() + text_decoder = codecs.getincrementaldecoder(charset)(errors="replace") - buffer = "" + buf = "" for chunk in self._response.iter_bytes(): - # Decode chunk using detected charset - text_chunk = chunk.decode(charset, errors="replace") - buffer += text_chunk - - # Process complete lines - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - line = line.rstrip("\r") + buf += text_decoder.decode(chunk) + buf = self._normalize_sse_line_endings(buf) + + while "\n" in buf: + line, buf = buf.split("\n", 1) sse = decoder.decode(line) - # when we reach a "\n\n" => line = '' - # => decoder will attempt to return an SSE Event if sse is not None: yield sse - # Process any remaining data in buffer - if buffer.strip(): - line = buffer.rstrip("\r") + if len(buf) > MAX_LINE_SIZE: + raise SSEError( + f"SSE line exceeded maximum size of {MAX_LINE_SIZE} characters without encountering a newline" + ) + + # Flush any remaining bytes from the incremental decoder + buf += text_decoder.decode(b"", final=True) + buf = buf.replace("\r\n", "\n").replace("\r", "\n") + + if len(buf) > MAX_LINE_SIZE: + raise SSEError( + f"SSE line exceeded maximum size of {MAX_LINE_SIZE} characters without encountering a newline" + ) + + while "\n" in buf: + line, buf = buf.split("\n", 1) sse = decoder.decode(line) if sse is not None: yield sse + if buf.strip(): + sse = decoder.decode(buf) + if sse is not None: + yield sse + async def aiter_sse(self) -> AsyncGenerator[ServerSentEvent, None]: self._check_content_type() decoder = SSEDecoder() - lines = cast(AsyncGenerator[str, None], self._response.aiter_lines()) - try: - async for line in lines: - line = line.rstrip("\n") + charset = self._get_charset() + text_decoder = codecs.getincrementaldecoder(charset)(errors="replace") + + buf = "" + async for chunk in self._response.aiter_bytes(): + buf += text_decoder.decode(chunk) + buf = self._normalize_sse_line_endings(buf) + + while "\n" in buf: + line, buf = buf.split("\n", 1) sse = decoder.decode(line) if sse is not None: yield sse - finally: - await lines.aclose() + + if len(buf) > MAX_LINE_SIZE: + raise SSEError( + f"SSE line exceeded maximum size of {MAX_LINE_SIZE} characters without encountering a newline" + ) + + # Flush any remaining bytes from the incremental decoder + buf += text_decoder.decode(b"", final=True) + buf = buf.replace("\r\n", "\n").replace("\r", "\n") + + if len(buf) > MAX_LINE_SIZE: + raise SSEError( + f"SSE line exceeded maximum size of {MAX_LINE_SIZE} characters without encountering a newline" + ) + + while "\n" in buf: + line, buf = buf.split("\n", 1) + sse = decoder.decode(line) + if sse is not None: + yield sse + + if buf.strip(): + sse = decoder.decode(buf) + if sse is not None: + yield sse @contextmanager diff --git a/src/schematic/core/jsonable_encoder.py b/src/schematic/core/jsonable_encoder.py index f8beaea..5b0902e 100644 --- a/src/schematic/core/jsonable_encoder.py +++ b/src/schematic/core/jsonable_encoder.py @@ -106,3 +106,15 @@ def fallback_serializer(o: Any) -> Any: return jsonable_encoder(data, custom_encoder=custom_encoder) return to_jsonable_with_fallback(obj, fallback_serializer) + + +def encode_path_param(obj: Any) -> str: + """Encode a value for use in a URL path segment. + + Ensures proper string conversion for all types, including + booleans which need lowercase 'true'/'false' rather than + Python's 'True'/'False'. + """ + if isinstance(obj, bool): + return "true" if obj else "false" + return str(jsonable_encoder(obj)) diff --git a/src/schematic/core/pydantic_utilities.py b/src/schematic/core/pydantic_utilities.py index fea3a08..6587f5e 100644 --- a/src/schematic/core/pydantic_utilities.py +++ b/src/schematic/core/pydantic_utilities.py @@ -135,111 +135,21 @@ def _decimal_encoder(dec_value: Any) -> Any: Model = TypeVar("Model", bound=pydantic.BaseModel) -def _get_discriminator_and_variants(type_: Type[Any]) -> Tuple[Optional[str], Optional[List[Type[Any]]]]: - """ - Extract the discriminator field name and union variants from a discriminated union type. - Supports Annotated[Union[...], Field(discriminator=...)] patterns. - Returns (discriminator, variants) or (None, None) if not a discriminated union. - """ - origin = typing_extensions.get_origin(type_) - - if origin is typing_extensions.Annotated: - args = typing_extensions.get_args(type_) - if len(args) >= 2: - inner_type = args[0] - # Check annotations for discriminator - discriminator = None - for annotation in args[1:]: - if hasattr(annotation, "discriminator"): - discriminator = getattr(annotation, "discriminator", None) - break - - if discriminator: - inner_origin = typing_extensions.get_origin(inner_type) - if inner_origin is Union: - variants = list(typing_extensions.get_args(inner_type)) - return discriminator, variants - return None, None - - -def _get_field_annotation(model: Type[Any], field_name: str) -> Optional[Type[Any]]: - """Get the type annotation of a field from a Pydantic model.""" - if IS_PYDANTIC_V2: - fields = getattr(model, "model_fields", {}) - field_info = fields.get(field_name) - if field_info: - return cast(Optional[Type[Any]], field_info.annotation) - else: - fields = getattr(model, "__fields__", {}) - field_info = fields.get(field_name) - if field_info: - return cast(Optional[Type[Any]], field_info.outer_type_) - return None - - -def _find_variant_by_discriminator( - variants: List[Type[Any]], - discriminator: str, - discriminator_value: Any, -) -> Optional[Type[Any]]: - """Find the union variant that matches the discriminator value.""" - for variant in variants: - if not (inspect.isclass(variant) and issubclass(variant, pydantic.BaseModel)): - continue - - disc_annotation = _get_field_annotation(variant, discriminator) - if disc_annotation and is_literal_type(disc_annotation): - literal_args = get_args(disc_annotation) - if literal_args and literal_args[0] == discriminator_value: - return variant - return None - - -def _is_string_type(type_: Type[Any]) -> bool: - """Check if a type is str or Optional[str].""" - if type_ is str: - return True - - origin = typing_extensions.get_origin(type_) - if origin is Union: - args = typing_extensions.get_args(type_) - # Optional[str] = Union[str, None] - non_none_args = [a for a in args if a is not type(None)] - if len(non_none_args) == 1 and non_none_args[0] is str: - return True - - return False - - def parse_sse_obj(sse: "ServerSentEvent", type_: Type[T]) -> T: """ Parse a ServerSentEvent into the appropriate type. - Handles two scenarios based on where the discriminator field is located: - - 1. Data-level discrimination: The discriminator (e.g., 'type') is inside the 'data' payload. - The union describes the data content, not the SSE envelope. - -> Returns: json.loads(data) parsed into the type + This function handles data-level discrimination where the discriminator + (e.g., 'type') is inside the 'data' payload. It parses the SSE data field + as JSON and deserializes it into the target type. - Example: ChatStreamResponse with discriminator='type' - Input: ServerSentEvent(event="message", data='{"type": "content-delta", ...}', id="") - Output: ContentDeltaEvent (parsed from data, SSE envelope stripped) - - 2. Event-level discrimination: The discriminator (e.g., 'event') is at the SSE event level. - The union describes the full SSE event structure. - -> Returns: SSE envelope with 'data' field JSON-parsed only if the variant expects non-string - - Example: JobStreamResponse with discriminator='event' - Input: ServerSentEvent(event="ERROR", data='{"code": "FAILED", ...}', id="123") - Output: JobStreamResponse_Error with data as ErrorData object - - But for variants where data is str (like STATUS_UPDATE): - Input: ServerSentEvent(event="STATUS_UPDATE", data='{"status": "processing"}', id="1") - Output: JobStreamResponse_StatusUpdate with data as string (not parsed) + Note: Protocol-level discrimination (where the discriminator comes from + the SSE event: field) is handled at code-generation time and does not + use this function. Args: sse: The ServerSentEvent object to parse - type_: The target discriminated union type + type_: The target type to deserialize into Returns: The parsed object of type T @@ -248,66 +158,30 @@ def parse_sse_obj(sse: "ServerSentEvent", type_: Type[T]) -> T: This function is only available in SDK contexts where http_sse module exists. """ sse_event = asdict(sse) - discriminator, variants = _get_discriminator_and_variants(type_) - - if discriminator is None or variants is None: - # Not a discriminated union - parse the data field as JSON - data_value = sse_event.get("data") - if isinstance(data_value, str) and data_value: - try: - parsed_data = json.loads(data_value) - return parse_obj_as(type_, parsed_data) - except json.JSONDecodeError as e: - _logger.warning( - "Failed to parse SSE data field as JSON: %s, data: %s", - e, - data_value[:100] if len(data_value) > 100 else data_value, - ) - return parse_obj_as(type_, sse_event) - data_value = sse_event.get("data") + if isinstance(data_value, str) and data_value: + try: + parsed_data = json.loads(data_value) + return parse_obj_as(type_, parsed_data) + except json.JSONDecodeError as e: + _logger.warning( + "Failed to parse SSE data field as JSON: %s, data: %s", + e, + data_value[:100] if len(data_value) > 100 else data_value, + ) + return parse_obj_as(type_, sse_event) - # Check if discriminator is at the top level (event-level discrimination) - if discriminator in sse_event: - # Case 2: Event-level discrimination - # Find the matching variant to check if 'data' field needs JSON parsing - disc_value = sse_event.get(discriminator) - matching_variant = _find_variant_by_discriminator(variants, discriminator, disc_value) - - if matching_variant is not None: - # Check what type the variant expects for 'data' - data_type = _get_field_annotation(matching_variant, "data") - if data_type is not None and not _is_string_type(data_type): - # Variant expects non-string data - parse JSON - if isinstance(data_value, str) and data_value: - try: - parsed_data = json.loads(data_value) - new_object = dict(sse_event) - new_object["data"] = parsed_data - return parse_obj_as(type_, new_object) - except json.JSONDecodeError as e: - _logger.warning( - "Failed to parse SSE data field as JSON for event-level discrimination: %s, data: %s", - e, - data_value[:100] if len(data_value) > 100 else data_value, - ) - # Either no matching variant, data is string type, or JSON parse failed - return parse_obj_as(type_, sse_event) - else: - # Case 1: Data-level discrimination - # The discriminator is inside the data payload - extract and parse data only - if isinstance(data_value, str) and data_value: - try: - parsed_data = json.loads(data_value) - return parse_obj_as(type_, parsed_data) - except json.JSONDecodeError as e: - _logger.warning( - "Failed to parse SSE data field as JSON for data-level discrimination: %s, data: %s", - e, - data_value[:100] if len(data_value) > 100 else data_value, - ) - return parse_obj_as(type_, sse_event) +_type_adapter_cache: Dict[int, Any] = {} + + +def _get_type_adapter(type_: Type[Any]) -> Any: + key = id(type_) + adapter = _type_adapter_cache.get(key) + if adapter is None: + adapter = pydantic.TypeAdapter(type_) # type: ignore[attr-defined] + _type_adapter_cache[key] = adapter + return adapter def parse_obj_as(type_: Type[T], object_: Any) -> T: @@ -342,8 +216,8 @@ def parse_obj_as(type_: Type[T], object_: Any) -> T: else: dealiased_object = convert_and_respect_annotation_metadata(object_=object_, annotation=type_, direction="read") if IS_PYDANTIC_V2: - adapter = pydantic.TypeAdapter(type_) # type: ignore[attr-defined] - return adapter.validate_python(dealiased_object) + adapter = _get_type_adapter(type_) + return adapter.validate_python(dealiased_object) # type: ignore[no-any-return] return pydantic.parse_obj_as(type_, dealiased_object) diff --git a/src/schematic/core/serialization.py b/src/schematic/core/serialization.py index c36e865..1d753e2 100644 --- a/src/schematic/core/serialization.py +++ b/src/schematic/core/serialization.py @@ -26,6 +26,75 @@ def __init__(self, *, alias: str) -> None: self.alias = alias +# Resolving type hints (typing.get_type_hints) is expensive because it eval/compiles +# forward-reference annotations. The result is constant for a given type, so we cache it. +# This is critical for hot paths like SSE event parsing, where the same (often large +# discriminated-union) type is converted on every single event. +_type_hints_cache: typing.Dict[typing.Any, typing.Dict[str, typing.Any]] = {} + + +def _get_cached_type_hints(expected_type: typing.Any) -> typing.Dict[str, typing.Any]: + try: + cached = _type_hints_cache.get(expected_type) + except TypeError: + # Unhashable type; resolve without caching. + return _resolve_type_hints(expected_type) + if cached is None: + cached = _resolve_type_hints(expected_type) + _type_hints_cache[expected_type] = cached + return cached + + +def _resolve_type_hints(expected_type: typing.Any) -> typing.Dict[str, typing.Any]: + try: + return typing_extensions.get_type_hints(expected_type, include_extras=True) + except NameError: + # The type contains a circular reference, so we use the __annotations__ attribute directly. + return getattr(expected_type, "__annotations__", {}) + + +# Whether convert_and_respect_annotation_metadata can possibly rewrite anything for a given +# annotation, i.e. whether any reachable model/TypedDict field carries a FieldMetadata alias. +# This is constant per type, so we cache it and use it to short-circuit the recursive walk. +_requires_conversion_cache: typing.Dict[typing.Any, bool] = {} + + +def _requires_conversion(type_: typing.Any) -> bool: + try: + cached = _requires_conversion_cache.get(type_) + except TypeError: + # Unhashable annotation; compute without caching. + return _compute_requires_conversion(type_, set()) + if cached is None: + cached = _compute_requires_conversion(type_, set()) + _requires_conversion_cache[type_] = cached + return cached + + +def _compute_requires_conversion(type_: typing.Any, seen: typing.Set[typing.Any]) -> bool: + clean_type = _remove_annotations(type_) + + try: + if clean_type in seen: + return False + seen = seen | {clean_type} + except TypeError: + # Unhashable type; skip cycle tracking (the type graph is finite in practice). + pass + + # Models / TypedDicts: a field alias here means we must dealias; otherwise recurse into fields. + if (inspect.isclass(clean_type) and issubclass(clean_type, pydantic.BaseModel)) or typing_extensions.is_typeddict( + clean_type + ): + annotations = _get_cached_type_hints(clean_type) + if _get_alias_to_field_name(annotations): + return True + return any(_compute_requires_conversion(hint, seen) for hint in annotations.values()) + + # Containers / unions: recurse into the type arguments (List/Set/Sequence/Dict/Union/etc.). + return any(_compute_requires_conversion(arg, seen) for arg in typing_extensions.get_args(clean_type)) + + def convert_and_respect_annotation_metadata( *, object_: typing.Any, @@ -57,6 +126,13 @@ def convert_and_respect_annotation_metadata( return None if inner_type is None: inner_type = annotation + # The only thing this function ever rewrites is keys that carry a FieldMetadata + # alias. If nothing in the (cached) type graph has such an alias, the conversion is + # a content-identity transform, so we can skip the entire recursive walk. This is + # the hot path for SSE streaming, where a large discriminated union would otherwise + # be traversed on every single event. + if not _requires_conversion(annotation): + return object_ clean_type = _remove_annotations(inner_type) # Pydantic models @@ -160,12 +236,7 @@ def _convert_mapping( direction: typing.Literal["read", "write"], ) -> typing.Mapping[str, object]: converted_object: typing.Dict[str, object] = {} - try: - annotations = typing_extensions.get_type_hints(expected_type, include_extras=True) - except NameError: - # The TypedDict contains a circular reference, so - # we use the __annotations__ attribute directly. - annotations = getattr(expected_type, "__annotations__", {}) + annotations = _get_cached_type_hints(expected_type) aliases_to_field_names = _get_alias_to_field_name(annotations) for key, value in object_.items(): if direction == "read" and key in aliases_to_field_names: @@ -221,12 +292,12 @@ def _remove_annotations(type_: typing.Any) -> typing.Any: def get_alias_to_field_mapping(type_: typing.Any) -> typing.Dict[str, str]: - annotations = typing_extensions.get_type_hints(type_, include_extras=True) + annotations = _get_cached_type_hints(type_) return _get_alias_to_field_name(annotations) def get_field_to_alias_mapping(type_: typing.Any) -> typing.Dict[str, str]: - annotations = typing_extensions.get_type_hints(type_, include_extras=True) + annotations = _get_cached_type_hints(type_) return _get_field_to_alias_name(annotations) diff --git a/src/schematic/credits/raw_client.py b/src/schematic/credits/raw_client.py index 6405d4a..663b55f 100644 --- a/src/schematic/credits/raw_client.py +++ b/src/schematic/credits/raw_client.py @@ -7,7 +7,7 @@ from ..core.api_error import ApiError as core_api_error_ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -375,7 +375,7 @@ def get_single_billing_credit( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/credits/{jsonable_encoder(credit_id)}", + f"billing/credits/{encode_path_param(credit_id)}", method="GET", request_options=request_options, ) @@ -503,7 +503,7 @@ def update_billing_credit( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/credits/{jsonable_encoder(credit_id)}", + f"billing/credits/{encode_path_param(credit_id)}", method="PUT", json={ "burn_strategy": burn_strategy, @@ -625,7 +625,7 @@ def soft_delete_billing_credit( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/credits/{jsonable_encoder(credit_id)}", + f"billing/credits/{encode_path_param(credit_id)}", method="DELETE", request_options=request_options, ) @@ -1111,7 +1111,7 @@ def get_credit_bundle( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/credits/bundles/{jsonable_encoder(bundle_id)}", + f"billing/credits/bundles/{encode_path_param(bundle_id)}", method="GET", request_options=request_options, ) @@ -1230,7 +1230,7 @@ def update_credit_bundle_details( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/credits/bundles/{jsonable_encoder(bundle_id)}", + f"billing/credits/bundles/{encode_path_param(bundle_id)}", method="PUT", json={ "bundle_name": bundle_name, @@ -1349,7 +1349,7 @@ def delete_credit_bundle( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/credits/bundles/{jsonable_encoder(bundle_id)}", + f"billing/credits/bundles/{encode_path_param(bundle_id)}", method="DELETE", request_options=request_options, ) @@ -1701,7 +1701,7 @@ def zero_out_grant( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/credits/grants/{jsonable_encoder(grant_id)}/zero-out", + f"billing/credits/grants/{encode_path_param(grant_id)}/zero-out", method="PUT", json={ "reason": reason, @@ -2575,7 +2575,7 @@ def extend_credit_lease( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/credits/lease/{jsonable_encoder(lease_id)}/extend", + f"billing/credits/lease/{encode_path_param(lease_id)}/extend", method="PUT", json={ "additional_amount": additional_amount, @@ -2689,7 +2689,7 @@ def release_credit_lease( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/credits/lease/{jsonable_encoder(lease_id)}/release", + f"billing/credits/lease/{encode_path_param(lease_id)}/release", method="PUT", json=request, headers={ @@ -3127,7 +3127,7 @@ def get_single_billing_plan_credit_grant( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/credits/plan-grants/{jsonable_encoder(plan_grant_id)}", + f"billing/credits/plan-grants/{encode_path_param(plan_grant_id)}", method="GET", request_options=request_options, ) @@ -3280,7 +3280,7 @@ def update_billing_plan_credit_grant( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/credits/plan-grants/{jsonable_encoder(plan_grant_id)}", + f"billing/credits/plan-grants/{encode_path_param(plan_grant_id)}", method="PUT", json={ "apply_to_existing": apply_to_existing, @@ -3412,7 +3412,7 @@ def delete_billing_plan_credit_grant( OK """ _response = self._client_wrapper.httpx_client.request( - f"billing/credits/plan-grants/{jsonable_encoder(plan_grant_id)}", + f"billing/credits/plan-grants/{encode_path_param(plan_grant_id)}", method="DELETE", params={ "apply_to_existing": apply_to_existing, @@ -4205,7 +4205,7 @@ async def get_single_billing_credit( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/credits/{jsonable_encoder(credit_id)}", + f"billing/credits/{encode_path_param(credit_id)}", method="GET", request_options=request_options, ) @@ -4333,7 +4333,7 @@ async def update_billing_credit( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/credits/{jsonable_encoder(credit_id)}", + f"billing/credits/{encode_path_param(credit_id)}", method="PUT", json={ "burn_strategy": burn_strategy, @@ -4455,7 +4455,7 @@ async def soft_delete_billing_credit( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/credits/{jsonable_encoder(credit_id)}", + f"billing/credits/{encode_path_param(credit_id)}", method="DELETE", request_options=request_options, ) @@ -4941,7 +4941,7 @@ async def get_credit_bundle( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/credits/bundles/{jsonable_encoder(bundle_id)}", + f"billing/credits/bundles/{encode_path_param(bundle_id)}", method="GET", request_options=request_options, ) @@ -5060,7 +5060,7 @@ async def update_credit_bundle_details( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/credits/bundles/{jsonable_encoder(bundle_id)}", + f"billing/credits/bundles/{encode_path_param(bundle_id)}", method="PUT", json={ "bundle_name": bundle_name, @@ -5179,7 +5179,7 @@ async def delete_credit_bundle( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/credits/bundles/{jsonable_encoder(bundle_id)}", + f"billing/credits/bundles/{encode_path_param(bundle_id)}", method="DELETE", request_options=request_options, ) @@ -5531,7 +5531,7 @@ async def zero_out_grant( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/credits/grants/{jsonable_encoder(grant_id)}/zero-out", + f"billing/credits/grants/{encode_path_param(grant_id)}/zero-out", method="PUT", json={ "reason": reason, @@ -6405,7 +6405,7 @@ async def extend_credit_lease( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/credits/lease/{jsonable_encoder(lease_id)}/extend", + f"billing/credits/lease/{encode_path_param(lease_id)}/extend", method="PUT", json={ "additional_amount": additional_amount, @@ -6519,7 +6519,7 @@ async def release_credit_lease( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/credits/lease/{jsonable_encoder(lease_id)}/release", + f"billing/credits/lease/{encode_path_param(lease_id)}/release", method="PUT", json=request, headers={ @@ -6957,7 +6957,7 @@ async def get_single_billing_plan_credit_grant( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/credits/plan-grants/{jsonable_encoder(plan_grant_id)}", + f"billing/credits/plan-grants/{encode_path_param(plan_grant_id)}", method="GET", request_options=request_options, ) @@ -7110,7 +7110,7 @@ async def update_billing_plan_credit_grant( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/credits/plan-grants/{jsonable_encoder(plan_grant_id)}", + f"billing/credits/plan-grants/{encode_path_param(plan_grant_id)}", method="PUT", json={ "apply_to_existing": apply_to_existing, @@ -7242,7 +7242,7 @@ async def delete_billing_plan_credit_grant( OK """ _response = await self._client_wrapper.httpx_client.request( - f"billing/credits/plan-grants/{jsonable_encoder(plan_grant_id)}", + f"billing/credits/plan-grants/{encode_path_param(plan_grant_id)}", method="DELETE", params={ "apply_to_existing": apply_to_existing, diff --git a/src/schematic/dataexports/raw_client.py b/src/schematic/dataexports/raw_client.py index 0fa0357..b5d39b8 100644 --- a/src/schematic/dataexports/raw_client.py +++ b/src/schematic/dataexports/raw_client.py @@ -7,7 +7,7 @@ from ..core.api_error import ApiError as core_api_error_ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -155,7 +155,7 @@ def get_data_export_artifact( OK """ with self._client_wrapper.httpx_client.stream( - f"data-exports/{jsonable_encoder(data_export_id)}/artifact", + f"data-exports/{encode_path_param(data_export_id)}/artifact", method="GET", request_options=request_options, ) as _response: @@ -362,7 +362,7 @@ async def get_data_export_artifact( OK """ async with self._client_wrapper.httpx_client.stream( - f"data-exports/{jsonable_encoder(data_export_id)}/artifact", + f"data-exports/{encode_path_param(data_export_id)}/artifact", method="GET", request_options=request_options, ) as _response: diff --git a/src/schematic/entitlements/raw_client.py b/src/schematic/entitlements/raw_client.py index 67fade7..4b1df92 100644 --- a/src/schematic/entitlements/raw_client.py +++ b/src/schematic/entitlements/raw_client.py @@ -8,7 +8,7 @@ from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.datetime_utils import serialize_datetime from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -378,7 +378,7 @@ def get_company_override( OK """ _response = self._client_wrapper.httpx_client.request( - f"company-overrides/{jsonable_encoder(company_override_id)}", + f"company-overrides/{encode_path_param(company_override_id)}", method="GET", request_options=request_options, ) @@ -500,7 +500,7 @@ def update_company_override( OK """ _response = self._client_wrapper.httpx_client.request( - f"company-overrides/{jsonable_encoder(company_override_id)}", + f"company-overrides/{encode_path_param(company_override_id)}", method="PUT", json={ "credit_consumption_rate": credit_consumption_rate, @@ -616,7 +616,7 @@ def delete_company_override( OK """ _response = self._client_wrapper.httpx_client.request( - f"company-overrides/{jsonable_encoder(company_override_id)}", + f"company-overrides/{encode_path_param(company_override_id)}", method="DELETE", request_options=request_options, ) @@ -2147,7 +2147,7 @@ def get_plan_entitlement( OK """ _response = self._client_wrapper.httpx_client.request( - f"plan-entitlements/{jsonable_encoder(plan_entitlement_id)}", + f"plan-entitlements/{encode_path_param(plan_entitlement_id)}", method="GET", request_options=request_options, ) @@ -2334,7 +2334,7 @@ def update_plan_entitlement( OK """ _response = self._client_wrapper.httpx_client.request( - f"plan-entitlements/{jsonable_encoder(plan_entitlement_id)}", + f"plan-entitlements/{encode_path_param(plan_entitlement_id)}", method="PUT", json={ "billing_product_id": billing_product_id, @@ -2489,7 +2489,7 @@ def delete_plan_entitlement( OK """ _response = self._client_wrapper.httpx_client.request( - f"plan-entitlements/{jsonable_encoder(plan_entitlement_id)}", + f"plan-entitlements/{encode_path_param(plan_entitlement_id)}", method="DELETE", request_options=request_options, ) @@ -3519,7 +3519,7 @@ async def get_company_override( OK """ _response = await self._client_wrapper.httpx_client.request( - f"company-overrides/{jsonable_encoder(company_override_id)}", + f"company-overrides/{encode_path_param(company_override_id)}", method="GET", request_options=request_options, ) @@ -3641,7 +3641,7 @@ async def update_company_override( OK """ _response = await self._client_wrapper.httpx_client.request( - f"company-overrides/{jsonable_encoder(company_override_id)}", + f"company-overrides/{encode_path_param(company_override_id)}", method="PUT", json={ "credit_consumption_rate": credit_consumption_rate, @@ -3757,7 +3757,7 @@ async def delete_company_override( OK """ _response = await self._client_wrapper.httpx_client.request( - f"company-overrides/{jsonable_encoder(company_override_id)}", + f"company-overrides/{encode_path_param(company_override_id)}", method="DELETE", request_options=request_options, ) @@ -5288,7 +5288,7 @@ async def get_plan_entitlement( OK """ _response = await self._client_wrapper.httpx_client.request( - f"plan-entitlements/{jsonable_encoder(plan_entitlement_id)}", + f"plan-entitlements/{encode_path_param(plan_entitlement_id)}", method="GET", request_options=request_options, ) @@ -5475,7 +5475,7 @@ async def update_plan_entitlement( OK """ _response = await self._client_wrapper.httpx_client.request( - f"plan-entitlements/{jsonable_encoder(plan_entitlement_id)}", + f"plan-entitlements/{encode_path_param(plan_entitlement_id)}", method="PUT", json={ "billing_product_id": billing_product_id, @@ -5630,7 +5630,7 @@ async def delete_plan_entitlement( OK """ _response = await self._client_wrapper.httpx_client.request( - f"plan-entitlements/{jsonable_encoder(plan_entitlement_id)}", + f"plan-entitlements/{encode_path_param(plan_entitlement_id)}", method="DELETE", request_options=request_options, ) diff --git a/src/schematic/events/raw_client.py b/src/schematic/events/raw_client.py index c3be49e..2c38bba 100644 --- a/src/schematic/events/raw_client.py +++ b/src/schematic/events/raw_client.py @@ -7,7 +7,7 @@ from ..core.api_error import ApiError as core_api_error_ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -554,7 +554,7 @@ def get_event( OK """ _response = self._client_wrapper.httpx_client.request( - f"events/{jsonable_encoder(event_id)}", + f"events/{encode_path_param(event_id)}", method="GET", request_options=request_options, ) @@ -1233,7 +1233,7 @@ async def get_event( OK """ _response = await self._client_wrapper.httpx_client.request( - f"events/{jsonable_encoder(event_id)}", + f"events/{encode_path_param(event_id)}", method="GET", request_options=request_options, ) diff --git a/src/schematic/features/raw_client.py b/src/schematic/features/raw_client.py index cd9e048..ef082d6 100644 --- a/src/schematic/features/raw_client.py +++ b/src/schematic/features/raw_client.py @@ -6,7 +6,7 @@ from ..core.api_error import ApiError as core_api_error_ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -369,7 +369,7 @@ def get_feature( OK """ _response = self._client_wrapper.httpx_client.request( - f"features/{jsonable_encoder(feature_id)}", + f"features/{encode_path_param(feature_id)}", method="GET", request_options=request_options, ) @@ -494,7 +494,7 @@ def update_feature( OK """ _response = self._client_wrapper.httpx_client.request( - f"features/{jsonable_encoder(feature_id)}", + f"features/{encode_path_param(feature_id)}", method="PUT", json={ "description": description, @@ -613,7 +613,7 @@ def delete_feature( OK """ _response = self._client_wrapper.httpx_client.request( - f"features/{jsonable_encoder(feature_id)}", + f"features/{encode_path_param(feature_id)}", method="DELETE", request_options=request_options, ) @@ -1276,7 +1276,7 @@ def get_flag( OK """ _response = self._client_wrapper.httpx_client.request( - f"flags/{jsonable_encoder(flag_id)}", + f"flags/{encode_path_param(flag_id)}", method="GET", request_options=request_options, ) @@ -1386,7 +1386,7 @@ def update_flag( OK """ _response = self._client_wrapper.httpx_client.request( - f"flags/{jsonable_encoder(flag_id)}", + f"flags/{encode_path_param(flag_id)}", method="PUT", json={ "default_value": default_value, @@ -1499,7 +1499,7 @@ def delete_flag( OK """ _response = self._client_wrapper.httpx_client.request( - f"flags/{jsonable_encoder(flag_id)}", + f"flags/{encode_path_param(flag_id)}", method="DELETE", request_options=request_options, ) @@ -1605,7 +1605,7 @@ def update_flag_rules( OK """ _response = self._client_wrapper.httpx_client.request( - f"flags/{jsonable_encoder(flag_id)}/rules", + f"flags/{encode_path_param(flag_id)}/rules", method="PUT", json={ "rules": convert_and_respect_annotation_metadata( @@ -1723,7 +1723,7 @@ def check_flag( OK """ _response = self._client_wrapper.httpx_client.request( - f"flags/{jsonable_encoder(key)}/check", + f"flags/{encode_path_param(key)}/check", method="POST", json={ "company": company, @@ -2485,7 +2485,7 @@ async def get_feature( OK """ _response = await self._client_wrapper.httpx_client.request( - f"features/{jsonable_encoder(feature_id)}", + f"features/{encode_path_param(feature_id)}", method="GET", request_options=request_options, ) @@ -2610,7 +2610,7 @@ async def update_feature( OK """ _response = await self._client_wrapper.httpx_client.request( - f"features/{jsonable_encoder(feature_id)}", + f"features/{encode_path_param(feature_id)}", method="PUT", json={ "description": description, @@ -2729,7 +2729,7 @@ async def delete_feature( OK """ _response = await self._client_wrapper.httpx_client.request( - f"features/{jsonable_encoder(feature_id)}", + f"features/{encode_path_param(feature_id)}", method="DELETE", request_options=request_options, ) @@ -3392,7 +3392,7 @@ async def get_flag( OK """ _response = await self._client_wrapper.httpx_client.request( - f"flags/{jsonable_encoder(flag_id)}", + f"flags/{encode_path_param(flag_id)}", method="GET", request_options=request_options, ) @@ -3502,7 +3502,7 @@ async def update_flag( OK """ _response = await self._client_wrapper.httpx_client.request( - f"flags/{jsonable_encoder(flag_id)}", + f"flags/{encode_path_param(flag_id)}", method="PUT", json={ "default_value": default_value, @@ -3615,7 +3615,7 @@ async def delete_flag( OK """ _response = await self._client_wrapper.httpx_client.request( - f"flags/{jsonable_encoder(flag_id)}", + f"flags/{encode_path_param(flag_id)}", method="DELETE", request_options=request_options, ) @@ -3721,7 +3721,7 @@ async def update_flag_rules( OK """ _response = await self._client_wrapper.httpx_client.request( - f"flags/{jsonable_encoder(flag_id)}/rules", + f"flags/{encode_path_param(flag_id)}/rules", method="PUT", json={ "rules": convert_and_respect_annotation_metadata( @@ -3839,7 +3839,7 @@ async def check_flag( OK """ _response = await self._client_wrapper.httpx_client.request( - f"flags/{jsonable_encoder(key)}/check", + f"flags/{encode_path_param(key)}/check", method="POST", json={ "company": company, diff --git a/src/schematic/integrationsapi/raw_client.py b/src/schematic/integrationsapi/raw_client.py index 9d84438..afd5a3f 100644 --- a/src/schematic/integrationsapi/raw_client.py +++ b/src/schematic/integrationsapi/raw_client.py @@ -6,7 +6,7 @@ from ..core.api_error import ApiError as core_api_error_ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -56,7 +56,7 @@ def run_integration( OK """ _response = self._client_wrapper.httpx_client.request( - f"integration/start/{jsonable_encoder(integration_id)}", + f"integration/start/{encode_path_param(integration_id)}", method="GET", request_options=request_options, ) @@ -276,7 +276,7 @@ def get_integration_webhook_url( OK """ _response = self._client_wrapper.httpx_client.request( - f"integrations/{jsonable_encoder(type)}/webhook-url", + f"integrations/{encode_path_param(type)}/webhook-url", method="GET", request_options=request_options, ) @@ -955,7 +955,7 @@ def uninstall_integration( OK """ _response = self._client_wrapper.httpx_client.request( - f"integrations/uninstall/{jsonable_encoder(integration_id)}", + f"integrations/uninstall/{encode_path_param(integration_id)}", method="DELETE", request_options=request_options, ) @@ -1060,7 +1060,7 @@ async def run_integration( OK """ _response = await self._client_wrapper.httpx_client.request( - f"integration/start/{jsonable_encoder(integration_id)}", + f"integration/start/{encode_path_param(integration_id)}", method="GET", request_options=request_options, ) @@ -1280,7 +1280,7 @@ async def get_integration_webhook_url( OK """ _response = await self._client_wrapper.httpx_client.request( - f"integrations/{jsonable_encoder(type)}/webhook-url", + f"integrations/{encode_path_param(type)}/webhook-url", method="GET", request_options=request_options, ) @@ -1959,7 +1959,7 @@ async def uninstall_integration( OK """ _response = await self._client_wrapper.httpx_client.request( - f"integrations/uninstall/{jsonable_encoder(integration_id)}", + f"integrations/uninstall/{encode_path_param(integration_id)}", method="DELETE", request_options=request_options, ) diff --git a/src/schematic/planbundle/raw_client.py b/src/schematic/planbundle/raw_client.py index 356ab1c..65be3b7 100644 --- a/src/schematic/planbundle/raw_client.py +++ b/src/schematic/planbundle/raw_client.py @@ -6,7 +6,7 @@ from ..core.api_error import ApiError as core_api_error_ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -330,7 +330,7 @@ def update_plan_bundle( OK """ _response = self._client_wrapper.httpx_client.request( - f"plan-bundles/{jsonable_encoder(plan_bundle_id)}", + f"plan-bundles/{encode_path_param(plan_bundle_id)}", method="PUT", json={ "billing_product": convert_and_respect_annotation_metadata( @@ -734,7 +734,7 @@ async def update_plan_bundle( OK """ _response = await self._client_wrapper.httpx_client.request( - f"plan-bundles/{jsonable_encoder(plan_bundle_id)}", + f"plan-bundles/{encode_path_param(plan_bundle_id)}", method="PUT", json={ "billing_product": convert_and_respect_annotation_metadata( diff --git a/src/schematic/plangroups/raw_client.py b/src/schematic/plangroups/raw_client.py index 6565f41..543f303 100644 --- a/src/schematic/plangroups/raw_client.py +++ b/src/schematic/plangroups/raw_client.py @@ -6,7 +6,7 @@ from ..core.api_error import ApiError as core_api_error_ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -523,7 +523,7 @@ def update_plan_group( OK """ _response = self._client_wrapper.httpx_client.request( - f"plan-groups/{jsonable_encoder(plan_group_id)}", + f"plan-groups/{encode_path_param(plan_group_id)}", method="PUT", json={ "add_on_compatibilities": convert_and_respect_annotation_metadata( @@ -1150,7 +1150,7 @@ async def update_plan_group( OK """ _response = await self._client_wrapper.httpx_client.request( - f"plan-groups/{jsonable_encoder(plan_group_id)}", + f"plan-groups/{encode_path_param(plan_group_id)}", method="PUT", json={ "add_on_compatibilities": convert_and_respect_annotation_metadata( diff --git a/src/schematic/planmigrations/raw_client.py b/src/schematic/planmigrations/raw_client.py index 2b7c380..750f190 100644 --- a/src/schematic/planmigrations/raw_client.py +++ b/src/schematic/planmigrations/raw_client.py @@ -6,7 +6,7 @@ from ..core.api_error import ApiError as core_api_error_ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -180,7 +180,7 @@ def retry_company_migration( OK """ _response = self._client_wrapper.httpx_client.request( - f"plan-version-company-migrations/{jsonable_encoder(plan_version_company_migration_id)}/retry", + f"plan-version-company-migrations/{encode_path_param(plan_version_company_migration_id)}/retry", method="POST", request_options=request_options, ) @@ -655,7 +655,7 @@ def get_migration( OK """ _response = self._client_wrapper.httpx_client.request( - f"plan-version-migrations/{jsonable_encoder(plan_version_migration_id)}", + f"plan-version-migrations/{encode_path_param(plan_version_migration_id)}", method="GET", request_options=request_options, ) @@ -750,7 +750,7 @@ def retry_migration( OK """ _response = self._client_wrapper.httpx_client.request( - f"plan-version-migrations/{jsonable_encoder(plan_version_migration_id)}/retry", + f"plan-version-migrations/{encode_path_param(plan_version_migration_id)}/retry", method="POST", json={ "error_codes": error_codes, @@ -1104,7 +1104,7 @@ async def retry_company_migration( OK """ _response = await self._client_wrapper.httpx_client.request( - f"plan-version-company-migrations/{jsonable_encoder(plan_version_company_migration_id)}/retry", + f"plan-version-company-migrations/{encode_path_param(plan_version_company_migration_id)}/retry", method="POST", request_options=request_options, ) @@ -1579,7 +1579,7 @@ async def get_migration( OK """ _response = await self._client_wrapper.httpx_client.request( - f"plan-version-migrations/{jsonable_encoder(plan_version_migration_id)}", + f"plan-version-migrations/{encode_path_param(plan_version_migration_id)}", method="GET", request_options=request_options, ) @@ -1674,7 +1674,7 @@ async def retry_migration( OK """ _response = await self._client_wrapper.httpx_client.request( - f"plan-version-migrations/{jsonable_encoder(plan_version_migration_id)}/retry", + f"plan-version-migrations/{encode_path_param(plan_version_migration_id)}/retry", method="POST", json={ "error_codes": error_codes, diff --git a/src/schematic/plans/raw_client.py b/src/schematic/plans/raw_client.py index f2688f3..28177ba 100644 --- a/src/schematic/plans/raw_client.py +++ b/src/schematic/plans/raw_client.py @@ -6,7 +6,7 @@ from ..core.api_error import ApiError as core_api_error_ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -82,7 +82,7 @@ def update_company_plans( OK """ _response = self._client_wrapper.httpx_client.request( - f"company-plans/{jsonable_encoder(company_plan_id)}", + f"company-plans/{encode_path_param(company_plan_id)}", method="PUT", json={ "add_on_ids": add_on_ids, @@ -329,7 +329,7 @@ def mark_custom_plan_billing_paid( OK """ _response = self._client_wrapper.httpx_client.request( - f"custom-plan-billings/{jsonable_encoder(custom_plan_billing_id)}/mark-paid", + f"custom-plan-billings/{encode_path_param(custom_plan_billing_id)}/mark-paid", method="PUT", json=request, headers={ @@ -446,7 +446,7 @@ def retry_custom_plan_billing( OK """ _response = self._client_wrapper.httpx_client.request( - f"custom-plan-billings/{jsonable_encoder(custom_plan_billing_id)}/retry", + f"custom-plan-billings/{encode_path_param(custom_plan_billing_id)}/retry", method="PUT", json={ "activation_strategy": activation_strategy, @@ -991,7 +991,7 @@ def get_plan( OK """ _response = self._client_wrapper.httpx_client.request( - f"plans/{jsonable_encoder(plan_id)}", + f"plans/{encode_path_param(plan_id)}", method="GET", params={ "plan_version_id": plan_version_id, @@ -1095,7 +1095,7 @@ def update_plan( OK """ _response = self._client_wrapper.httpx_client.request( - f"plans/{jsonable_encoder(plan_id)}", + f"plans/{encode_path_param(plan_id)}", method="PUT", json={ "description": description, @@ -1204,7 +1204,7 @@ def delete_plan( OK """ _response = self._client_wrapper.httpx_client.request( - f"plans/{jsonable_encoder(plan_id)}", + f"plans/{encode_path_param(plan_id)}", method="DELETE", request_options=request_options, ) @@ -1352,7 +1352,7 @@ def upsert_billing_product_plan( OK """ _response = self._client_wrapper.httpx_client.request( - f"plans/{jsonable_encoder(plan_id)}/billing_products", + f"plans/{encode_path_param(plan_id)}/billing_products", method="PUT", json={ "billing_product_id": billing_product_id, @@ -2148,7 +2148,7 @@ def delete_plan_version( OK """ _response = self._client_wrapper.httpx_client.request( - f"plans/version/{jsonable_encoder(plan_id)}", + f"plans/version/{encode_path_param(plan_id)}", method="DELETE", params={ "promote_archived_version": promote_archived_version, @@ -2272,7 +2272,7 @@ def publish_plan_version( OK """ _response = self._client_wrapper.httpx_client.request( - f"plans/version/{jsonable_encoder(plan_id)}/publish", + f"plans/version/{encode_path_param(plan_id)}/publish", method="PUT", json={ "activation_strategy": activation_strategy, @@ -2398,7 +2398,7 @@ async def update_company_plans( OK """ _response = await self._client_wrapper.httpx_client.request( - f"company-plans/{jsonable_encoder(company_plan_id)}", + f"company-plans/{encode_path_param(company_plan_id)}", method="PUT", json={ "add_on_ids": add_on_ids, @@ -2645,7 +2645,7 @@ async def mark_custom_plan_billing_paid( OK """ _response = await self._client_wrapper.httpx_client.request( - f"custom-plan-billings/{jsonable_encoder(custom_plan_billing_id)}/mark-paid", + f"custom-plan-billings/{encode_path_param(custom_plan_billing_id)}/mark-paid", method="PUT", json=request, headers={ @@ -2762,7 +2762,7 @@ async def retry_custom_plan_billing( OK """ _response = await self._client_wrapper.httpx_client.request( - f"custom-plan-billings/{jsonable_encoder(custom_plan_billing_id)}/retry", + f"custom-plan-billings/{encode_path_param(custom_plan_billing_id)}/retry", method="PUT", json={ "activation_strategy": activation_strategy, @@ -3307,7 +3307,7 @@ async def get_plan( OK """ _response = await self._client_wrapper.httpx_client.request( - f"plans/{jsonable_encoder(plan_id)}", + f"plans/{encode_path_param(plan_id)}", method="GET", params={ "plan_version_id": plan_version_id, @@ -3411,7 +3411,7 @@ async def update_plan( OK """ _response = await self._client_wrapper.httpx_client.request( - f"plans/{jsonable_encoder(plan_id)}", + f"plans/{encode_path_param(plan_id)}", method="PUT", json={ "description": description, @@ -3520,7 +3520,7 @@ async def delete_plan( OK """ _response = await self._client_wrapper.httpx_client.request( - f"plans/{jsonable_encoder(plan_id)}", + f"plans/{encode_path_param(plan_id)}", method="DELETE", request_options=request_options, ) @@ -3668,7 +3668,7 @@ async def upsert_billing_product_plan( OK """ _response = await self._client_wrapper.httpx_client.request( - f"plans/{jsonable_encoder(plan_id)}/billing_products", + f"plans/{encode_path_param(plan_id)}/billing_products", method="PUT", json={ "billing_product_id": billing_product_id, @@ -4464,7 +4464,7 @@ async def delete_plan_version( OK """ _response = await self._client_wrapper.httpx_client.request( - f"plans/version/{jsonable_encoder(plan_id)}", + f"plans/version/{encode_path_param(plan_id)}", method="DELETE", params={ "promote_archived_version": promote_archived_version, @@ -4588,7 +4588,7 @@ async def publish_plan_version( OK """ _response = await self._client_wrapper.httpx_client.request( - f"plans/version/{jsonable_encoder(plan_id)}/publish", + f"plans/version/{encode_path_param(plan_id)}/publish", method="PUT", json={ "activation_strategy": activation_strategy, diff --git a/src/schematic/scheduledcheckout/raw_client.py b/src/schematic/scheduledcheckout/raw_client.py index bec01dc..892028a 100644 --- a/src/schematic/scheduledcheckout/raw_client.py +++ b/src/schematic/scheduledcheckout/raw_client.py @@ -7,7 +7,7 @@ from ..core.api_error import ApiError as core_api_error_ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -290,7 +290,7 @@ def get_scheduled_checkout( OK """ _response = self._client_wrapper.httpx_client.request( - f"scheduled-checkout/{jsonable_encoder(scheduled_checkout_id)}", + f"scheduled-checkout/{encode_path_param(scheduled_checkout_id)}", method="GET", request_options=request_options, ) @@ -388,7 +388,7 @@ def update_scheduled_checkout( OK """ _response = self._client_wrapper.httpx_client.request( - f"scheduled-checkout/{jsonable_encoder(scheduled_checkout_id)}", + f"scheduled-checkout/{encode_path_param(scheduled_checkout_id)}", method="PUT", json={ "execute_after": execute_after, @@ -741,7 +741,7 @@ async def get_scheduled_checkout( OK """ _response = await self._client_wrapper.httpx_client.request( - f"scheduled-checkout/{jsonable_encoder(scheduled_checkout_id)}", + f"scheduled-checkout/{encode_path_param(scheduled_checkout_id)}", method="GET", request_options=request_options, ) @@ -839,7 +839,7 @@ async def update_scheduled_checkout( OK """ _response = await self._client_wrapper.httpx_client.request( - f"scheduled-checkout/{jsonable_encoder(scheduled_checkout_id)}", + f"scheduled-checkout/{encode_path_param(scheduled_checkout_id)}", method="PUT", json={ "execute_after": execute_after, diff --git a/src/schematic/webhooks/raw_client.py b/src/schematic/webhooks/raw_client.py index cd85f14..f2fb3d1 100644 --- a/src/schematic/webhooks/raw_client.py +++ b/src/schematic/webhooks/raw_client.py @@ -6,7 +6,7 @@ from ..core.api_error import ApiError as core_api_error_ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder +from ..core.jsonable_encoder import encode_path_param from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -182,7 +182,7 @@ def get_webhook_event( OK """ _response = self._client_wrapper.httpx_client.request( - f"webhook-events/{jsonable_encoder(webhook_event_id)}", + f"webhook-events/{encode_path_param(webhook_event_id)}", method="GET", request_options=request_options, ) @@ -640,7 +640,7 @@ def get_webhook( OK """ _response = self._client_wrapper.httpx_client.request( - f"webhooks/{jsonable_encoder(webhook_id)}", + f"webhooks/{encode_path_param(webhook_id)}", method="GET", request_options=request_options, ) @@ -750,7 +750,7 @@ def update_webhook( OK """ _response = self._client_wrapper.httpx_client.request( - f"webhooks/{jsonable_encoder(webhook_id)}", + f"webhooks/{encode_path_param(webhook_id)}", method="PUT", json={ "credit_trigger_configs": convert_and_respect_annotation_metadata( @@ -868,7 +868,7 @@ def delete_webhook( OK """ _response = self._client_wrapper.httpx_client.request( - f"webhooks/{jsonable_encoder(webhook_id)}", + f"webhooks/{encode_path_param(webhook_id)}", method="DELETE", request_options=request_options, ) @@ -974,7 +974,7 @@ def send_test_webhook_action( OK """ _response = self._client_wrapper.httpx_client.request( - f"webhooks/{jsonable_encoder(webhook_id)}/test", + f"webhooks/{encode_path_param(webhook_id)}/test", method="POST", json={ "request_type": request_type, @@ -1324,7 +1324,7 @@ async def get_webhook_event( OK """ _response = await self._client_wrapper.httpx_client.request( - f"webhook-events/{jsonable_encoder(webhook_event_id)}", + f"webhook-events/{encode_path_param(webhook_event_id)}", method="GET", request_options=request_options, ) @@ -1782,7 +1782,7 @@ async def get_webhook( OK """ _response = await self._client_wrapper.httpx_client.request( - f"webhooks/{jsonable_encoder(webhook_id)}", + f"webhooks/{encode_path_param(webhook_id)}", method="GET", request_options=request_options, ) @@ -1892,7 +1892,7 @@ async def update_webhook( OK """ _response = await self._client_wrapper.httpx_client.request( - f"webhooks/{jsonable_encoder(webhook_id)}", + f"webhooks/{encode_path_param(webhook_id)}", method="PUT", json={ "credit_trigger_configs": convert_and_respect_annotation_metadata( @@ -2010,7 +2010,7 @@ async def delete_webhook( OK """ _response = await self._client_wrapper.httpx_client.request( - f"webhooks/{jsonable_encoder(webhook_id)}", + f"webhooks/{encode_path_param(webhook_id)}", method="DELETE", request_options=request_options, ) @@ -2116,7 +2116,7 @@ async def send_test_webhook_action( OK """ _response = await self._client_wrapper.httpx_client.request( - f"webhooks/{jsonable_encoder(webhook_id)}/test", + f"webhooks/{encode_path_param(webhook_id)}/test", method="POST", json={ "request_type": request_type, diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..25710db --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +import pytest + + +def _has_httpx_aiohttp() -> bool: + """Check if httpx_aiohttp is importable.""" + try: + import httpx_aiohttp # type: ignore[import-not-found] # noqa: F401 + + return True + except ImportError: + return False + + +def pytest_collection_modifyitems(config: pytest.Config, items: list) -> None: + """Auto-skip @pytest.mark.aiohttp tests when httpx_aiohttp is not installed.""" + if _has_httpx_aiohttp(): + return + skip_aiohttp = pytest.mark.skip(reason="httpx_aiohttp not installed") + for item in items: + if "aiohttp" in item.keywords: + item.add_marker(skip_aiohttp) diff --git a/tests/test_aiohttp_autodetect.py b/tests/test_aiohttp_autodetect.py new file mode 100644 index 0000000..ada597f --- /dev/null +++ b/tests/test_aiohttp_autodetect.py @@ -0,0 +1,116 @@ +import importlib +import sys +import unittest +from unittest import mock + +import httpx +import pytest + + +class TestMakeDefaultAsyncClientWithoutAiohttp(unittest.TestCase): + """Tests for _make_default_async_client when httpx_aiohttp is NOT installed.""" + + def test_returns_httpx_async_client(self) -> None: + """When httpx_aiohttp is not installed, returns plain httpx.AsyncClient.""" + with mock.patch.dict(sys.modules, {"httpx_aiohttp": None}): + from schematic.base_client import _make_default_async_client + + client = _make_default_async_client(timeout=60, follow_redirects=True) + self.assertIsInstance(client, httpx.AsyncClient) + self.assertEqual(client.timeout.read, 60) + self.assertTrue(client.follow_redirects) + + def test_follow_redirects_none(self) -> None: + """When follow_redirects is None, omits it from httpx.AsyncClient.""" + with mock.patch.dict(sys.modules, {"httpx_aiohttp": None}): + from schematic.base_client import _make_default_async_client + + client = _make_default_async_client(timeout=60, follow_redirects=None) + self.assertIsInstance(client, httpx.AsyncClient) + self.assertFalse(client.follow_redirects) + + def test_explicit_httpx_client_bypasses_autodetect(self) -> None: + """When user passes httpx_client explicitly, _make_default_async_client is not called.""" + + explicit_client = httpx.AsyncClient(timeout=120) + with mock.patch("schematic.base_client._make_default_async_client") as mock_make: + # Replicate the generated conditional: httpx_client if httpx_client is not None else _make_default_async_client(...) + result = explicit_client if explicit_client is not None else mock_make(timeout=60, follow_redirects=True) + mock_make.assert_not_called() + self.assertIs(result, explicit_client) + + +@pytest.mark.aiohttp +class TestMakeDefaultAsyncClientWithAiohttp(unittest.TestCase): + """Tests for _make_default_async_client when httpx_aiohttp IS installed.""" + + def test_returns_aiohttp_client(self) -> None: + """When httpx_aiohttp is installed, returns HttpxAiohttpClient.""" + import httpx_aiohttp # type: ignore[import-not-found] + + from schematic.base_client import _make_default_async_client + + client = _make_default_async_client(timeout=60, follow_redirects=True) + self.assertIsInstance(client, httpx_aiohttp.HttpxAiohttpClient) + self.assertEqual(client.timeout.read, 60) + self.assertTrue(client.follow_redirects) + + def test_follow_redirects_none(self) -> None: + """When httpx_aiohttp is installed and follow_redirects is None, omits it.""" + import httpx_aiohttp # type: ignore[import-not-found] + + from schematic.base_client import _make_default_async_client + + client = _make_default_async_client(timeout=60, follow_redirects=None) + self.assertIsInstance(client, httpx_aiohttp.HttpxAiohttpClient) + self.assertFalse(client.follow_redirects) + + +class TestDefaultClientsWithoutAiohttp(unittest.TestCase): + """Tests for _default_clients.py convenience classes (no aiohttp).""" + + def test_default_async_httpx_client_defaults(self) -> None: + """DefaultAsyncHttpxClient applies SDK defaults.""" + from schematic._default_clients import SDK_DEFAULT_TIMEOUT, DefaultAsyncHttpxClient + + client = DefaultAsyncHttpxClient() + self.assertIsInstance(client, httpx.AsyncClient) + self.assertEqual(client.timeout.read, SDK_DEFAULT_TIMEOUT) + self.assertTrue(client.follow_redirects) + + def test_default_async_httpx_client_overrides(self) -> None: + """DefaultAsyncHttpxClient allows overriding defaults.""" + from schematic._default_clients import DefaultAsyncHttpxClient + + client = DefaultAsyncHttpxClient(timeout=30, follow_redirects=False) + self.assertEqual(client.timeout.read, 30) + self.assertFalse(client.follow_redirects) + + def test_default_aiohttp_client_raises_without_package(self) -> None: + """DefaultAioHttpClient raises RuntimeError when httpx_aiohttp not installed.""" + import schematic._default_clients + + with mock.patch.dict(sys.modules, {"httpx_aiohttp": None}): + importlib.reload(schematic._default_clients) + + with self.assertRaises(RuntimeError) as ctx: + schematic._default_clients.DefaultAioHttpClient() + self.assertIn("pip install schematichq[aiohttp]", str(ctx.exception)) + + importlib.reload(schematic._default_clients) + + +@pytest.mark.aiohttp +class TestDefaultClientsWithAiohttp(unittest.TestCase): + """Tests for _default_clients.py when httpx_aiohttp IS installed.""" + + def test_default_aiohttp_client_defaults(self) -> None: + """DefaultAioHttpClient works when httpx_aiohttp is installed.""" + import httpx_aiohttp # type: ignore[import-not-found] + + from schematic._default_clients import SDK_DEFAULT_TIMEOUT, DefaultAioHttpClient + + client = DefaultAioHttpClient() + self.assertIsInstance(client, httpx_aiohttp.HttpxAiohttpClient) + self.assertEqual(client.timeout.read, SDK_DEFAULT_TIMEOUT) + self.assertTrue(client.follow_redirects) diff --git a/tests/utils/test_http_client.py b/tests/utils/test_http_client.py index f5a50ba..ed31173 100644 --- a/tests/utils/test_http_client.py +++ b/tests/utils/test_http_client.py @@ -1,13 +1,16 @@ # This file was auto-generated by Fern from our API Definition. from typing import Any, Dict +from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest from schematic.core.http_client import ( AsyncHttpClient, HttpClient, _build_url, + _should_retry, get_request_body, remove_none_from_dict, ) @@ -298,3 +301,394 @@ def test_preserves_base_url_path_prefix_trailing_slash() -> None: """Test that path prefixes in base URL are preserved.""" result = _build_url("https://cloud.example.com/org/tenant/api/", "/users") assert result == "https://cloud.example.com/org/tenant/api/users" + + +# --------------------------------------------------------------------------- +# Connection error retry tests +# --------------------------------------------------------------------------- + + +def _make_sync_http_client(mock_client: Any) -> HttpClient: + return HttpClient( + httpx_client=mock_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + ) + + +def _make_async_http_client(mock_client: Any) -> AsyncHttpClient: + return AsyncHttpClient( + httpx_client=mock_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + async_base_headers=None, + ) + + +@patch("schematic.core.http_client.time.sleep", return_value=None) +def test_sync_retries_on_connect_error(mock_sleep: MagicMock) -> None: + """Sync: connection error retries on httpx.ConnectError.""" + mock_client = MagicMock() + mock_client.request.side_effect = [ + httpx.ConnectError("connection failed"), + _DummyResponse(), + ] + http_client = _make_sync_http_client(mock_client) + + response = http_client.request(path="/test", method="GET") + + assert response.status_code == 200 + assert mock_client.request.call_count == 2 + mock_sleep.assert_called_once() + + +@patch("schematic.core.http_client.time.sleep", return_value=None) +def test_sync_retries_on_remote_protocol_error(mock_sleep: MagicMock) -> None: + """Sync: connection error retries on httpx.RemoteProtocolError.""" + mock_client = MagicMock() + mock_client.request.side_effect = [ + httpx.RemoteProtocolError("Remote end closed connection without response"), + _DummyResponse(), + ] + http_client = _make_sync_http_client(mock_client) + + response = http_client.request(path="/test", method="GET") + + assert response.status_code == 200 + assert mock_client.request.call_count == 2 + mock_sleep.assert_called_once() + + +@patch("schematic.core.http_client.time.sleep", return_value=None) +def test_sync_connection_error_exhausts_retries(mock_sleep: MagicMock) -> None: + """Sync: connection error exhausts retries then raises.""" + mock_client = MagicMock() + mock_client.request.side_effect = httpx.ConnectError("connection failed") + http_client = _make_sync_http_client(mock_client) + + with pytest.raises(httpx.ConnectError): + http_client.request( + path="/test", + method="GET", + request_options={"max_retries": 2}, + ) + + # 1 initial + 2 retries = 3 total attempts + assert mock_client.request.call_count == 3 + assert mock_sleep.call_count == 2 + + +@patch("schematic.core.http_client.time.sleep", return_value=None) +def test_sync_connection_error_respects_max_retries_zero(mock_sleep: MagicMock) -> None: + """Sync: connection error respects max_retries=0.""" + mock_client = MagicMock() + mock_client.request.side_effect = httpx.ConnectError("connection failed") + http_client = _make_sync_http_client(mock_client) + + with pytest.raises(httpx.ConnectError): + http_client.request( + path="/test", + method="GET", + request_options={"max_retries": 0}, + ) + + # No retries, just the initial attempt + assert mock_client.request.call_count == 1 + mock_sleep.assert_not_called() + + +@pytest.mark.asyncio +@patch("schematic.core.http_client.asyncio.sleep", new_callable=AsyncMock) +async def test_async_retries_on_connect_error(mock_sleep: AsyncMock) -> None: + """Async: connection error retries on httpx.ConnectError.""" + mock_client = MagicMock() + mock_client.request = AsyncMock( + side_effect=[ + httpx.ConnectError("connection failed"), + _DummyResponse(), + ] + ) + http_client = _make_async_http_client(mock_client) + + response = await http_client.request(path="/test", method="GET") + + assert response.status_code == 200 + assert mock_client.request.call_count == 2 + mock_sleep.assert_called_once() + + +@pytest.mark.asyncio +@patch("schematic.core.http_client.asyncio.sleep", new_callable=AsyncMock) +async def test_async_retries_on_remote_protocol_error(mock_sleep: AsyncMock) -> None: + """Async: connection error retries on httpx.RemoteProtocolError.""" + mock_client = MagicMock() + mock_client.request = AsyncMock( + side_effect=[ + httpx.RemoteProtocolError("Remote end closed connection without response"), + _DummyResponse(), + ] + ) + http_client = _make_async_http_client(mock_client) + + response = await http_client.request(path="/test", method="GET") + + assert response.status_code == 200 + assert mock_client.request.call_count == 2 + mock_sleep.assert_called_once() + + +@pytest.mark.asyncio +@patch("schematic.core.http_client.asyncio.sleep", new_callable=AsyncMock) +async def test_async_connection_error_exhausts_retries(mock_sleep: AsyncMock) -> None: + """Async: connection error exhausts retries then raises.""" + mock_client = MagicMock() + mock_client.request = AsyncMock(side_effect=httpx.ConnectError("connection failed")) + http_client = _make_async_http_client(mock_client) + + with pytest.raises(httpx.ConnectError): + await http_client.request( + path="/test", + method="GET", + request_options={"max_retries": 2}, + ) + + # 1 initial + 2 retries = 3 total attempts + assert mock_client.request.call_count == 3 + assert mock_sleep.call_count == 2 + + +# --------------------------------------------------------------------------- +# base_max_retries constructor parameter tests +# --------------------------------------------------------------------------- + + +def test_sync_http_client_default_base_max_retries() -> None: + """HttpClient defaults to base_max_retries=2.""" + http_client = HttpClient( + httpx_client=MagicMock(), # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + ) + assert http_client.base_max_retries == 2 + + +def test_async_http_client_default_base_max_retries() -> None: + """AsyncHttpClient defaults to base_max_retries=2.""" + http_client = AsyncHttpClient( + httpx_client=MagicMock(), # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + ) + assert http_client.base_max_retries == 2 + + +def test_sync_http_client_custom_base_max_retries() -> None: + """HttpClient accepts a custom base_max_retries value.""" + http_client = HttpClient( + httpx_client=MagicMock(), # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_max_retries=5, + ) + assert http_client.base_max_retries == 5 + + +def test_async_http_client_custom_base_max_retries() -> None: + """AsyncHttpClient accepts a custom base_max_retries value.""" + http_client = AsyncHttpClient( + httpx_client=MagicMock(), # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_max_retries=5, + ) + assert http_client.base_max_retries == 5 + + +@patch("schematic.core.http_client.time.sleep", return_value=None) +def test_sync_base_max_retries_zero_disables_retries(mock_sleep: MagicMock) -> None: + """Sync: base_max_retries=0 disables retries when no request_options override.""" + mock_client = MagicMock() + mock_client.request.side_effect = httpx.ConnectError("connection failed") + http_client = HttpClient( + httpx_client=mock_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + base_max_retries=0, + ) + + with pytest.raises(httpx.ConnectError): + http_client.request(path="/test", method="GET") + + # No retries, just the initial attempt + assert mock_client.request.call_count == 1 + mock_sleep.assert_not_called() + + +@pytest.mark.asyncio +@patch("schematic.core.http_client.asyncio.sleep", new_callable=AsyncMock) +async def test_async_base_max_retries_zero_disables_retries(mock_sleep: AsyncMock) -> None: + """Async: base_max_retries=0 disables retries when no request_options override.""" + mock_client = MagicMock() + mock_client.request = AsyncMock(side_effect=httpx.ConnectError("connection failed")) + http_client = AsyncHttpClient( + httpx_client=mock_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + base_max_retries=0, + ) + + with pytest.raises(httpx.ConnectError): + await http_client.request(path="/test", method="GET") + + # No retries, just the initial attempt + assert mock_client.request.call_count == 1 + mock_sleep.assert_not_called() + + +@patch("schematic.core.http_client.time.sleep", return_value=None) +def test_sync_request_options_override_base_max_retries(mock_sleep: MagicMock) -> None: + """Sync: request_options max_retries overrides base_max_retries.""" + mock_client = MagicMock() + mock_client.request.side_effect = [ + httpx.ConnectError("connection failed"), + httpx.ConnectError("connection failed"), + _DummyResponse(), + ] + http_client = HttpClient( + httpx_client=mock_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + base_max_retries=0, # base says no retries + ) + + # But request_options overrides to allow 2 retries + response = http_client.request( + path="/test", + method="GET", + request_options={"max_retries": 2}, + ) + + assert response.status_code == 200 + # 1 initial + 2 retries = 3 total attempts + assert mock_client.request.call_count == 3 + + +@pytest.mark.asyncio +@patch("schematic.core.http_client.asyncio.sleep", new_callable=AsyncMock) +async def test_async_request_options_override_base_max_retries(mock_sleep: AsyncMock) -> None: + """Async: request_options max_retries overrides base_max_retries.""" + mock_client = MagicMock() + mock_client.request = AsyncMock( + side_effect=[ + httpx.ConnectError("connection failed"), + httpx.ConnectError("connection failed"), + _DummyResponse(), + ] + ) + http_client = AsyncHttpClient( + httpx_client=mock_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + base_max_retries=0, # base says no retries + ) + + # But request_options overrides to allow 2 retries + response = await http_client.request( + path="/test", + method="GET", + request_options={"max_retries": 2}, + ) + + assert response.status_code == 200 + # 1 initial + 2 retries = 3 total attempts + assert mock_client.request.call_count == 3 + + +@patch("schematic.core.http_client.time.sleep", return_value=None) +def test_sync_base_max_retries_used_as_default(mock_sleep: MagicMock) -> None: + """Sync: base_max_retries is used when request_options has no max_retries.""" + mock_client = MagicMock() + mock_client.request.side_effect = [ + httpx.ConnectError("fail"), + httpx.ConnectError("fail"), + httpx.ConnectError("fail"), + _DummyResponse(), + ] + http_client = HttpClient( + httpx_client=mock_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + base_max_retries=3, + ) + + response = http_client.request(path="/test", method="GET") + + assert response.status_code == 200 + # 1 initial + 3 retries = 4 total attempts + assert mock_client.request.call_count == 4 + + +@pytest.mark.asyncio +@patch("schematic.core.http_client.asyncio.sleep", new_callable=AsyncMock) +async def test_async_base_max_retries_used_as_default(mock_sleep: AsyncMock) -> None: + """Async: base_max_retries is used when request_options has no max_retries.""" + mock_client = MagicMock() + mock_client.request = AsyncMock( + side_effect=[ + httpx.ConnectError("fail"), + httpx.ConnectError("fail"), + httpx.ConnectError("fail"), + _DummyResponse(), + ] + ) + http_client = AsyncHttpClient( + httpx_client=mock_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + base_max_retries=3, + ) + + response = await http_client.request(path="/test", method="GET") + + assert response.status_code == 200 + # 1 initial + 3 retries = 4 total attempts + assert mock_client.request.call_count == 4 + + +# --------------------------------------------------------------------------- +# _should_retry unit tests +# --------------------------------------------------------------------------- + + +def _make_response(status_code: int) -> httpx.Response: + return httpx.Response(status_code=status_code, content=b"") + + +@pytest.mark.parametrize( + "status_code", + [408, 409, 429, 500, 501, 502, 503, 504, 599], +) +def test_should_retry_retryable_status_codes(status_code: int) -> None: + """Legacy mode: retries on 408, 409, 429, and all >= 500.""" + assert _should_retry(_make_response(status_code)) is True + + +@pytest.mark.parametrize( + "status_code", + [200, 201, 301, 400, 401, 403, 404], +) +def test_should_not_retry_non_retryable_status_codes(status_code: int) -> None: + assert _should_retry(_make_response(status_code)) is False + + +def test_should_retry_599_upper_boundary() -> None: + """Legacy mode retries on >= 500, which includes 599.""" + assert _should_retry(_make_response(599)) is True