diff --git a/generators/python/sdk/CHANGELOG.md b/generators/python/sdk/CHANGELOG.md index ccc8cf649e3..79827bb6906 100644 --- a/generators/python/sdk/CHANGELOG.md +++ b/generators/python/sdk/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.3.3] - 2024-08-02 + +- Fix: The generator now allows you to extend aliased types (as long as they're objects). + ## [3.3.2] - 2024-08-02 - Fix: regression in readme generation introduced in 3.3.1 diff --git a/generators/python/sdk/VERSION b/generators/python/sdk/VERSION index 47725433179..619b5376684 100644 --- a/generators/python/sdk/VERSION +++ b/generators/python/sdk/VERSION @@ -1 +1 @@ -3.3.2 +3.3.3 diff --git a/generators/python/src/fern_python/generator_cli/readme_snippet_builder.py b/generators/python/src/fern_python/generator_cli/readme_snippet_builder.py index 364f98be634..d171641a3de 100644 --- a/generators/python/src/fern_python/generator_cli/readme_snippet_builder.py +++ b/generators/python/src/fern_python/generator_cli/readme_snippet_builder.py @@ -117,7 +117,7 @@ def _build_timeout_snippets(self) -> List[str]: client_instantiation = AST.ClassInstantiation( class_=self._root_client.sync_client.class_reference, - args=[AST.Expression("..."), AST.Expression('timeout=20.0')], + args=[AST.Expression("..."), AST.Expression("timeout=20.0")], ) def _client_writer(writer: AST.NodeWriter) -> None: diff --git a/generators/python/src/fern_python/generators/context/pydantic_generator_context_impl.py b/generators/python/src/fern_python/generators/context/pydantic_generator_context_impl.py index adab11f5053..c7ff8288a4e 100644 --- a/generators/python/src/fern_python/generators/context/pydantic_generator_context_impl.py +++ b/generators/python/src/fern_python/generators/context/pydantic_generator_context_impl.py @@ -136,7 +136,16 @@ def get_filepath_for_type_id(self, type_id: ir_types.TypeId, as_request: bool) - def get_all_properties_including_extensions(self, type_name: ir_types.TypeId) -> List[ir_types.ObjectProperty]: declaration = self.get_declaration_for_type_id(type_name) shape = declaration.shape.get_as_union() - if shape.type != "object": + + if shape.type == "alias": + resolved_type_union = shape.resolved_type.get_as_union() + if resolved_type_union.type != "named": + raise RuntimeError( + f"Cannot get properties because {declaration.name.name.original_name} is not an object, it's a {shape.type}" + ) + else: + return self.get_all_properties_including_extensions(resolved_type_union.name.type_id) + elif shape.type != "object": raise RuntimeError( f"Cannot get properties because {declaration.name.name.original_name} is not an object, it's a {shape.type}" ) diff --git a/seed/python-sdk/alias-extends/.github/workflows/ci.yml b/seed/python-sdk/alias-extends/.github/workflows/ci.yml new file mode 100644 index 00000000000..b7316b8cab7 --- /dev/null +++ b/seed/python-sdk/alias-extends/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: ci + +on: [push] +jobs: + compile: + runs-on: ubuntu-20.04 + steps: + - name: Checkout repo + uses: actions/checkout@v3 + - name: Set up python + uses: actions/setup-python@v4 + with: + python-version: 3.8 + - name: Bootstrap poetry + run: | + curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + - name: Install dependencies + run: poetry install + - name: Compile + run: poetry run mypy . + test: + runs-on: ubuntu-20.04 + steps: + - name: Checkout repo + uses: actions/checkout@v3 + - name: Set up python + uses: actions/setup-python@v4 + with: + python-version: 3.8 + - name: Bootstrap poetry + run: | + curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + - name: Install dependencies + run: poetry install + + - name: Install Fern + run: npm install -g fern-api + - name: Test + run: fern test --command "poetry run pytest -rP ." + + publish: + needs: [compile, test] + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: ubuntu-20.04 + steps: + - name: Checkout repo + uses: actions/checkout@v3 + - name: Set up python + uses: actions/setup-python@v4 + with: + python-version: 3.8 + - name: Bootstrap poetry + run: | + curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + - name: Install dependencies + run: poetry install + - name: Publish to pypi + run: | + poetry config repositories.remote + poetry --no-interaction -v publish --build --repository remote --username "$" --password "$" + env: + : ${{ secrets. }} + : ${{ secrets. }} diff --git a/seed/python-sdk/alias-extends/.gitignore b/seed/python-sdk/alias-extends/.gitignore new file mode 100644 index 00000000000..42cb863501e --- /dev/null +++ b/seed/python-sdk/alias-extends/.gitignore @@ -0,0 +1,4 @@ +dist/ +.mypy_cache/ +__pycache__/ +poetry.toml diff --git a/seed/python-sdk/alias-extends/.mock/definition/__package__.yml b/seed/python-sdk/alias-extends/.mock/definition/__package__.yml new file mode 100644 index 00000000000..b93c9081fd9 --- /dev/null +++ b/seed/python-sdk/alias-extends/.mock/definition/__package__.yml @@ -0,0 +1,40 @@ +service: + auth: false + base-path: /extends + endpoints: + extendedInlineRequestBody: + path: /extended-inline-request-body + method: POST + request: + name: InlinedChildRequest + body: + extends: AliasType + properties: + child: string + +types: + AliasType: + type: Parent + examples: + - name: One + value: + parent: Property from the parent + + Parent: + properties: + parent: string + examples: + - name: One + value: + parent: Property from the parent + + Child: + extends: Parent + properties: + child: string + examples: + - name: One + value: + parent: Property from the parent + child: Property from the child + diff --git a/seed/python-sdk/alias-extends/.mock/definition/api.yml b/seed/python-sdk/alias-extends/.mock/definition/api.yml new file mode 100644 index 00000000000..fe47afdc2d2 --- /dev/null +++ b/seed/python-sdk/alias-extends/.mock/definition/api.yml @@ -0,0 +1,2 @@ +name: alias-extends +docs: A Test Definition for extending an alias \ No newline at end of file diff --git a/seed/python-sdk/alias-extends/.mock/fern.config.json b/seed/python-sdk/alias-extends/.mock/fern.config.json new file mode 100644 index 00000000000..4c8e54ac313 --- /dev/null +++ b/seed/python-sdk/alias-extends/.mock/fern.config.json @@ -0,0 +1 @@ +{"organization": "fern-test", "version": "*"} \ No newline at end of file diff --git a/seed/python-sdk/alias-extends/.mock/generators.yml b/seed/python-sdk/alias-extends/.mock/generators.yml new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/seed/python-sdk/alias-extends/.mock/generators.yml @@ -0,0 +1 @@ +{} diff --git a/seed/python-sdk/alias-extends/README.md b/seed/python-sdk/alias-extends/README.md new file mode 100644 index 00000000000..57024e6070c --- /dev/null +++ b/seed/python-sdk/alias-extends/README.md @@ -0,0 +1,136 @@ +# Seed Python Library + +[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-SDK%20generated%20by%20Fern-brightgreen)](https://github.com/fern-api/fern) +[![pypi](https://img.shields.io/pypi/v/fern_alias-extends)](https://pypi.python.org/pypi/fern_alias-extends) + +The Seed Python library provides convenient access to the Seed API from Python. + +## Installation + +```sh +pip install fern_alias-extends +``` + +## Usage + +Instantiate and use the client with the following: + +```python +from seed import SeedAliasExtends + +client = SeedAliasExtends( + base_url="https://yourhost.com/path/to/api", +) +client.extended_inline_request_body( + child="string", + parent="string", +) +``` + +## Async Client + +The SDK also exports an `async` client so that you can make non-blocking calls to our API. + +```python +import asyncio + +from seed import AsyncSeedAliasExtends + +client = AsyncSeedAliasExtends( + base_url="https://yourhost.com/path/to/api", +) + + +async def main() -> None: + await client.extended_inline_request_body( + child="string", + parent="string", + ) + + +asyncio.run(main()) +``` + +## Exception Handling + +When the API returns a non-success status code (4xx or 5xx response), a subclass of the following error +will be thrown. + +```python +from seed.core.api_error import ApiError + +try: + client.extended_inline_request_body() +except ApiError as e: + print(e.status_code) + print(e.body) +``` + +## Advanced + +### Retries + +The SDK is instrumented with automatic retries with exponential backoff. A request will be retried as long +as the request is deemed retriable and the number of retry attempts has not grown larger than the configured +retry limit (default: 2). + +A request is deemed retriable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` request option to configure this behavior. + +```python +client.extended_inline_request_body({ + "max_retries": 1 +}) +``` + +### Timeouts + +The SDK defaults to a 60 second timeout. You can configure this with a timeout option at the client or request level. + +```python + +from seed import SeedAliasExtends + +client = SeedAliasExtends( + ..., + timeout=20.0, +) + + +# Override timeout for a specific method +client.extended_inline_request_body({ + "timeout_in_seconds": 1 +}) +``` + +### Custom Client + +You can override the `httpx` client to customize it for your use-case. Some common use-cases include support for proxies +and transports. +```python +import httpx +from seed import SeedAliasExtends + +client = SeedAliasExtends( + ..., + httpx_client=httpx.Client( + proxies="http://my.test.proxy.example.com", + transport=httpx.HTTPTransport(local_address="0.0.0.0"), + ), +) +``` + +## Contributing + +While we value open-source contributions to this SDK, this library is generated programmatically. +Additions made directly to this library would have to be moved over to our generation code, +otherwise they would be overwritten upon the next generated release. Feel free to open a PR as +a proof of concept, but know that we will not be able to merge it as-is. We suggest opening +an issue first to discuss with us! + +On the other hand, contributions to the README are always very welcome! diff --git a/seed/python-sdk/alias-extends/pyproject.toml b/seed/python-sdk/alias-extends/pyproject.toml new file mode 100644 index 00000000000..d504d7617b7 --- /dev/null +++ b/seed/python-sdk/alias-extends/pyproject.toml @@ -0,0 +1,57 @@ +[tool.poetry] +name = "fern_alias-extends" +version = "0.0.1" +description = "" +readme = "README.md" +authors = [] +keywords = [] + +classifiers = [ + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: OS Independent", + "Operating System :: POSIX", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed" +] +packages = [ + { include = "seed", from = "src"} +] + +[project.urls] +Repository = 'https://github.com/alias-extends/fern' + +[tool.poetry.dependencies] +python = "^3.8" +httpx = ">=0.21.2" +pydantic = ">= 1.9.2" +pydantic-core = "^2.18.2" +typing_extensions = ">= 4.0.0" + +[tool.poetry.dev-dependencies] +mypy = "1.0.1" +pytest = "^7.4.0" +pytest-asyncio = "^0.23.5" +python-dateutil = "^2.9.0" +types-python-dateutil = "^2.9.0.20240316" + +[tool.pytest.ini_options] +testpaths = [ "tests" ] +asyncio_mode = "auto" + +[tool.mypy] +plugins = ["pydantic.mypy"] + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/seed/python-sdk/alias-extends/reference.md b/seed/python-sdk/alias-extends/reference.md new file mode 100644 index 00000000000..6171df043ba --- /dev/null +++ b/seed/python-sdk/alias-extends/reference.md @@ -0,0 +1,66 @@ +# Reference +
client.extended_inline_request_body(...) +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from seed import SeedAliasExtends + +client = SeedAliasExtends( + base_url="https://yourhost.com/path/to/api", +) +client.extended_inline_request_body( + child="string", + parent="string", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**child:** `str` + +
+
+ +
+
+ +**parent:** `str` + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ diff --git a/seed/python-sdk/alias-extends/snippet-templates.json b/seed/python-sdk/alias-extends/snippet-templates.json new file mode 100644 index 00000000000..15633aa34b1 --- /dev/null +++ b/seed/python-sdk/alias-extends/snippet-templates.json @@ -0,0 +1,124 @@ +[ + { + "sdk": { + "package": "fern_alias-extends", + "version": "0.0.1", + "type": "python" + }, + "endpointId": { + "path": "/extends/extended-inline-request-body", + "method": "POST", + "identifierOverride": "endpoint_.extendedInlineRequestBody" + }, + "snippetTemplate": { + "clientInstantiation": { + "imports": [ + "from seed import SeedAliasExtends" + ], + "isOptional": true, + "templateString": "client = SeedAliasExtends(\n base_url=\"https://yourhost.com/path/to/api\",\n)", + "templateInputs": [], + "inputDelimiter": ",", + "type": "generic" + }, + "functionInvocation": { + "imports": [], + "isOptional": true, + "templateString": "client.extended_inline_request_body(\n\t$FERN_INPUT\n)", + "templateInputs": [ + { + "type": "template", + "value": { + "imports": [], + "isOptional": true, + "templateString": "child=$FERN_INPUT", + "templateInputs": [ + { + "location": "BODY", + "path": "child", + "type": "payload" + } + ], + "type": "generic" + } + }, + { + "type": "template", + "value": { + "imports": [], + "isOptional": true, + "templateString": "parent=$FERN_INPUT", + "templateInputs": [ + { + "location": "BODY", + "path": "parent", + "type": "payload" + } + ], + "type": "generic" + } + } + ], + "inputDelimiter": ",\n\t", + "type": "generic" + }, + "type": "v1" + }, + "additionalTemplates": { + "async": { + "clientInstantiation": { + "imports": [ + "from seed import AsyncSeedAliasExtends" + ], + "isOptional": true, + "templateString": "client = AsyncSeedAliasExtends(\n base_url=\"https://yourhost.com/path/to/api\",\n)", + "templateInputs": [], + "inputDelimiter": ",", + "type": "generic" + }, + "functionInvocation": { + "imports": [], + "isOptional": true, + "templateString": "await client.extended_inline_request_body(\n\t$FERN_INPUT\n)", + "templateInputs": [ + { + "type": "template", + "value": { + "imports": [], + "isOptional": true, + "templateString": "child=$FERN_INPUT", + "templateInputs": [ + { + "location": "BODY", + "path": "child", + "type": "payload" + } + ], + "type": "generic" + } + }, + { + "type": "template", + "value": { + "imports": [], + "isOptional": true, + "templateString": "parent=$FERN_INPUT", + "templateInputs": [ + { + "location": "BODY", + "path": "parent", + "type": "payload" + } + ], + "type": "generic" + } + } + ], + "inputDelimiter": ",\n\t", + "type": "generic" + }, + "type": "v1" + } + } + } +] \ No newline at end of file diff --git a/seed/python-sdk/alias-extends/snippet.json b/seed/python-sdk/alias-extends/snippet.json new file mode 100644 index 00000000000..478b85268c4 --- /dev/null +++ b/seed/python-sdk/alias-extends/snippet.json @@ -0,0 +1,22 @@ +{ + "types": { + "type_:AliasType": "from seed import Parent\n\nParent(\n parent=\"Property from the parent\",\n)\n", + "type_:Parent": "from seed import Parent\n\nParent(\n parent=\"Property from the parent\",\n)\n", + "type_:Child": "from seed import Child\n\nChild(\n parent=\"Property from the parent\",\n child=\"Property from the child\",\n)\n" + }, + "endpoints": [ + { + "example_identifier": "default", + "id": { + "path": "/extends/extended-inline-request-body", + "method": "POST", + "identifier_override": "endpoint_.extendedInlineRequestBody" + }, + "snippet": { + "sync_client": "from seed import SeedAliasExtends\n\nclient = SeedAliasExtends(\n base_url=\"https://yourhost.com/path/to/api\",\n)\nclient.extended_inline_request_body(\n child=\"string\",\n parent=\"string\",\n)\n", + "async_client": "import asyncio\n\nfrom seed import AsyncSeedAliasExtends\n\nclient = AsyncSeedAliasExtends(\n base_url=\"https://yourhost.com/path/to/api\",\n)\n\n\nasync def main() -> None:\n await client.extended_inline_request_body(\n child=\"string\",\n parent=\"string\",\n )\n\n\nasyncio.run(main())\n", + "type": "python" + } + } + ] +} \ No newline at end of file diff --git a/seed/python-sdk/alias-extends/src/seed/__init__.py b/seed/python-sdk/alias-extends/src/seed/__init__.py new file mode 100644 index 00000000000..b5fdfda437c --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/__init__.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from .types import AliasType, Child, Parent +from .client import AsyncSeedAliasExtends, SeedAliasExtends +from .version import __version__ + +__all__ = ["AliasType", "AsyncSeedAliasExtends", "Child", "Parent", "SeedAliasExtends", "__version__"] diff --git a/seed/python-sdk/alias-extends/src/seed/client.py b/seed/python-sdk/alias-extends/src/seed/client.py new file mode 100644 index 00000000000..34f650cade3 --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/client.py @@ -0,0 +1,203 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing +from json.decoder import JSONDecodeError + +import httpx + +from .core.api_error import ApiError +from .core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from .core.request_options import RequestOptions + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class SeedAliasExtends: + """ + 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. + + Parameters + ---------- + base_url : str + The base url to use for requests from the client. + + 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. + + 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. + + httpx_client : typing.Optional[httpx.Client] + The httpx client to use for making requests, a preconfigured client is used by default, however this is useful should you want to pass in any custom httpx configuration. + + Examples + -------- + from seed import SeedAliasExtends + + client = SeedAliasExtends( + base_url="https://yourhost.com/path/to/api", + ) + """ + + def __init__( + self, + *, + base_url: str, + timeout: typing.Optional[float] = None, + follow_redirects: typing.Optional[bool] = True, + httpx_client: typing.Optional[httpx.Client] = None + ): + _defaulted_timeout = timeout if timeout is not None else 60 if httpx_client is None else None + self._client_wrapper = SyncClientWrapper( + base_url=base_url, + httpx_client=httpx_client + if httpx_client is not None + else httpx.Client(timeout=_defaulted_timeout, follow_redirects=follow_redirects) + if follow_redirects is not None + else httpx.Client(timeout=_defaulted_timeout), + timeout=_defaulted_timeout, + ) + + def extended_inline_request_body( + self, *, child: str, parent: str, request_options: typing.Optional[RequestOptions] = None + ) -> None: + """ + Parameters + ---------- + child : str + + parent : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from seed import SeedAliasExtends + + client = SeedAliasExtends( + base_url="https://yourhost.com/path/to/api", + ) + client.extended_inline_request_body( + child="string", + parent="string", + ) + """ + _response = self._client_wrapper.httpx_client.request( + "extends/extended-inline-request-body", + method="POST", + json={"child": child, "parent": parent}, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, body=_response.text) + raise ApiError(status_code=_response.status_code, body=_response_json) + + +class AsyncSeedAliasExtends: + """ + 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. + + Parameters + ---------- + base_url : str + The base url to use for requests from the client. + + 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. + + 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. + + httpx_client : typing.Optional[httpx.AsyncClient] + The httpx client to use for making requests, a preconfigured client is used by default, however this is useful should you want to pass in any custom httpx configuration. + + Examples + -------- + from seed import AsyncSeedAliasExtends + + client = AsyncSeedAliasExtends( + base_url="https://yourhost.com/path/to/api", + ) + """ + + def __init__( + self, + *, + base_url: str, + timeout: typing.Optional[float] = None, + follow_redirects: typing.Optional[bool] = True, + httpx_client: typing.Optional[httpx.AsyncClient] = None + ): + _defaulted_timeout = timeout if timeout is not None else 60 if httpx_client is None else None + self._client_wrapper = AsyncClientWrapper( + base_url=base_url, + 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), + timeout=_defaulted_timeout, + ) + + async def extended_inline_request_body( + self, *, child: str, parent: str, request_options: typing.Optional[RequestOptions] = None + ) -> None: + """ + Parameters + ---------- + child : str + + parent : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from seed import AsyncSeedAliasExtends + + client = AsyncSeedAliasExtends( + base_url="https://yourhost.com/path/to/api", + ) + + + async def main() -> None: + await client.extended_inline_request_body( + child="string", + parent="string", + ) + + + asyncio.run(main()) + """ + _response = await self._client_wrapper.httpx_client.request( + "extends/extended-inline-request-body", + method="POST", + json={"child": child, "parent": parent}, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, body=_response.text) + raise ApiError(status_code=_response.status_code, body=_response_json) diff --git a/seed/python-sdk/alias-extends/src/seed/core/__init__.py b/seed/python-sdk/alias-extends/src/seed/core/__init__.py new file mode 100644 index 00000000000..5a0bee343d0 --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/core/__init__.py @@ -0,0 +1,48 @@ +# This file was auto-generated by Fern from our API Definition. + +from .api_error import ApiError +from .client_wrapper import AsyncClientWrapper, BaseClientWrapper, SyncClientWrapper +from .datetime_utils import serialize_datetime +from .file import File, convert_file_dict_to_httpx_tuples +from .http_client import AsyncHttpClient, HttpClient +from .jsonable_encoder import jsonable_encoder +from .pydantic_utilities import ( + IS_PYDANTIC_V2, + UniversalBaseModel, + UniversalRootModel, + deep_union_pydantic_dicts, + parse_obj_as, + universal_field_validator, + universal_root_validator, + update_forward_refs, +) +from .query_encoder import encode_query +from .remove_none_from_dict import remove_none_from_dict +from .request_options import RequestOptions +from .serialization import FieldMetadata, convert_and_respect_annotation_metadata + +__all__ = [ + "ApiError", + "AsyncClientWrapper", + "AsyncHttpClient", + "BaseClientWrapper", + "FieldMetadata", + "File", + "HttpClient", + "IS_PYDANTIC_V2", + "RequestOptions", + "SyncClientWrapper", + "UniversalBaseModel", + "UniversalRootModel", + "convert_and_respect_annotation_metadata", + "convert_file_dict_to_httpx_tuples", + "deep_union_pydantic_dicts", + "encode_query", + "jsonable_encoder", + "parse_obj_as", + "remove_none_from_dict", + "serialize_datetime", + "universal_field_validator", + "universal_root_validator", + "update_forward_refs", +] diff --git a/seed/python-sdk/alias-extends/src/seed/core/api_error.py b/seed/python-sdk/alias-extends/src/seed/core/api_error.py new file mode 100644 index 00000000000..2e9fc5431cd --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/core/api_error.py @@ -0,0 +1,15 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + + +class ApiError(Exception): + status_code: typing.Optional[int] + body: typing.Any + + def __init__(self, *, status_code: typing.Optional[int] = None, body: typing.Any = None): + self.status_code = status_code + self.body = body + + def __str__(self) -> str: + return f"status_code: {self.status_code}, body: {self.body}" diff --git a/seed/python-sdk/alias-extends/src/seed/core/client_wrapper.py b/seed/python-sdk/alias-extends/src/seed/core/client_wrapper.py new file mode 100644 index 00000000000..96c69525624 --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/core/client_wrapper.py @@ -0,0 +1,49 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import httpx + +from .http_client import AsyncHttpClient, HttpClient + + +class BaseClientWrapper: + def __init__(self, *, base_url: str, timeout: typing.Optional[float] = None): + self._base_url = base_url + self._timeout = timeout + + def get_headers(self) -> typing.Dict[str, str]: + headers: typing.Dict[str, str] = { + "X-Fern-Language": "Python", + "X-Fern-SDK-Name": "fern_alias-extends", + "X-Fern-SDK-Version": "0.0.1", + } + return headers + + def get_base_url(self) -> str: + return self._base_url + + def get_timeout(self) -> typing.Optional[float]: + return self._timeout + + +class SyncClientWrapper(BaseClientWrapper): + def __init__(self, *, base_url: str, timeout: typing.Optional[float] = None, httpx_client: httpx.Client): + super().__init__(base_url=base_url, timeout=timeout) + self.httpx_client = HttpClient( + httpx_client=httpx_client, + base_headers=self.get_headers(), + base_timeout=self.get_timeout(), + base_url=self.get_base_url(), + ) + + +class AsyncClientWrapper(BaseClientWrapper): + def __init__(self, *, base_url: str, timeout: typing.Optional[float] = None, httpx_client: httpx.AsyncClient): + super().__init__(base_url=base_url, timeout=timeout) + self.httpx_client = AsyncHttpClient( + httpx_client=httpx_client, + base_headers=self.get_headers(), + base_timeout=self.get_timeout(), + base_url=self.get_base_url(), + ) diff --git a/seed/python-sdk/alias-extends/src/seed/core/datetime_utils.py b/seed/python-sdk/alias-extends/src/seed/core/datetime_utils.py new file mode 100644 index 00000000000..7c9864a944c --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/core/datetime_utils.py @@ -0,0 +1,28 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt + + +def serialize_datetime(v: dt.datetime) -> str: + """ + Serialize a datetime including timezone info. + + Uses the timezone info provided if present, otherwise uses the current runtime's timezone info. + + UTC datetimes end in "Z" while all other timezones are represented as offset from UTC, e.g. +05:00. + """ + + def _serialize_zoned_datetime(v: dt.datetime) -> str: + if v.tzinfo is not None and v.tzinfo.tzname(None) == dt.timezone.utc.tzname(None): + # UTC is a special case where we use "Z" at the end instead of "+00:00" + return v.isoformat().replace("+00:00", "Z") + else: + # Delegate to the typical +/- offset format + return v.isoformat() + + if v.tzinfo is not None: + return _serialize_zoned_datetime(v) + else: + local_tz = dt.datetime.now().astimezone().tzinfo + localized_dt = v.replace(tzinfo=local_tz) + return _serialize_zoned_datetime(localized_dt) diff --git a/seed/python-sdk/alias-extends/src/seed/core/file.py b/seed/python-sdk/alias-extends/src/seed/core/file.py new file mode 100644 index 00000000000..cb0d40bbbf3 --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/core/file.py @@ -0,0 +1,38 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +# File typing inspired by the flexibility of types within the httpx library +# https://github.com/encode/httpx/blob/master/httpx/_types.py +FileContent = typing.Union[typing.IO[bytes], bytes, str] +File = typing.Union[ + # file (or bytes) + FileContent, + # (filename, file (or bytes)) + typing.Tuple[typing.Optional[str], FileContent], + # (filename, file (or bytes), content_type) + typing.Tuple[typing.Optional[str], FileContent, typing.Optional[str]], + # (filename, file (or bytes), content_type, headers) + typing.Tuple[typing.Optional[str], FileContent, typing.Optional[str], typing.Mapping[str, str]], +] + + +def convert_file_dict_to_httpx_tuples( + d: typing.Dict[str, typing.Union[File, typing.List[File]]] +) -> typing.List[typing.Tuple[str, File]]: + """ + The format we use is a list of tuples, where the first element is the + name of the file and the second is the file object. Typically HTTPX wants + a dict, but to be able to send lists of files, you have to use the list + approach (which also works for non-lists) + https://github.com/encode/httpx/pull/1032 + """ + + httpx_tuples = [] + for key, file_like in d.items(): + if isinstance(file_like, list): + for file_like_item in file_like: + httpx_tuples.append((key, file_like_item)) + else: + httpx_tuples.append((key, file_like)) + return httpx_tuples diff --git a/seed/python-sdk/alias-extends/src/seed/core/http_client.py b/seed/python-sdk/alias-extends/src/seed/core/http_client.py new file mode 100644 index 00000000000..9333d8a7f15 --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/core/http_client.py @@ -0,0 +1,475 @@ +# This file was auto-generated by Fern from our API Definition. + +import asyncio +import email.utils +import json +import re +import time +import typing +import urllib.parse +from contextlib import asynccontextmanager, contextmanager +from random import random + +import httpx + +from .file import File, convert_file_dict_to_httpx_tuples +from .jsonable_encoder import jsonable_encoder +from .query_encoder import encode_query +from .remove_none_from_dict import remove_none_from_dict +from .request_options import RequestOptions + +INITIAL_RETRY_DELAY_SECONDS = 0.5 +MAX_RETRY_DELAY_SECONDS = 10 +MAX_RETRY_DELAY_SECONDS_FROM_HEADER = 30 + + +def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float]: + """ + This function parses the `Retry-After` header in a HTTP response and returns the number of seconds to wait. + + Inspired by the urllib3 retry implementation. + """ + retry_after_ms = response_headers.get("retry-after-ms") + if retry_after_ms is not None: + try: + return int(retry_after_ms) / 1000 if retry_after_ms > 0 else 0 + except Exception: + pass + + retry_after = response_headers.get("retry-after") + if retry_after is None: + return None + + # Attempt to parse the header as an int. + if re.match(r"^\s*[0-9]+\s*$", retry_after): + seconds = float(retry_after) + # Fallback to parsing it as a date. + else: + retry_date_tuple = email.utils.parsedate_tz(retry_after) + if retry_date_tuple is None: + return None + if retry_date_tuple[9] is None: # Python 2 + # Assume UTC if no timezone was specified + # On Python2.7, parsedate_tz returns None for a timezone offset + # instead of 0 if no timezone is given, where mktime_tz treats + # a None timezone offset as local time. + retry_date_tuple = retry_date_tuple[:9] + (0,) + retry_date_tuple[10:] + + retry_date = email.utils.mktime_tz(retry_date_tuple) + seconds = retry_date - time.time() + + if seconds < 0: + seconds = 0 + + return seconds + + +def _retry_timeout(response: httpx.Response, retries: int) -> float: + """ + Determine the amount of time to wait before retrying a request. + This function begins by trying to parse a retry-after header from the response, and then proceeds to use exponential backoff + with a jitter to determine the number of seconds to wait. + """ + + # If the API asks us to wait a certain amount of time (and it's a reasonable amount), just do what it says. + retry_after = _parse_retry_after(response.headers) + if retry_after is not None and retry_after <= MAX_RETRY_DELAY_SECONDS_FROM_HEADER: + return retry_after + + # Apply exponential backoff, capped at MAX_RETRY_DELAY_SECONDS. + retry_delay = min(INITIAL_RETRY_DELAY_SECONDS * pow(2.0, retries), MAX_RETRY_DELAY_SECONDS) + + # Add a randomness / jitter to the retry delay to avoid overwhelming the server with retries. + timeout = retry_delay * (1 - 0.25 * random()) + return timeout if timeout >= 0 else 0 + + +def _should_retry(response: httpx.Response) -> bool: + retriable_400s = [429, 408, 409] + return response.status_code >= 500 or response.status_code in retriable_400s + + +def remove_omit_from_dict( + original: typing.Dict[str, typing.Optional[typing.Any]], omit: typing.Optional[typing.Any] +) -> typing.Dict[str, typing.Any]: + if omit is None: + return original + new: typing.Dict[str, typing.Any] = {} + for key, value in original.items(): + if value is not omit: + new[key] = value + return new + + +def maybe_filter_request_body( + data: typing.Optional[typing.Any], + request_options: typing.Optional[RequestOptions], + omit: typing.Optional[typing.Any], +) -> typing.Optional[typing.Any]: + if data is None: + return ( + jsonable_encoder(request_options.get("additional_body_parameters", {})) or {} + if request_options is not None + else None + ) + elif not isinstance(data, typing.Mapping): + data_content = jsonable_encoder(data) + else: + data_content = { + **(jsonable_encoder(remove_omit_from_dict(data, omit))), # type: ignore + **( + jsonable_encoder(request_options.get("additional_body_parameters", {})) or {} + if request_options is not None + else {} + ), + } + return data_content + + +# Abstracted out for testing purposes +def get_request_body( + *, + json: typing.Optional[typing.Any], + data: typing.Optional[typing.Any], + request_options: typing.Optional[RequestOptions], + omit: typing.Optional[typing.Any], +) -> typing.Tuple[typing.Optional[typing.Any], typing.Optional[typing.Any]]: + json_body = None + data_body = None + if data is not None: + data_body = maybe_filter_request_body(data, request_options, omit) + else: + # If both data and json are None, we send json data in the event extra properties are specified + json_body = maybe_filter_request_body(json, request_options, omit) + + return json_body, data_body + + +class HttpClient: + def __init__( + self, + *, + httpx_client: httpx.Client, + base_timeout: typing.Optional[float], + base_headers: typing.Dict[str, str], + base_url: typing.Optional[str] = None, + ): + self.base_url = base_url + self.base_timeout = base_timeout + self.base_headers = base_headers + self.httpx_client = httpx_client + + def get_base_url(self, maybe_base_url: typing.Optional[str]) -> str: + base_url = self.base_url if maybe_base_url is None else maybe_base_url + if base_url is None: + raise ValueError("A base_url is required to make this request, please provide one and try again.") + return base_url + + def request( + self, + path: typing.Optional[str] = None, + *, + method: str, + base_url: typing.Optional[str] = None, + params: typing.Optional[typing.Dict[str, typing.Any]] = None, + json: typing.Optional[typing.Any] = None, + data: typing.Optional[typing.Any] = None, + content: typing.Optional[typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]]] = None, + files: typing.Optional[typing.Dict[str, typing.Optional[typing.Union[File, typing.List[File]]]]] = None, + headers: typing.Optional[typing.Dict[str, typing.Any]] = None, + request_options: typing.Optional[RequestOptions] = None, + retries: int = 0, + omit: typing.Optional[typing.Any] = None, + ) -> httpx.Response: + base_url = self.get_base_url(base_url) + timeout = ( + request_options.get("timeout_in_seconds") + if request_options is not None and request_options.get("timeout_in_seconds") is not None + else self.base_timeout + ) + + json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) + + response = self.httpx_client.request( + method=method, + url=urllib.parse.urljoin(f"{base_url}/", path), + headers=jsonable_encoder( + remove_none_from_dict( + { + **self.base_headers, + **(headers if headers is not None else {}), + **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), + } + ) + ), + params=encode_query( + jsonable_encoder( + remove_none_from_dict( + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) or {} + if request_options is not None + else {} + ), + }, + omit, + ) + ) + ) + ), + json=json_body, + data=data_body, + content=content, + files=convert_file_dict_to_httpx_tuples(remove_none_from_dict(files)) if files is not None else None, + timeout=timeout, + ) + + max_retries: int = request_options.get("max_retries", 0) if request_options is not None else 0 + if _should_retry(response=response): + if max_retries > retries: + time.sleep(_retry_timeout(response=response, retries=retries)) + return self.request( + path=path, + method=method, + base_url=base_url, + params=params, + json=json, + content=content, + files=files, + headers=headers, + request_options=request_options, + retries=retries + 1, + omit=omit, + ) + + return response + + @contextmanager + def stream( + self, + path: typing.Optional[str] = None, + *, + method: str, + base_url: typing.Optional[str] = None, + params: typing.Optional[typing.Dict[str, typing.Any]] = None, + json: typing.Optional[typing.Any] = None, + data: typing.Optional[typing.Any] = None, + content: typing.Optional[typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]]] = None, + files: typing.Optional[typing.Dict[str, typing.Optional[typing.Union[File, typing.List[File]]]]] = None, + headers: typing.Optional[typing.Dict[str, typing.Any]] = None, + request_options: typing.Optional[RequestOptions] = None, + retries: int = 0, + omit: typing.Optional[typing.Any] = None, + ) -> typing.Iterator[httpx.Response]: + base_url = self.get_base_url(base_url) + timeout = ( + request_options.get("timeout_in_seconds") + if request_options is not None and request_options.get("timeout_in_seconds") is not None + else self.base_timeout + ) + + json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) + + with self.httpx_client.stream( + method=method, + url=urllib.parse.urljoin(f"{base_url}/", path), + headers=jsonable_encoder( + remove_none_from_dict( + { + **self.base_headers, + **(headers if headers is not None else {}), + **(request_options.get("additional_headers", {}) if request_options is not None else {}), + } + ) + ), + params=encode_query( + jsonable_encoder( + remove_none_from_dict( + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) + if request_options is not None + else {} + ), + }, + omit, + ) + ) + ) + ), + json=json_body, + data=data_body, + content=content, + files=convert_file_dict_to_httpx_tuples(remove_none_from_dict(files)) if files is not None else None, + timeout=timeout, + ) as stream: + yield stream + + +class AsyncHttpClient: + def __init__( + self, + *, + httpx_client: httpx.AsyncClient, + base_timeout: typing.Optional[float], + base_headers: typing.Dict[str, str], + base_url: typing.Optional[str] = None, + ): + self.base_url = base_url + self.base_timeout = base_timeout + self.base_headers = base_headers + self.httpx_client = httpx_client + + def get_base_url(self, maybe_base_url: typing.Optional[str]) -> str: + base_url = self.base_url if maybe_base_url is None else maybe_base_url + if base_url is None: + raise ValueError("A base_url is required to make this request, please provide one and try again.") + return base_url + + async def request( + self, + path: typing.Optional[str] = None, + *, + method: str, + base_url: typing.Optional[str] = None, + params: typing.Optional[typing.Dict[str, typing.Any]] = None, + json: typing.Optional[typing.Any] = None, + data: typing.Optional[typing.Any] = None, + content: typing.Optional[typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]]] = None, + files: typing.Optional[typing.Dict[str, typing.Optional[typing.Union[File, typing.List[File]]]]] = None, + headers: typing.Optional[typing.Dict[str, typing.Any]] = None, + request_options: typing.Optional[RequestOptions] = None, + retries: int = 0, + omit: typing.Optional[typing.Any] = None, + ) -> httpx.Response: + base_url = self.get_base_url(base_url) + timeout = ( + request_options.get("timeout_in_seconds") + if request_options is not None and request_options.get("timeout_in_seconds") is not None + else self.base_timeout + ) + + json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) + + # Add the input to each of these and do None-safety checks + response = await self.httpx_client.request( + method=method, + url=urllib.parse.urljoin(f"{base_url}/", path), + headers=jsonable_encoder( + remove_none_from_dict( + { + **self.base_headers, + **(headers if headers is not None else {}), + **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), + } + ) + ), + params=encode_query( + jsonable_encoder( + remove_none_from_dict( + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) or {} + if request_options is not None + else {} + ), + }, + omit, + ) + ) + ) + ), + json=json_body, + data=data_body, + content=content, + files=convert_file_dict_to_httpx_tuples(remove_none_from_dict(files)) if files is not None else None, + timeout=timeout, + ) + + max_retries: int = request_options.get("max_retries", 0) if request_options is not None else 0 + if _should_retry(response=response): + if max_retries > retries: + await asyncio.sleep(_retry_timeout(response=response, retries=retries)) + return await self.request( + path=path, + method=method, + base_url=base_url, + params=params, + json=json, + content=content, + files=files, + headers=headers, + request_options=request_options, + retries=retries + 1, + omit=omit, + ) + return response + + @asynccontextmanager + async def stream( + self, + path: typing.Optional[str] = None, + *, + method: str, + base_url: typing.Optional[str] = None, + params: typing.Optional[typing.Dict[str, typing.Any]] = None, + json: typing.Optional[typing.Any] = None, + data: typing.Optional[typing.Any] = None, + content: typing.Optional[typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]]] = None, + files: typing.Optional[typing.Dict[str, typing.Optional[typing.Union[File, typing.List[File]]]]] = None, + headers: typing.Optional[typing.Dict[str, typing.Any]] = None, + request_options: typing.Optional[RequestOptions] = None, + retries: int = 0, + omit: typing.Optional[typing.Any] = None, + ) -> typing.AsyncIterator[httpx.Response]: + base_url = self.get_base_url(base_url) + timeout = ( + request_options.get("timeout_in_seconds") + if request_options is not None and request_options.get("timeout_in_seconds") is not None + else self.base_timeout + ) + + json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) + + async with self.httpx_client.stream( + method=method, + url=urllib.parse.urljoin(f"{base_url}/", path), + headers=jsonable_encoder( + remove_none_from_dict( + { + **self.base_headers, + **(headers if headers is not None else {}), + **(request_options.get("additional_headers", {}) if request_options is not None else {}), + } + ) + ), + params=encode_query( + jsonable_encoder( + remove_none_from_dict( + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) + if request_options is not None + else {} + ), + }, + omit=omit, + ) + ) + ) + ), + json=json_body, + data=data_body, + content=content, + files=convert_file_dict_to_httpx_tuples(remove_none_from_dict(files)) if files is not None else None, + timeout=timeout, + ) as stream: + yield stream diff --git a/seed/python-sdk/alias-extends/src/seed/core/jsonable_encoder.py b/seed/python-sdk/alias-extends/src/seed/core/jsonable_encoder.py new file mode 100644 index 00000000000..9251cd589d6 --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/core/jsonable_encoder.py @@ -0,0 +1,97 @@ +# This file was auto-generated by Fern from our API Definition. + +""" +jsonable_encoder converts a Python object to a JSON-friendly dict +(e.g. datetimes to strings, Pydantic models to dicts). + +Taken from FastAPI, and made a bit simpler +https://github.com/tiangolo/fastapi/blob/master/fastapi/encoders.py +""" + +import base64 +import dataclasses +import datetime as dt +from enum import Enum +from pathlib import PurePath +from types import GeneratorType +from typing import Any, Callable, Dict, List, Optional, Set, Union + +import pydantic + +from .datetime_utils import serialize_datetime +from .pydantic_utilities import IS_PYDANTIC_V2, encode_by_type, to_jsonable_with_fallback + +SetIntStr = Set[Union[int, str]] +DictIntStrAny = Dict[Union[int, str], Any] + + +def jsonable_encoder(obj: Any, custom_encoder: Optional[Dict[Any, Callable[[Any], Any]]] = None) -> Any: + custom_encoder = custom_encoder or {} + if custom_encoder: + if type(obj) in custom_encoder: + return custom_encoder[type(obj)](obj) + else: + for encoder_type, encoder_instance in custom_encoder.items(): + if isinstance(obj, encoder_type): + return encoder_instance(obj) + if isinstance(obj, pydantic.BaseModel): + if IS_PYDANTIC_V2: + encoder = getattr(obj.model_config, "json_encoders", {}) # type: ignore # Pydantic v2 + else: + encoder = getattr(obj.__config__, "json_encoders", {}) # type: ignore # Pydantic v1 + if custom_encoder: + encoder.update(custom_encoder) + obj_dict = obj.dict(by_alias=True) + if "__root__" in obj_dict: + obj_dict = obj_dict["__root__"] + if "root" in obj_dict: + obj_dict = obj_dict["root"] + return jsonable_encoder(obj_dict, custom_encoder=encoder) + if dataclasses.is_dataclass(obj): + obj_dict = dataclasses.asdict(obj) + return jsonable_encoder(obj_dict, custom_encoder=custom_encoder) + if isinstance(obj, bytes): + return base64.b64encode(obj).decode("utf-8") + if isinstance(obj, Enum): + return obj.value + if isinstance(obj, PurePath): + return str(obj) + if isinstance(obj, (str, int, float, type(None))): + return obj + if isinstance(obj, dt.datetime): + return serialize_datetime(obj) + if isinstance(obj, dt.date): + return str(obj) + if isinstance(obj, dict): + encoded_dict = {} + allowed_keys = set(obj.keys()) + for key, value in obj.items(): + if key in allowed_keys: + encoded_key = jsonable_encoder(key, custom_encoder=custom_encoder) + encoded_value = jsonable_encoder(value, custom_encoder=custom_encoder) + encoded_dict[encoded_key] = encoded_value + return encoded_dict + if isinstance(obj, (list, set, frozenset, GeneratorType, tuple)): + encoded_list = [] + for item in obj: + encoded_list.append(jsonable_encoder(item, custom_encoder=custom_encoder)) + return encoded_list + + def fallback_serializer(o: Any) -> Any: + attempt_encode = encode_by_type(o) + if attempt_encode is not None: + return attempt_encode + + try: + data = dict(o) + except Exception as e: + errors: List[Exception] = [] + errors.append(e) + try: + data = vars(o) + except Exception as e: + errors.append(e) + raise ValueError(errors) from e + return jsonable_encoder(data, custom_encoder=custom_encoder) + + return to_jsonable_with_fallback(obj, fallback_serializer) diff --git a/seed/python-sdk/alias-extends/src/seed/core/pydantic_utilities.py b/seed/python-sdk/alias-extends/src/seed/core/pydantic_utilities.py new file mode 100644 index 00000000000..7c5418b5cf7 --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/core/pydantic_utilities.py @@ -0,0 +1,179 @@ +# This file was auto-generated by Fern from our API Definition. + +# nopycln: file +import datetime as dt +import typing +from collections import defaultdict +from functools import wraps + +import pydantic + +from .datetime_utils import serialize_datetime + +IS_PYDANTIC_V2 = pydantic.VERSION.startswith("2.") + +if IS_PYDANTIC_V2: + # isort will try to reformat the comments on these imports, which breaks mypy + # isort: off + from pydantic.v1.datetime_parse import ( # type: ignore # pyright: ignore[reportMissingImports] # Pydantic v2 + parse_date as parse_date, + ) + from pydantic.v1.datetime_parse import ( # pyright: ignore[reportMissingImports] # Pydantic v2 + parse_datetime as parse_datetime, + ) + from pydantic.v1.json import ( # type: ignore # pyright: ignore[reportMissingImports] # Pydantic v2 + ENCODERS_BY_TYPE as encoders_by_type, + ) + from pydantic.v1.typing import ( # type: ignore # pyright: ignore[reportMissingImports] # Pydantic v2 + get_args as get_args, + ) + from pydantic.v1.typing import get_origin as get_origin # pyright: ignore[reportMissingImports] # Pydantic v2 + from pydantic.v1.typing import ( # pyright: ignore[reportMissingImports] # Pydantic v2 + is_literal_type as is_literal_type, + ) + from pydantic.v1.typing import is_union as is_union # pyright: ignore[reportMissingImports] # Pydantic v2 + from pydantic.v1.fields import ModelField as ModelField # type: ignore # pyright: ignore[reportMissingImports] # Pydantic v2 +else: + from pydantic.datetime_parse import parse_date as parse_date # type: ignore # Pydantic v1 + from pydantic.datetime_parse import parse_datetime as parse_datetime # type: ignore # Pydantic v1 + from pydantic.fields import ModelField as ModelField # type: ignore # Pydantic v1 + from pydantic.json import ENCODERS_BY_TYPE as encoders_by_type # type: ignore # Pydantic v1 + from pydantic.typing import get_args as get_args # type: ignore # Pydantic v1 + from pydantic.typing import get_origin as get_origin # type: ignore # Pydantic v1 + from pydantic.typing import is_literal_type as is_literal_type # type: ignore # Pydantic v1 + from pydantic.typing import is_union as is_union # type: ignore # Pydantic v1 + + # isort: on + + +T = typing.TypeVar("T") +Model = typing.TypeVar("Model", bound=pydantic.BaseModel) + + +def deep_union_pydantic_dicts( + source: typing.Dict[str, typing.Any], destination: typing.Dict[str, typing.Any] +) -> typing.Dict[str, typing.Any]: + for key, value in source.items(): + if isinstance(value, dict): + node = destination.setdefault(key, {}) + deep_union_pydantic_dicts(value, node) + else: + destination[key] = value + + return destination + + +def parse_obj_as(type_: typing.Type[T], object_: typing.Any) -> T: + if IS_PYDANTIC_V2: + adapter = pydantic.TypeAdapter(type_) # type: ignore # Pydantic v2 + return adapter.validate_python(object_) + else: + return pydantic.parse_obj_as(type_, object_) + + +def to_jsonable_with_fallback( + obj: typing.Any, fallback_serializer: typing.Callable[[typing.Any], typing.Any] +) -> typing.Any: + if IS_PYDANTIC_V2: + from pydantic_core import to_jsonable_python + + return to_jsonable_python(obj, fallback=fallback_serializer) + else: + return fallback_serializer(obj) + + +class UniversalBaseModel(pydantic.BaseModel): + class Config: + populate_by_name = True + smart_union = True + allow_population_by_field_name = True + json_encoders = {dt.datetime: serialize_datetime} + + def json(self, **kwargs: typing.Any) -> str: + kwargs_with_defaults: typing.Any = {"by_alias": True, "exclude_unset": True, **kwargs} + if IS_PYDANTIC_V2: + return super().model_dump_json(**kwargs_with_defaults) # type: ignore # Pydantic v2 + else: + return super().json(**kwargs_with_defaults) + + def dict(self, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: + kwargs_with_defaults_exclude_unset: typing.Any = {"by_alias": True, "exclude_unset": True, **kwargs} + kwargs_with_defaults_exclude_none: typing.Any = {"by_alias": True, "exclude_none": True, **kwargs} + + if IS_PYDANTIC_V2: + return deep_union_pydantic_dicts( + super().model_dump(**kwargs_with_defaults_exclude_unset), # type: ignore # Pydantic v2 + super().model_dump(**kwargs_with_defaults_exclude_none), # type: ignore # Pydantic v2 + ) + else: + return deep_union_pydantic_dicts( + super().dict(**kwargs_with_defaults_exclude_unset), super().dict(**kwargs_with_defaults_exclude_none) + ) + + +UniversalRootModel: typing.Type[typing.Any] +if IS_PYDANTIC_V2: + + class V2RootModel(UniversalBaseModel, pydantic.RootModel): # type: ignore # Pydantic v2 + pass + + UniversalRootModel = V2RootModel +else: + UniversalRootModel = UniversalBaseModel + + +def encode_by_type(o: typing.Any) -> typing.Any: + encoders_by_class_tuples: typing.Dict[ + typing.Callable[[typing.Any], typing.Any], typing.Tuple[typing.Any, ...] + ] = defaultdict(tuple) + for type_, encoder in encoders_by_type.items(): + encoders_by_class_tuples[encoder] += (type_,) + + if type(o) in encoders_by_type: + return encoders_by_type[type(o)](o) + for encoder, classes_tuple in encoders_by_class_tuples.items(): + if isinstance(o, classes_tuple): + return encoder(o) + + +def update_forward_refs(model: typing.Type["Model"], **localns: typing.Any) -> None: + if IS_PYDANTIC_V2: + model.model_rebuild(force=True, raise_errors=False) # type: ignore # Pydantic v2 + else: + model.update_forward_refs(**localns) + + +# Mirrors Pydantic's internal typing +AnyCallable = typing.Callable[..., typing.Any] + + +def universal_root_validator(pre: bool = False) -> typing.Callable[[AnyCallable], AnyCallable]: + def decorator(func: AnyCallable) -> AnyCallable: + @wraps(func) + def validate(*args: typing.Any, **kwargs: typing.Any) -> AnyCallable: + if IS_PYDANTIC_V2: + wrapped_func = pydantic.model_validator("before" if pre else "after")(func) # type: ignore # Pydantic v2 + else: + wrapped_func = pydantic.root_validator(pre=pre)(func) # type: ignore # Pydantic v1 + + return wrapped_func(*args, **kwargs) + + return validate + + return decorator + + +def universal_field_validator(field_name: str, pre: bool = False) -> typing.Callable[[AnyCallable], AnyCallable]: + def decorator(func: AnyCallable) -> AnyCallable: + @wraps(func) + def validate(*args: typing.Any, **kwargs: typing.Any) -> AnyCallable: + if IS_PYDANTIC_V2: + wrapped_func = pydantic.field_validator(field_name, mode="before" if pre else "after")(func) # type: ignore # Pydantic v2 + else: + wrapped_func = pydantic.validator(field_name, pre=pre)(func) + + return wrapped_func(*args, **kwargs) + + return validate + + return decorator diff --git a/seed/python-sdk/alias-extends/src/seed/core/query_encoder.py b/seed/python-sdk/alias-extends/src/seed/core/query_encoder.py new file mode 100644 index 00000000000..24076d72ee9 --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/core/query_encoder.py @@ -0,0 +1,33 @@ +# This file was auto-generated by Fern from our API Definition. + +from collections import ChainMap +from typing import Any, Dict, Optional + +import pydantic + + +# Flattens dicts to be of the form {"key[subkey][subkey2]": value} where value is not a dict +def traverse_query_dict(dict_flat: Dict[str, Any], key_prefix: Optional[str] = None) -> Dict[str, Any]: + result = {} + for k, v in dict_flat.items(): + key = f"{key_prefix}[{k}]" if key_prefix is not None else k + if isinstance(v, dict): + result.update(traverse_query_dict(v, key)) + else: + result[key] = v + return result + + +def single_query_encoder(query_key: str, query_value: Any) -> Dict[str, Any]: + if isinstance(query_value, pydantic.BaseModel) or isinstance(query_value, dict): + if isinstance(query_value, pydantic.BaseModel): + obj_dict = query_value.dict(by_alias=True) + else: + obj_dict = query_value + return traverse_query_dict(obj_dict, query_key) + + return {query_key: query_value} + + +def encode_query(query: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + return dict(ChainMap(*[single_query_encoder(k, v) for k, v in query.items()])) if query is not None else None diff --git a/seed/python-sdk/alias-extends/src/seed/core/remove_none_from_dict.py b/seed/python-sdk/alias-extends/src/seed/core/remove_none_from_dict.py new file mode 100644 index 00000000000..c2298143f14 --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/core/remove_none_from_dict.py @@ -0,0 +1,11 @@ +# This file was auto-generated by Fern from our API Definition. + +from typing import Any, Dict, Mapping, Optional + + +def remove_none_from_dict(original: Mapping[str, Optional[Any]]) -> Dict[str, Any]: + new: Dict[str, Any] = {} + for key, value in original.items(): + if value is not None: + new[key] = value + return new diff --git a/seed/python-sdk/alias-extends/src/seed/core/request_options.py b/seed/python-sdk/alias-extends/src/seed/core/request_options.py new file mode 100644 index 00000000000..d0bf0dbcecd --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/core/request_options.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +try: + from typing import NotRequired # type: ignore +except ImportError: + from typing_extensions import NotRequired + + +class RequestOptions(typing.TypedDict, total=False): + """ + Additional options for request-specific configuration when calling APIs via the SDK. + This is used primarily as an optional final parameter for service functions. + + Attributes: + - timeout_in_seconds: int. The number of seconds to await an API call before timing out. + + - max_retries: int. The max number of retries to attempt if the API call fails. + + - additional_headers: typing.Dict[str, typing.Any]. A dictionary containing additional parameters to spread into the request's header dict + + - additional_query_parameters: typing.Dict[str, typing.Any]. A dictionary containing additional parameters to spread into the request's query parameters dict + + - additional_body_parameters: typing.Dict[str, typing.Any]. A dictionary containing additional parameters to spread into the request's body parameters dict + """ + + timeout_in_seconds: NotRequired[int] + max_retries: NotRequired[int] + additional_headers: NotRequired[typing.Dict[str, typing.Any]] + additional_query_parameters: NotRequired[typing.Dict[str, typing.Any]] + additional_body_parameters: NotRequired[typing.Dict[str, typing.Any]] diff --git a/seed/python-sdk/alias-extends/src/seed/core/serialization.py b/seed/python-sdk/alias-extends/src/seed/core/serialization.py new file mode 100644 index 00000000000..8ad5cf8125f --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/core/serialization.py @@ -0,0 +1,167 @@ +# This file was auto-generated by Fern from our API Definition. + +import collections +import typing + +import typing_extensions + + +class FieldMetadata: + """ + Metadata class used to annotate fields to provide additional information. + + Example: + class MyDict(TypedDict): + field: typing.Annotated[str, FieldMetadata(alias="field_name")] + + Will serialize: `{"field": "value"}` + To: `{"field_name": "value"}` + """ + + alias: str + + def __init__(self, *, alias: str) -> None: + self.alias = alias + + +def convert_and_respect_annotation_metadata( + *, object_: typing.Any, annotation: typing.Any, inner_type: typing.Optional[typing.Any] = None +) -> typing.Any: + """ + Respect the metadata annotations on a field, such as aliasing. This function effectively + manipulates the dict-form of an object to respect the metadata annotations. This is primarily used for + TypedDicts, which cannot support aliasing out of the box, and can be extended for additional + utilities, such as defaults. + + Parameters + ---------- + object_ : typing.Any + + annotation : type + The type we're looking to apply typing annotations from + + inner_type : typing.Optional[type] + + Returns + ------- + typing.Any + """ + + if object_ is None: + return None + if inner_type is None: + inner_type = annotation + + clean_type = _remove_annotations(inner_type) + if typing_extensions.is_typeddict(clean_type) and isinstance(object_, typing.Mapping): + return _convert_typeddict(object_, clean_type) + + if ( + # If you're iterating on a string, do not bother to coerce it to a sequence. + (not isinstance(object_, str)) + and ( + ( + ( + typing_extensions.get_origin(clean_type) == typing.List + or typing_extensions.get_origin(clean_type) == list + or clean_type == typing.List + ) + and isinstance(object_, typing.List) + ) + or ( + ( + typing_extensions.get_origin(clean_type) == typing.Set + or typing_extensions.get_origin(clean_type) == set + or clean_type == typing.Set + ) + and isinstance(object_, typing.Set) + ) + or ( + ( + typing_extensions.get_origin(clean_type) == typing.Sequence + or typing_extensions.get_origin(clean_type) == collections.abc.Sequence + or clean_type == typing.Sequence + ) + and isinstance(object_, typing.Sequence) + ) + ) + ): + inner_type = typing_extensions.get_args(clean_type)[0] + return [ + convert_and_respect_annotation_metadata(object_=item, annotation=annotation, inner_type=inner_type) + for item in object_ + ] + + if typing_extensions.get_origin(clean_type) == typing.Union: + # We should be able to ~relatively~ safely try to convert keys against all + # member types in the union, the edge case here is if one member aliases a field + # of the same name to a different name from another member + # Or if another member aliases a field of the same name that another member does not. + for member in typing_extensions.get_args(clean_type): + object_ = convert_and_respect_annotation_metadata(object_=object_, annotation=annotation, inner_type=member) + return object_ + + annotated_type = _get_annotation(annotation) + if annotated_type is None: + return object_ + + # If the object is not a TypedDict, a Union, or other container (list, set, sequence, etc.) + # Then we can safely call it on the recursive conversion. + return object_ + + +def _convert_typeddict(object_: typing.Mapping[str, object], expected_type: typing.Any) -> typing.Mapping[str, object]: + converted_object: typing.Dict[str, object] = {} + annotations = typing_extensions.get_type_hints(expected_type, include_extras=True) + for key, value in object_.items(): + type_ = annotations.get(key) + if type_ is None: + converted_object[key] = value + else: + converted_object[_alias_key(key, type_)] = convert_and_respect_annotation_metadata( + object_=value, annotation=type_ + ) + return converted_object + + +def _get_annotation(type_: typing.Any) -> typing.Optional[typing.Any]: + maybe_annotated_type = typing_extensions.get_origin(type_) + if maybe_annotated_type is None: + return None + + if maybe_annotated_type == typing_extensions.NotRequired: + type_ = typing_extensions.get_args(type_)[0] + maybe_annotated_type = typing_extensions.get_origin(type_) + + if maybe_annotated_type == typing_extensions.Annotated: + return type_ + + return None + + +def _remove_annotations(type_: typing.Any) -> typing.Any: + maybe_annotated_type = typing_extensions.get_origin(type_) + if maybe_annotated_type is None: + return type_ + + if maybe_annotated_type == typing_extensions.NotRequired: + return _remove_annotations(typing_extensions.get_args(type_)[0]) + + if maybe_annotated_type == typing_extensions.Annotated: + return _remove_annotations(typing_extensions.get_args(type_)[0]) + + return type_ + + +def _alias_key(key: str, type_: typing.Any) -> str: + maybe_annotated_type = _get_annotation(type_) + + if maybe_annotated_type is not None: + # The actual annotations are 1 onward, the first is the annotated type + annotations = typing_extensions.get_args(maybe_annotated_type)[1:] + + for annotation in annotations: + if isinstance(annotation, FieldMetadata) and annotation.alias is not None: + return annotation.alias + + return key diff --git a/seed/python-sdk/alias-extends/src/seed/py.typed b/seed/python-sdk/alias-extends/src/seed/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/python-sdk/alias-extends/src/seed/types/__init__.py b/seed/python-sdk/alias-extends/src/seed/types/__init__.py new file mode 100644 index 00000000000..7190199fc55 --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/types/__init__.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from .alias_type import AliasType +from .child import Child +from .parent import Parent + +__all__ = ["AliasType", "Child", "Parent"] diff --git a/seed/python-sdk/alias-extends/src/seed/types/alias_type.py b/seed/python-sdk/alias-extends/src/seed/types/alias_type.py new file mode 100644 index 00000000000..369c01f4baa --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/types/alias_type.py @@ -0,0 +1,12 @@ +# This file was auto-generated by Fern from our API Definition. + +from .parent import Parent + +""" +from seed import Parent + +Parent( + parent="Property from the parent", +) +""" +AliasType = Parent diff --git a/seed/python-sdk/alias-extends/src/seed/types/child.py b/seed/python-sdk/alias-extends/src/seed/types/child.py new file mode 100644 index 00000000000..8bc4752b866 --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/types/child.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic + +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from .parent import Parent + + +class Child(Parent): + """ + Examples + -------- + from seed import Child + + Child( + parent="Property from the parent", + child="Property from the child", + ) + """ + + child: str + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/seed/python-sdk/alias-extends/src/seed/types/parent.py b/seed/python-sdk/alias-extends/src/seed/types/parent.py new file mode 100644 index 00000000000..ff9ac6ac3cb --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/types/parent.py @@ -0,0 +1,30 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic + +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class Parent(UniversalBaseModel): + """ + Examples + -------- + from seed import Parent + + Parent( + parent="Property from the parent", + ) + """ + + parent: str + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/seed/python-sdk/alias-extends/src/seed/version.py b/seed/python-sdk/alias-extends/src/seed/version.py new file mode 100644 index 00000000000..8093dc025c3 --- /dev/null +++ b/seed/python-sdk/alias-extends/src/seed/version.py @@ -0,0 +1,4 @@ + +from importlib import metadata + +__version__ = metadata.version("fern_alias-extends") diff --git a/seed/python-sdk/alias-extends/tests/__init__.py b/seed/python-sdk/alias-extends/tests/__init__.py new file mode 100644 index 00000000000..f3ea2659bb1 --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/__init__.py @@ -0,0 +1,2 @@ +# This file was auto-generated by Fern from our API Definition. + diff --git a/seed/python-sdk/alias-extends/tests/conftest.py b/seed/python-sdk/alias-extends/tests/conftest.py new file mode 100644 index 00000000000..56d2cb80c55 --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/conftest.py @@ -0,0 +1,16 @@ +# This file was auto-generated by Fern from our API Definition. + +import os + +import pytest +from seed import AsyncSeedAliasExtends, SeedAliasExtends + + +@pytest.fixture +def client() -> SeedAliasExtends: + return SeedAliasExtends(base_url=os.getenv("TESTS_BASE_URL", "base_url")) + + +@pytest.fixture +def async_client() -> AsyncSeedAliasExtends: + return AsyncSeedAliasExtends(base_url=os.getenv("TESTS_BASE_URL", "base_url")) diff --git a/seed/python-sdk/alias-extends/tests/custom/test_client.py b/seed/python-sdk/alias-extends/tests/custom/test_client.py new file mode 100644 index 00000000000..60a58e64c27 --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/custom/test_client.py @@ -0,0 +1,6 @@ +import pytest + +# Get started with writing tests with pytest at https://docs.pytest.org +@pytest.mark.skip(reason="Unimplemented") +def test_client() -> None: + assert True == True diff --git a/seed/python-sdk/alias-extends/tests/test_root.py b/seed/python-sdk/alias-extends/tests/test_root.py new file mode 100644 index 00000000000..bff235b6549 --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/test_root.py @@ -0,0 +1,10 @@ +# This file was auto-generated by Fern from our API Definition. + +from seed import AsyncSeedAliasExtends, SeedAliasExtends + + +async def test_extended_inline_request_body(client: SeedAliasExtends, async_client: AsyncSeedAliasExtends) -> None: + # Type ignore to avoid mypy complaining about the function not being meant to return a value + assert client.extended_inline_request_body(child="string", parent="string") is None # type: ignore[func-returns-value] + + assert await async_client.extended_inline_request_body(child="string", parent="string") is None # type: ignore[func-returns-value] diff --git a/seed/python-sdk/alias-extends/tests/utilities.py b/seed/python-sdk/alias-extends/tests/utilities.py new file mode 100644 index 00000000000..13da208eb3a --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/utilities.py @@ -0,0 +1,138 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing +import uuid + +import pydantic +from dateutil import parser + + +def cast_field(json_expectation: typing.Any, type_expectation: typing.Any) -> typing.Any: + # Cast these specific types which come through as string and expect our + # models to cast to the correct type. + if type_expectation == "uuid": + return uuid.UUID(json_expectation) + elif type_expectation == "date": + return parser.parse(json_expectation).date() + elif type_expectation == "datetime": + return parser.parse(json_expectation) + elif type_expectation == "set": + return set(json_expectation) + elif type_expectation == "integer": + # Necessary as we allow numeric keys, but JSON makes them strings + return int(json_expectation) + + return json_expectation + + +def validate_field(response: typing.Any, json_expectation: typing.Any, type_expectation: typing.Any) -> None: + # Allow for an escape hatch if the object cannot be validated + if type_expectation == "no_validate": + return + + is_container_of_complex_type = False + # Parse types in containers, note that dicts are handled within `validate_response` + if isinstance(json_expectation, list): + if isinstance(type_expectation, tuple): + container_expectation = type_expectation[0] + contents_expectation = type_expectation[1] + + cast_json_expectation = [] + for idx, ex in enumerate(json_expectation): + if isinstance(contents_expectation, dict): + entry_expectation = contents_expectation.get(idx) + if isinstance(entry_expectation, dict): + is_container_of_complex_type = True + validate_response( + response=response[idx], json_expectation=ex, type_expectations=entry_expectation + ) + else: + cast_json_expectation.append(cast_field(ex, entry_expectation)) + else: + cast_json_expectation.append(ex) + json_expectation = cast_json_expectation + + # Note that we explicitly do not allow for sets of pydantic models as they are not hashable, so + # if any of the values of the set have a type_expectation of a dict, we're assuming it's a pydantic + # model and keeping it a list. + if container_expectation != "set" or not any( + map(lambda value: isinstance(value, dict), list(contents_expectation.values())) + ): + json_expectation = cast_field(json_expectation, container_expectation) + elif isinstance(type_expectation, tuple): + container_expectation = type_expectation[0] + contents_expectation = type_expectation[1] + if isinstance(contents_expectation, dict): + json_expectation = { + cast_field( + key, contents_expectation.get(idx)[0] if contents_expectation.get(idx) is not None else None # type: ignore + ): cast_field( + value, contents_expectation.get(idx)[1] if contents_expectation.get(idx) is not None else None # type: ignore + ) + for idx, (key, value) in enumerate(json_expectation.items()) + } + else: + json_expectation = cast_field(json_expectation, container_expectation) + elif type_expectation is not None: + json_expectation = cast_field(json_expectation, type_expectation) + + # When dealing with containers of models, etc. we're validating them implicitly, so no need to check the resultant list + if not is_container_of_complex_type: + assert ( + json_expectation == response + ), "Primitives found, expected: {0} (type: {1}), Actual: {2} (type: {3})".format( + json_expectation, type(json_expectation), response, type(response) + ) + + +# Arg type_expectations is a deeply nested structure that matches the response, but with the values replaced with the expected types +def validate_response(response: typing.Any, json_expectation: typing.Any, type_expectations: typing.Any) -> None: + # Allow for an escape hatch if the object cannot be validated + if type_expectations == "no_validate": + return + + if ( + not isinstance(response, list) + and not isinstance(response, dict) + and not issubclass(type(response), pydantic.BaseModel) + ): + validate_field(response=response, json_expectation=json_expectation, type_expectation=type_expectations) + return + + if isinstance(response, list): + assert len(response) == len(json_expectation), "Length mismatch, expected: {0}, Actual: {1}".format( + len(response), len(json_expectation) + ) + content_expectation = type_expectations + if isinstance(type_expectations, tuple): + content_expectation = type_expectations[1] + for idx, item in enumerate(response): + validate_response( + response=item, json_expectation=json_expectation[idx], type_expectations=content_expectation[idx] + ) + else: + response_json = response + if issubclass(type(response), pydantic.BaseModel): + response_json = response.dict(by_alias=True) + + for key, value in json_expectation.items(): + assert key in response_json, "Field {0} not found within the response object: {1}".format( + key, response_json + ) + + type_expectation = None + if type_expectations is not None and isinstance(type_expectations, dict): + type_expectation = type_expectations.get(key) + + # If your type_expectation is a tuple then you have a container field, process it as such + # Otherwise, we're just validating a single field that's a pydantic model. + if isinstance(value, dict) and not isinstance(type_expectation, tuple): + validate_response( + response=response_json[key], json_expectation=value, type_expectations=type_expectation + ) + else: + validate_field(response=response_json[key], json_expectation=value, type_expectation=type_expectation) + + # Ensure there are no additional fields here either + del response_json[key] + assert len(response_json) == 0, "Additional fields found, expected None: {0}".format(response_json) diff --git a/seed/python-sdk/alias-extends/tests/utils/__init__.py b/seed/python-sdk/alias-extends/tests/utils/__init__.py new file mode 100644 index 00000000000..f3ea2659bb1 --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/utils/__init__.py @@ -0,0 +1,2 @@ +# This file was auto-generated by Fern from our API Definition. + diff --git a/seed/python-sdk/alias-extends/tests/utils/assets/models/__init__.py b/seed/python-sdk/alias-extends/tests/utils/assets/models/__init__.py new file mode 100644 index 00000000000..2cf01263529 --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/utils/assets/models/__init__.py @@ -0,0 +1,21 @@ +# This file was auto-generated by Fern from our API Definition. + +# This file was auto-generated by Fern from our API Definition. + +from .circle import CircleParams +from .object_with_defaults import ObjectWithDefaultsParams +from .object_with_optional_field import ObjectWithOptionalFieldParams +from .shape import Shape_CircleParams, Shape_SquareParams, ShapeParams +from .square import SquareParams +from .undiscriminated_shape import UndiscriminatedShapeParams + +__all__ = [ + "CircleParams", + "ObjectWithDefaultsParams", + "ObjectWithOptionalFieldParams", + "ShapeParams", + "Shape_CircleParams", + "Shape_SquareParams", + "SquareParams", + "UndiscriminatedShapeParams", +] diff --git a/seed/python-sdk/alias-extends/tests/utils/assets/models/circle.py b/seed/python-sdk/alias-extends/tests/utils/assets/models/circle.py new file mode 100644 index 00000000000..af7a1bf8a8e --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/utils/assets/models/circle.py @@ -0,0 +1,10 @@ +# This file was auto-generated by Fern from our API Definition. + +# This file was auto-generated by Fern from our API Definition. + +import typing_extensions +from seed.core.serialization import FieldMetadata + + +class CircleParams(typing_extensions.TypedDict): + radius_measurement: typing_extensions.Annotated[float, FieldMetadata(alias="radiusMeasurement")] diff --git a/seed/python-sdk/alias-extends/tests/utils/assets/models/color.py b/seed/python-sdk/alias-extends/tests/utils/assets/models/color.py new file mode 100644 index 00000000000..2aa2c4c52f0 --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/utils/assets/models/color.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +# This file was auto-generated by Fern from our API Definition. + +import typing + +Color = typing.Union[typing.Literal["red", "blue"], typing.Any] diff --git a/seed/python-sdk/alias-extends/tests/utils/assets/models/object_with_defaults.py b/seed/python-sdk/alias-extends/tests/utils/assets/models/object_with_defaults.py new file mode 100644 index 00000000000..a977b1d2aa1 --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/utils/assets/models/object_with_defaults.py @@ -0,0 +1,15 @@ +# This file was auto-generated by Fern from our API Definition. + +# This file was auto-generated by Fern from our API Definition. + +import typing_extensions + + +class ObjectWithDefaultsParams(typing_extensions.TypedDict): + """ + Defines properties with default values and validation rules. + """ + + decimal: typing_extensions.NotRequired[float] + string: typing_extensions.NotRequired[str] + required_string: str diff --git a/seed/python-sdk/alias-extends/tests/utils/assets/models/object_with_optional_field.py b/seed/python-sdk/alias-extends/tests/utils/assets/models/object_with_optional_field.py new file mode 100644 index 00000000000..3ad93d5f305 --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/utils/assets/models/object_with_optional_field.py @@ -0,0 +1,35 @@ +# This file was auto-generated by Fern from our API Definition. + +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing +import uuid + +import typing_extensions +from seed.core.serialization import FieldMetadata + +from .color import Color +from .shape import ShapeParams +from .undiscriminated_shape import UndiscriminatedShapeParams + + +class ObjectWithOptionalFieldParams(typing_extensions.TypedDict): + literal: typing.Literal["lit_one"] + string: typing_extensions.NotRequired[str] + integer: typing_extensions.NotRequired[int] + long_: typing_extensions.NotRequired[typing_extensions.Annotated[int, FieldMetadata(alias="long")]] + double: typing_extensions.NotRequired[float] + bool_: typing_extensions.NotRequired[typing_extensions.Annotated[bool, FieldMetadata(alias="bool")]] + datetime: typing_extensions.NotRequired[dt.datetime] + date: typing_extensions.NotRequired[dt.date] + uuid_: typing_extensions.NotRequired[typing_extensions.Annotated[uuid.UUID, FieldMetadata(alias="uuid")]] + base_64: typing_extensions.NotRequired[typing_extensions.Annotated[str, FieldMetadata(alias="base64")]] + list_: typing_extensions.NotRequired[typing_extensions.Annotated[typing.Sequence[str], FieldMetadata(alias="list")]] + set_: typing_extensions.NotRequired[typing_extensions.Annotated[typing.Set[str], FieldMetadata(alias="set")]] + map_: typing_extensions.NotRequired[typing_extensions.Annotated[typing.Dict[int, str], FieldMetadata(alias="map")]] + enum: typing_extensions.NotRequired[Color] + union: typing_extensions.NotRequired[ShapeParams] + second_union: typing_extensions.NotRequired[ShapeParams] + undiscriminated_union: typing_extensions.NotRequired[UndiscriminatedShapeParams] + any: typing.Any diff --git a/seed/python-sdk/alias-extends/tests/utils/assets/models/shape.py b/seed/python-sdk/alias-extends/tests/utils/assets/models/shape.py new file mode 100644 index 00000000000..2c33c877951 --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/utils/assets/models/shape.py @@ -0,0 +1,27 @@ +# This file was auto-generated by Fern from our API Definition. + +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import typing_extensions +from seed.core.serialization import FieldMetadata + + +class Base(typing_extensions.TypedDict): + id: str + + +class Shape_CircleParams(Base): + shape_type: typing_extensions.Annotated[typing.Literal["circle"], FieldMetadata(alias="shapeType")] + radius_measurement: typing_extensions.Annotated[float, FieldMetadata(alias="radiusMeasurement")] + + +class Shape_SquareParams(Base): + shape_type: typing_extensions.Annotated[typing.Literal["square"], FieldMetadata(alias="shapeType")] + length_measurement: typing_extensions.Annotated[float, FieldMetadata(alias="lengthMeasurement")] + + +ShapeParams = typing.Union[Shape_CircleParams, Shape_SquareParams] diff --git a/seed/python-sdk/alias-extends/tests/utils/assets/models/square.py b/seed/python-sdk/alias-extends/tests/utils/assets/models/square.py new file mode 100644 index 00000000000..b9b7dd319bc --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/utils/assets/models/square.py @@ -0,0 +1,10 @@ +# This file was auto-generated by Fern from our API Definition. + +# This file was auto-generated by Fern from our API Definition. + +import typing_extensions +from seed.core.serialization import FieldMetadata + + +class SquareParams(typing_extensions.TypedDict): + length_measurement: typing_extensions.Annotated[float, FieldMetadata(alias="lengthMeasurement")] diff --git a/seed/python-sdk/alias-extends/tests/utils/assets/models/undiscriminated_shape.py b/seed/python-sdk/alias-extends/tests/utils/assets/models/undiscriminated_shape.py new file mode 100644 index 00000000000..99f12b300d1 --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/utils/assets/models/undiscriminated_shape.py @@ -0,0 +1,10 @@ +# This file was auto-generated by Fern from our API Definition. + +# This file was auto-generated by Fern from our API Definition. + +import typing + +from .circle import CircleParams +from .square import SquareParams + +UndiscriminatedShapeParams = typing.Union[CircleParams, SquareParams] diff --git a/seed/python-sdk/alias-extends/tests/utils/test_http_client.py b/seed/python-sdk/alias-extends/tests/utils/test_http_client.py new file mode 100644 index 00000000000..edd11ca7afb --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/utils/test_http_client.py @@ -0,0 +1,47 @@ +# This file was auto-generated by Fern from our API Definition. + +from seed.core.http_client import get_request_body +from seed.core.request_options import RequestOptions + + +def get_request_options() -> RequestOptions: + return {"additional_body_parameters": {"see you": "later"}} + + +def test_get_json_request_body() -> None: + json_body, data_body = get_request_body(json={"hello": "world"}, data=None, request_options=None, omit=None) + assert json_body == {"hello": "world"} + assert data_body is None + + json_body_extras, data_body_extras = get_request_body( + json={"goodbye": "world"}, data=None, request_options=get_request_options(), omit=None + ) + + assert json_body_extras == {"goodbye": "world", "see you": "later"} + assert data_body_extras is None + + +def test_get_files_request_body() -> None: + json_body, data_body = get_request_body(json=None, data={"hello": "world"}, request_options=None, omit=None) + assert data_body == {"hello": "world"} + assert json_body is None + + json_body_extras, data_body_extras = get_request_body( + json=None, data={"goodbye": "world"}, request_options=get_request_options(), omit=None + ) + + assert data_body_extras == {"goodbye": "world", "see you": "later"} + assert json_body_extras is None + + +def test_get_none_request_body() -> None: + json_body, data_body = get_request_body(json=None, data=None, request_options=None, omit=None) + assert data_body is None + assert json_body is None + + json_body_extras, data_body_extras = get_request_body( + json=None, data=None, request_options=get_request_options(), omit=None + ) + + assert json_body_extras == {"see you": "later"} + assert data_body_extras is None diff --git a/seed/python-sdk/alias-extends/tests/utils/test_query_encoding.py b/seed/python-sdk/alias-extends/tests/utils/test_query_encoding.py new file mode 100644 index 00000000000..247e1551b46 --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/utils/test_query_encoding.py @@ -0,0 +1,18 @@ +# This file was auto-generated by Fern from our API Definition. + +from seed.core.query_encoder import encode_query + + +def test_query_encoding() -> None: + assert encode_query({"hello world": "hello world"}) == {"hello world": "hello world"} + assert encode_query({"hello_world": {"hello": "world"}}) == {"hello_world[hello]": "world"} + assert encode_query({"hello_world": {"hello": {"world": "today"}, "test": "this"}, "hi": "there"}) == { + "hello_world[hello][world]": "today", + "hello_world[test]": "this", + "hi": "there", + } + + +def test_encode_query_with_none() -> None: + encoded = encode_query(None) + assert encoded == None diff --git a/seed/python-sdk/alias-extends/tests/utils/test_serialization.py b/seed/python-sdk/alias-extends/tests/utils/test_serialization.py new file mode 100644 index 00000000000..58b1ed66e6d --- /dev/null +++ b/seed/python-sdk/alias-extends/tests/utils/test_serialization.py @@ -0,0 +1,66 @@ +# This file was auto-generated by Fern from our API Definition. + +from typing import Any, List + +from seed.core.serialization import convert_and_respect_annotation_metadata + +from .assets.models import ObjectWithOptionalFieldParams, ShapeParams + +UNION_TEST: ShapeParams = {"radius_measurement": 1.0, "shape_type": "circle", "id": "1"} +UNION_TEST_CONVERTED = {"shapeType": "circle", "radiusMeasurement": 1.0, "id": "1"} + + +def test_convert_and_respect_annotation_metadata() -> None: + data: ObjectWithOptionalFieldParams = { + "string": "string", + "long_": 12345, + "bool_": True, + "literal": "lit_one", + "any": "any", + } + converted = convert_and_respect_annotation_metadata(object_=data, annotation=ObjectWithOptionalFieldParams) + assert converted == {"string": "string", "long": 12345, "bool": True, "literal": "lit_one", "any": "any"} + + +def test_convert_and_respect_annotation_metadata_in_list() -> None: + data: List[ObjectWithOptionalFieldParams] = [ + {"string": "string", "long_": 12345, "bool_": True, "literal": "lit_one", "any": "any"}, + {"string": "another string", "long_": 67890, "list_": [], "literal": "lit_one", "any": "any"}, + ] + converted = convert_and_respect_annotation_metadata(object_=data, annotation=List[ObjectWithOptionalFieldParams]) + + assert converted == [ + {"string": "string", "long": 12345, "bool": True, "literal": "lit_one", "any": "any"}, + {"string": "another string", "long": 67890, "list": [], "literal": "lit_one", "any": "any"}, + ] + + +def test_convert_and_respect_annotation_metadata_in_nested_object() -> None: + data: ObjectWithOptionalFieldParams = { + "string": "string", + "long_": 12345, + "union": UNION_TEST, + "literal": "lit_one", + "any": "any", + } + converted = convert_and_respect_annotation_metadata(object_=data, annotation=ObjectWithOptionalFieldParams) + + assert converted == { + "string": "string", + "long": 12345, + "union": UNION_TEST_CONVERTED, + "literal": "lit_one", + "any": "any", + } + + +def test_convert_and_respect_annotation_metadata_in_union() -> None: + converted = convert_and_respect_annotation_metadata(object_=UNION_TEST, annotation=ShapeParams) + + assert converted == UNION_TEST_CONVERTED + + +def test_convert_and_respect_annotation_metadata_with_empty_object() -> None: + data: Any = {} + converted = convert_and_respect_annotation_metadata(object_=data, annotation=ShapeParams) + assert converted == data diff --git a/seed/python-sdk/seed.yml b/seed/python-sdk/seed.yml index 0241053b0db..a42abdf7b76 100644 --- a/seed/python-sdk/seed.yml +++ b/seed/python-sdk/seed.yml @@ -191,4 +191,3 @@ allowedFailures: # We are not generating the correct snippets for union-utils # types (e.g. we generate `Shape_Square` instead of Shape.factory.square()) - unions:union-utils - - alias-extends \ No newline at end of file