diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6eb00725..dd939620 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -3,7 +3,7 @@ FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} USER vscode -RUN curl -sSf https://rye-up.com/get | RYE_VERSION="0.15.2" RYE_INSTALL_OPTION="--yes" bash +RUN curl -sSf https://rye-up.com/get | RYE_VERSION="0.24.0" RYE_INSTALL_OPTION="--yes" bash ENV PATH=/home/vscode/.rye/shims:$PATH RUN echo "[[ -d .venv ]] && source .venv/bin/activate" >> /home/vscode/.bashrc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e19e8cfa..0f300739 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,14 +14,14 @@ jobs: if: github.repository == 'orbcorp/orb-python' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install Rye run: | curl -sSf https://rye-up.com/get | bash echo "$HOME/.rye/shims" >> $GITHUB_PATH env: - RYE_VERSION: 0.15.2 + RYE_VERSION: 0.24.0 RYE_INSTALL_OPTION: "--yes" - name: Install dependencies diff --git a/.github/workflows/create-releases.yml b/.github/workflows/create-releases.yml deleted file mode 100644 index 346eb213..00000000 --- a/.github/workflows/create-releases.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Create releases -on: - schedule: - - cron: '0 5 * * *' # every day at 5am UTC - push: - branches: - - main - -jobs: - release: - name: release - if: github.ref == 'refs/heads/main' && github.repository == 'orbcorp/orb-python' - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - uses: stainless-api/trigger-release-please@v1 - id: release - with: - repo: ${{ github.event.repository.full_name }} - stainless-api-key: ${{ secrets.STAINLESS_API_KEY }} - - - name: Install Rye - if: ${{ steps.release.outputs.releases_created }} - run: | - curl -sSf https://rye-up.com/get | bash - echo "$HOME/.rye/shims" >> $GITHUB_PATH - env: - RYE_VERSION: 0.15.2 - RYE_INSTALL_OPTION: "--yes" - - - name: Publish to PyPI - if: ${{ steps.release.outputs.releases_created }} - run: | - bash ./bin/publish-pypi - env: - PYPI_TOKEN: ${{ secrets.ORB_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.github/workflows/handle-release-pr-title-edit.yml b/.github/workflows/handle-release-pr-title-edit.yml deleted file mode 100644 index 30a9bcd2..00000000 --- a/.github/workflows/handle-release-pr-title-edit.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Handle release PR title edits -on: - pull_request: - types: - - edited - - unlabeled - -jobs: - update_pr_content: - name: Update pull request content - if: | - ((github.event.action == 'edited' && github.event.changes.title.from != github.event.pull_request.title) || - (github.event.action == 'unlabeled' && github.event.label.name == 'autorelease: custom version')) && - startsWith(github.event.pull_request.head.ref, 'release-please--') && - github.event.pull_request.state == 'open' && - github.event.sender.login != 'stainless-bot' && - github.event.sender.login != 'stainless-app' && - github.repository == 'orbcorp/orb-python' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: stainless-api/trigger-release-please@v1 - with: - repo: ${{ github.event.repository.full_name }} - stainless-api-key: ${{ secrets.STAINLESS_API_KEY }} diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index db65bec7..2a3f9b8f 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,9 +1,13 @@ -# workflow for re-running publishing to PyPI in case it fails for some reason -# you can run this workflow by navigating to https://www.github.com/orbcorp/orb-python/actions/workflows/publish-pypi.yml +# This workflow is triggered when a GitHub release is created. +# It can also be run manually to re-publish to PyPI in case it failed for some reason. +# You can run this workflow by navigating to https://www.github.com/orbcorp/orb-python/actions/workflows/publish-pypi.yml name: Publish PyPI on: workflow_dispatch: + release: + types: [published] + jobs: publish: name: publish @@ -17,7 +21,7 @@ jobs: curl -sSf https://rye-up.com/get | bash echo "$HOME/.rye/shims" >> $GITHUB_PATH env: - RYE_VERSION: 0.15.2 + RYE_VERSION: 0.24.0 RYE_INSTALL_OPTION: "--yes" - name: Publish to PyPI diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index fa9a5309..a55199c6 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -16,5 +16,4 @@ jobs: run: | bash ./bin/check-release-environment env: - STAINLESS_API_KEY: ${{ secrets.STAINLESS_API_KEY }} PYPI_TOKEN: ${{ secrets.ORB_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b3636e9a..b5fcdb93 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.42.3" + ".": "1.43.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a8763a6c..64c7f088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## 1.43.0 (2024-03-09) + +Full Changelog: [v1.42.3...v1.43.0](https://github.com/orbcorp/orb-python/compare/v1.42.3...v1.43.0) + +### Features + +* **api:** updates ([#189](https://github.com/orbcorp/orb-python/issues/189)) ([3b9e36f](https://github.com/orbcorp/orb-python/commit/3b9e36f1fbca43b9e29794778178056d94870b2e)) + + +### Chores + +* **ci:** uses Stainless GitHub App for releases ([#172](https://github.com/orbcorp/orb-python/issues/172)) ([d5b9f5f](https://github.com/orbcorp/orb-python/commit/d5b9f5fff40d93d4aad599b38b8771284619d01d)) +* **client:** improve error message for invalid http_client argument ([#184](https://github.com/orbcorp/orb-python/issues/184)) ([17dacf2](https://github.com/orbcorp/orb-python/commit/17dacf22512f6bb7d3cc0c87905f92d964a84a76)) +* **client:** use anyio.sleep instead of asyncio.sleep ([#178](https://github.com/orbcorp/orb-python/issues/178)) ([677ef7e](https://github.com/orbcorp/orb-python/commit/677ef7e68840dd04cfbd7cae8e6a5af9998bc306)) +* **docs:** mention install from git repo ([#181](https://github.com/orbcorp/orb-python/issues/181)) ([795ae88](https://github.com/orbcorp/orb-python/commit/795ae882855d557105339689155110f4506da4c2)) +* export NOT_GIVEN sentinel value ([#188](https://github.com/orbcorp/orb-python/issues/188)) ([7a739b9](https://github.com/orbcorp/orb-python/commit/7a739b9a3b0a06704e02944c1af01930335c2b97)) +* **internal:** add core support for deserializing into number response ([#185](https://github.com/orbcorp/orb-python/issues/185)) ([247964e](https://github.com/orbcorp/orb-python/commit/247964ea68575e12ba5aad7c39675a48e3653474)) +* **internal:** bump pyright ([#177](https://github.com/orbcorp/orb-python/issues/177)) ([f5c6f0c](https://github.com/orbcorp/orb-python/commit/f5c6f0c3b5a432933a053f227d630df20582c4c4)) +* **internal:** bump pyright ([#186](https://github.com/orbcorp/orb-python/issues/186)) ([03ff063](https://github.com/orbcorp/orb-python/commit/03ff063b02838b75a1e8f602aae312afd12e44b6)) +* **internal:** bump rye to v0.24.0 ([#175](https://github.com/orbcorp/orb-python/issues/175)) ([450508a](https://github.com/orbcorp/orb-python/commit/450508a34a4838b92c54ff10ba439f6d60808b04)) +* **internal:** minor core client restructuring ([#179](https://github.com/orbcorp/orb-python/issues/179)) ([ace9451](https://github.com/orbcorp/orb-python/commit/ace945177ced39721f2eb8ea1ddd9b9b448b73bd)) +* **internal:** refactor release environment script ([#173](https://github.com/orbcorp/orb-python/issues/173)) ([2503fbc](https://github.com/orbcorp/orb-python/commit/2503fbc1c20524c9057ab5a1ef25bb279ee35827)) +* **internal:** split up transforms into sync / async ([#182](https://github.com/orbcorp/orb-python/issues/182)) ([17a94e7](https://github.com/orbcorp/orb-python/commit/17a94e75d071908b7e9048d26a26adaf16f2b52c)) +* **internal:** support more input types ([#183](https://github.com/orbcorp/orb-python/issues/183)) ([fc2721e](https://github.com/orbcorp/orb-python/commit/fc2721e1638b3c79d5e22f573923c09142196c43)) +* **internal:** support parsing Annotated types ([#187](https://github.com/orbcorp/orb-python/issues/187)) ([9982119](https://github.com/orbcorp/orb-python/commit/9982119ec618bba15f0e1cfa1eac9adc5168206a)) +* **internal:** update deps ([#176](https://github.com/orbcorp/orb-python/issues/176)) ([abdbcc6](https://github.com/orbcorp/orb-python/commit/abdbcc69bb3efc80fabe4980c6eacc3cf8725dbd)) + + +### Documentation + +* add CONTRIBUTING.md ([#170](https://github.com/orbcorp/orb-python/issues/170)) ([87d1adf](https://github.com/orbcorp/orb-python/commit/87d1adfaaeb7afaf4741e9e26afb07ac7962b6c1)) +* **contributing:** improve wording ([#180](https://github.com/orbcorp/orb-python/issues/180)) ([ff322f1](https://github.com/orbcorp/orb-python/commit/ff322f16b36dba9051c6c6c7c49d2c34419317ca)) + ## 1.42.3 (2024-02-07) Full Changelog: [v1.42.2...v1.42.3](https://github.com/orbcorp/orb-python/compare/v1.42.2...v1.42.3) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..4024277e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,125 @@ +## Setting up the environment + +### With Rye + +We use [Rye](https://rye-up.com/) to manage dependencies so we highly recommend [installing it](https://rye-up.com/guide/installation/) as it will automatically provision a Python environment with the expected Python version. + +After installing Rye, you'll just have to run this command: + +```sh +$ rye sync --all-features +``` + +You can then run scripts using `rye run python script.py` or by activating the virtual environment: + +```sh +$ rye shell +# or manually activate - https://docs.python.org/3/library/venv.html#how-venvs-work +$ source .venv/bin/activate + +# now you can omit the `rye run` prefix +$ python script.py +``` + +### Without Rye + +Alternatively if you don't want to install `Rye`, you can stick with the standard `pip` setup by ensuring you have the Python version specified in `.python-version`, create a virtual environment however you desire and then install dependencies using this command: + +```sh +$ pip install -r requirements-dev.lock +``` + +## Modifying/Adding code + +Most of the SDK is generated code, and any modified code will be overridden on the next generation. The +`src/orb/lib/` and `examples/` directories are exceptions and will never be overridden. + +## Adding and running examples + +All files in the `examples/` directory are not modified by the Stainless generator and can be freely edited or +added to. + +```bash +# add an example to examples/.py + +#!/usr/bin/env -S rye run python +… +``` + +``` +chmod +x examples/.py +# run the example against your api +./examples/.py +``` + +## Using the repository from source + +If you’d like to use the repository from source, you can either install from git or link to a cloned repository: + +To install via git: + +```bash +pip install git+ssh://git@github.com/orbcorp/orb-python.git +``` + +Alternatively, you can build from source and install the wheel file: + +Building this package will create two files in the `dist/` directory, a `.tar.gz` containing the source files and a `.whl` that can be used to install the package efficiently. + +To create a distributable version of the library, all you have to do is run this command: + +```bash +rye build +# or +python -m build +``` + +Then to install: + +```sh +pip install ./path-to-wheel-file.whl +``` + +## Running tests + +Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. + +```bash +# you will need npm installed +npx prism path/to/your/openapi.yml +``` + +```bash +rye run pytest +``` + +## Linting and formatting + +This repository uses [ruff](https://github.com/astral-sh/ruff) and +[black](https://github.com/psf/black) to format the code in the repository. + +To lint: + +```bash +rye run lint +``` + +To format and fix all ruff issues automatically: + +```bash +rye run format +``` + +## Publishing and releases + +Changes made to this repository via the automated release PR pipeline should publish to PyPI automatically. If +the changes aren't made through the automated pipeline, you may want to make releases manually. + +### Publish with a GitHub workflow + +You can release to package managers by using [the `Publish PyPI` GitHub action](https://www.github.com/orbcorp/orb-python/actions/workflows/publish-pypi.yml). This requires a setup organization or repository secret to be set up. + +### Publish manually + +If you need to manually release a package, you can run the `bin/publish-pypi` script with an `PYPI_TOKEN` set on +the environment. diff --git a/README.md b/README.md index 039d8b95..9675eaa4 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The REST API documentation can be found [on docs.withorb.com](https://docs.witho ## Installation ```sh +# install from PyPI pip install orb-billing ``` diff --git a/bin/check-release-environment b/bin/check-release-environment index 7e344b6f..b0828071 100644 --- a/bin/check-release-environment +++ b/bin/check-release-environment @@ -2,17 +2,13 @@ errors=() -if [ -z "${STAINLESS_API_KEY}" ]; then - errors+=("The STAINLESS_API_KEY secret has not been set. Please contact Stainless for an API key & set it in your organization secrets on GitHub.") -fi - if [ -z "${PYPI_TOKEN}" ]; then errors+=("The ORB_PYPI_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets.") fi -len=${#errors[@]} +lenErrors=${#errors[@]} -if [[ len -gt 0 ]]; then +if [[ lenErrors -gt 0 ]]; then echo -e "Found the following errors in the release environment:\n" for error in "${errors[@]}"; do diff --git a/pyproject.toml b/pyproject.toml index 3acfcb8f..599e2ef1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "orb-billing" -version = "1.42.3" +version = "1.43.0" description = "The official Python library for the orb API" readme = "README.md" license = "Apache-2.0" diff --git a/requirements-dev.lock b/requirements-dev.lock index a0134d65..8cd3a6da 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -5,48 +5,92 @@ # pre: false # features: [] # all-features: true +# with-sources: false -e file:. annotated-types==0.6.0 + # via pydantic anyio==4.1.0 + # via httpx + # via orb-billing argcomplete==3.1.2 + # via nox attrs==23.1.0 + # via pytest certifi==2023.7.22 + # via httpcore + # via httpx colorlog==6.7.0 + # via nox dirty-equals==0.6.0 distlib==0.3.7 + # via virtualenv distro==1.8.0 + # via orb-billing exceptiongroup==1.1.3 + # via anyio filelock==3.12.4 + # via virtualenv h11==0.14.0 + # via httpcore httpcore==1.0.2 + # via httpx httpx==0.25.2 + # via orb-billing + # via respx idna==3.4 + # via anyio + # via httpx importlib-metadata==7.0.0 iniconfig==2.0.0 + # via pytest mypy==1.7.1 mypy-extensions==1.0.0 + # via mypy nodeenv==1.8.0 + # via pyright nox==2023.4.22 packaging==23.2 + # via nox + # via pytest platformdirs==3.11.0 + # via virtualenv pluggy==1.3.0 + # via pytest py==1.11.0 + # via pytest pydantic==2.4.2 + # via orb-billing pydantic-core==2.10.1 -pyright==1.1.332 + # via pydantic +pyright==1.1.353 pytest==7.1.1 + # via pytest-asyncio pytest-asyncio==0.21.1 python-dateutil==2.8.2 + # via time-machine pytz==2023.3.post1 + # via dirty-equals respx==0.20.2 ruff==0.1.9 +setuptools==68.2.2 + # via nodeenv six==1.16.0 + # via python-dateutil sniffio==1.3.0 + # via anyio + # via httpx + # via orb-billing time-machine==2.9.0 tomli==2.0.1 + # via mypy + # via pytest typing-extensions==4.8.0 + # via mypy + # via orb-billing + # via pydantic + # via pydantic-core virtualenv==20.24.5 + # via nox zipp==3.17.0 -# The following packages are considered to be unsafe in a requirements file: -setuptools==68.2.2 + # via importlib-metadata diff --git a/requirements.lock b/requirements.lock index 2022a5c5..c5aa659e 100644 --- a/requirements.lock +++ b/requirements.lock @@ -5,18 +5,39 @@ # pre: false # features: [] # all-features: true +# with-sources: false -e file:. annotated-types==0.6.0 + # via pydantic anyio==4.1.0 + # via httpx + # via orb-billing certifi==2023.7.22 + # via httpcore + # via httpx distro==1.8.0 + # via orb-billing exceptiongroup==1.1.3 + # via anyio h11==0.14.0 + # via httpcore httpcore==1.0.2 + # via httpx httpx==0.25.2 + # via orb-billing idna==3.4 + # via anyio + # via httpx pydantic==2.4.2 + # via orb-billing pydantic-core==2.10.1 + # via pydantic sniffio==1.3.0 + # via anyio + # via httpx + # via orb-billing typing-extensions==4.8.0 + # via orb-billing + # via pydantic + # via pydantic-core diff --git a/src/orb/__init__.py b/src/orb/__init__.py index 893128f4..a7338c1c 100644 --- a/src/orb/__init__.py +++ b/src/orb/__init__.py @@ -1,7 +1,7 @@ # File generated from our OpenAPI spec by Stainless. from . import types -from ._types import NoneType, Transport, ProxiesTypes +from ._types import NOT_GIVEN, NoneType, NotGiven, Transport, ProxiesTypes from ._utils import file_from_path from ._client import Orb, Client, Stream, Timeout, AsyncOrb, Transport, AsyncClient, AsyncStream, RequestOptions from ._models import BaseModel @@ -44,6 +44,8 @@ "NoneType", "Transport", "ProxiesTypes", + "NotGiven", + "NOT_GIVEN", "OrbError", "APIError", "APIStatusError", diff --git a/src/orb/_base_client.py b/src/orb/_base_client.py index 73bd2411..f431128e 100644 --- a/src/orb/_base_client.py +++ b/src/orb/_base_client.py @@ -79,7 +79,7 @@ RAW_RESPONSE_HEADER, OVERRIDE_CAST_TO_HEADER, ) -from ._streaming import Stream, AsyncStream +from ._streaming import Stream, SSEDecoder, AsyncStream, SSEBytesDecoder from ._exceptions import ( APIStatusError, APITimeoutError, @@ -431,6 +431,9 @@ def _prepare_url(self, url: str) -> URL: return merge_url + def _make_sse_decoder(self) -> SSEDecoder | SSEBytesDecoder: + return SSEDecoder() + def _build_request( self, options: FinalRequestOptions, @@ -777,6 +780,11 @@ def __init__( else: timeout = DEFAULT_TIMEOUT + if http_client is not None and not isinstance(http_client, httpx.Client): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + f"Invalid `http_client` argument; Expected an instance of `httpx.Client` but got {type(http_client)}" + ) + super().__init__( version=version, limits=limits, @@ -1319,6 +1327,11 @@ def __init__( else: timeout = DEFAULT_TIMEOUT + if http_client is not None and not isinstance(http_client, httpx.AsyncClient): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + f"Invalid `http_client` argument; Expected an instance of `httpx.AsyncClient` but got {type(http_client)}" + ) + super().__init__( version=version, base_url=base_url, diff --git a/src/orb/_files.py b/src/orb/_files.py index b6e8af8b..0d2022ae 100644 --- a/src/orb/_files.py +++ b/src/orb/_files.py @@ -13,12 +13,17 @@ FileContent, RequestFiles, HttpxFileTypes, + Base64FileInput, HttpxFileContent, HttpxRequestFiles, ) from ._utils import is_tuple_t, is_mapping_t, is_sequence_t +def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: + return isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) + + def is_file_content(obj: object) -> TypeGuard[FileContent]: return ( isinstance(obj, bytes) or isinstance(obj, tuple) or isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) diff --git a/src/orb/_legacy_response.py b/src/orb/_legacy_response.py index 2d749b09..6fb2d7fd 100644 --- a/src/orb/_legacy_response.py +++ b/src/orb/_legacy_response.py @@ -13,7 +13,7 @@ import pydantic from ._types import NoneType -from ._utils import is_given +from ._utils import is_given, extract_type_arg, is_annotated_type from ._models import BaseModel, is_basemodel from ._constants import RAW_RESPONSE_HEADER from ._streaming import Stream, AsyncStream, is_stream_class_type, extract_stream_chunk_type @@ -107,6 +107,8 @@ class MyModel(BaseModel): - `list` - `Union` - `str` + - `int` + - `float` - `httpx.Response` """ cache_key = to if to is not None else self._cast_to @@ -172,6 +174,10 @@ def elapsed(self) -> datetime.timedelta: return self.http_response.elapsed def _parse(self, *, to: type[_T] | None = None) -> R | _T: + # unwrap `Annotated[T, ...]` -> `T` + if to and is_annotated_type(to): + to = extract_type_arg(to, 0) + if self._stream: if to: if not is_stream_class_type(to): @@ -213,6 +219,11 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: ) cast_to = to if to is not None else self._cast_to + + # unwrap `Annotated[T, ...]` -> `T` + if is_annotated_type(cast_to): + cast_to = extract_type_arg(cast_to, 0) + if cast_to is NoneType: return cast(R, None) @@ -220,6 +231,12 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: if cast_to == str: return cast(R, response.text) + if cast_to == int: + return cast(R, int(response.text)) + + if cast_to == float: + return cast(R, float(response.text)) + origin = get_origin(cast_to) or cast_to if inspect.isclass(origin) and issubclass(origin, HttpxBinaryResponseContent): @@ -307,7 +324,7 @@ def to_raw_response_wrapper(func: Callable[P, R]) -> Callable[P, LegacyAPIRespon @functools.wraps(func) def wrapped(*args: P.args, **kwargs: P.kwargs) -> LegacyAPIResponse[R]: - extra_headers = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} extra_headers[RAW_RESPONSE_HEADER] = "true" kwargs["extra_headers"] = extra_headers @@ -324,7 +341,7 @@ def async_to_raw_response_wrapper(func: Callable[P, Awaitable[R]]) -> Callable[P @functools.wraps(func) async def wrapped(*args: P.args, **kwargs: P.kwargs) -> LegacyAPIResponse[R]: - extra_headers = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} extra_headers[RAW_RESPONSE_HEADER] = "true" kwargs["extra_headers"] = extra_headers diff --git a/src/orb/_models.py b/src/orb/_models.py index 48d5624f..af68e618 100644 --- a/src/orb/_models.py +++ b/src/orb/_models.py @@ -30,7 +30,16 @@ AnyMapping, HttpxRequestFiles, ) -from ._utils import is_list, is_given, is_mapping, parse_date, parse_datetime, strip_not_given +from ._utils import ( + is_list, + is_given, + is_mapping, + parse_date, + parse_datetime, + strip_not_given, + extract_type_arg, + is_annotated_type, +) from ._compat import ( PYDANTIC_V2, ConfigDict, @@ -275,6 +284,9 @@ def construct_type(*, value: object, type_: type) -> object: If the given value does not match the expected type then it is returned as-is. """ + # unwrap `Annotated[T, ...]` -> `T` + if is_annotated_type(type_): + type_ = extract_type_arg(type_, 0) # we need to use the origin class for any types that are subscripted generics # e.g. Dict[str, object] @@ -283,7 +295,7 @@ def construct_type(*, value: object, type_: type) -> object: if is_union(origin): try: - return validate_type(type_=type_, value=value) + return validate_type(type_=cast("type[object]", type_), value=value) except Exception: pass diff --git a/src/orb/_resource.py b/src/orb/_resource.py index 4e0051e1..6d550782 100644 --- a/src/orb/_resource.py +++ b/src/orb/_resource.py @@ -3,9 +3,10 @@ from __future__ import annotations import time -import asyncio from typing import TYPE_CHECKING +import anyio + if TYPE_CHECKING: from ._client import Orb, AsyncOrb @@ -39,4 +40,4 @@ def __init__(self, client: AsyncOrb) -> None: self._get_api_list = client.get_api_list async def _sleep(self, seconds: float) -> None: - await asyncio.sleep(seconds) + await anyio.sleep(seconds) diff --git a/src/orb/_response.py b/src/orb/_response.py index e65d1034..a3332803 100644 --- a/src/orb/_response.py +++ b/src/orb/_response.py @@ -25,7 +25,7 @@ import pydantic from ._types import NoneType -from ._utils import is_given, extract_type_var_from_base +from ._utils import is_given, extract_type_arg, is_annotated_type, extract_type_var_from_base from ._models import BaseModel, is_basemodel from ._constants import RAW_RESPONSE_HEADER, OVERRIDE_CAST_TO_HEADER from ._streaming import Stream, AsyncStream, is_stream_class_type, extract_stream_chunk_type @@ -121,6 +121,10 @@ def __repr__(self) -> str: ) def _parse(self, *, to: type[_T] | None = None) -> R | _T: + # unwrap `Annotated[T, ...]` -> `T` + if to and is_annotated_type(to): + to = extract_type_arg(to, 0) + if self._is_sse_stream: if to: if not is_stream_class_type(to): @@ -162,6 +166,11 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: ) cast_to = to if to is not None else self._cast_to + + # unwrap `Annotated[T, ...]` -> `T` + if is_annotated_type(cast_to): + cast_to = extract_type_arg(cast_to, 0) + if cast_to is NoneType: return cast(R, None) @@ -172,6 +181,12 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: if cast_to == bytes: return cast(R, response.content) + if cast_to == int: + return cast(R, int(response.text)) + + if cast_to == float: + return cast(R, float(response.text)) + origin = get_origin(cast_to) or cast_to # handle the legacy binary response case @@ -277,6 +292,8 @@ class MyModel(BaseModel): - `list` - `Union` - `str` + - `int` + - `float` - `httpx.Response` """ cache_key = to if to is not None else self._cast_to @@ -626,7 +643,7 @@ def to_streamed_response_wrapper(func: Callable[P, R]) -> Callable[P, ResponseCo @functools.wraps(func) def wrapped(*args: P.args, **kwargs: P.kwargs) -> ResponseContextManager[APIResponse[R]]: - extra_headers = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} extra_headers[RAW_RESPONSE_HEADER] = "stream" kwargs["extra_headers"] = extra_headers @@ -647,7 +664,7 @@ def async_to_streamed_response_wrapper( @functools.wraps(func) def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncResponseContextManager[AsyncAPIResponse[R]]: - extra_headers = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} extra_headers[RAW_RESPONSE_HEADER] = "stream" kwargs["extra_headers"] = extra_headers @@ -671,7 +688,7 @@ def to_custom_streamed_response_wrapper( @functools.wraps(func) def wrapped(*args: P.args, **kwargs: P.kwargs) -> ResponseContextManager[_APIResponseT]: - extra_headers = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} extra_headers[RAW_RESPONSE_HEADER] = "stream" extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls @@ -696,7 +713,7 @@ def async_to_custom_streamed_response_wrapper( @functools.wraps(func) def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncResponseContextManager[_AsyncAPIResponseT]: - extra_headers = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} extra_headers[RAW_RESPONSE_HEADER] = "stream" extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls @@ -716,7 +733,7 @@ def to_raw_response_wrapper(func: Callable[P, R]) -> Callable[P, APIResponse[R]] @functools.wraps(func) def wrapped(*args: P.args, **kwargs: P.kwargs) -> APIResponse[R]: - extra_headers = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} extra_headers[RAW_RESPONSE_HEADER] = "raw" kwargs["extra_headers"] = extra_headers @@ -733,7 +750,7 @@ def async_to_raw_response_wrapper(func: Callable[P, Awaitable[R]]) -> Callable[P @functools.wraps(func) async def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncAPIResponse[R]: - extra_headers = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} extra_headers[RAW_RESPONSE_HEADER] = "raw" kwargs["extra_headers"] = extra_headers @@ -755,7 +772,7 @@ def to_custom_raw_response_wrapper( @functools.wraps(func) def wrapped(*args: P.args, **kwargs: P.kwargs) -> _APIResponseT: - extra_headers = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} extra_headers[RAW_RESPONSE_HEADER] = "raw" extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls @@ -778,7 +795,7 @@ def async_to_custom_raw_response_wrapper( @functools.wraps(func) def wrapped(*args: P.args, **kwargs: P.kwargs) -> Awaitable[_AsyncAPIResponseT]: - extra_headers = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} extra_headers[RAW_RESPONSE_HEADER] = "raw" extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls diff --git a/src/orb/_streaming.py b/src/orb/_streaming.py index e085318e..d1d7d03f 100644 --- a/src/orb/_streaming.py +++ b/src/orb/_streaming.py @@ -5,7 +5,7 @@ import inspect from types import TracebackType from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, AsyncIterator, cast -from typing_extensions import Self, TypeGuard, override, get_origin +from typing_extensions import Self, Protocol, TypeGuard, override, get_origin, runtime_checkable import httpx @@ -23,6 +23,8 @@ class Stream(Generic[_T]): response: httpx.Response + _decoder: SSEDecoder | SSEBytesDecoder + def __init__( self, *, @@ -33,7 +35,7 @@ def __init__( self.response = response self._cast_to = cast_to self._client = client - self._decoder = SSEDecoder() + self._decoder = client._make_sse_decoder() self._iterator = self.__stream__() def __next__(self) -> _T: @@ -44,7 +46,10 @@ def __iter__(self) -> Iterator[_T]: yield item def _iter_events(self) -> Iterator[ServerSentEvent]: - yield from self._decoder.iter(self.response.iter_lines()) + if isinstance(self._decoder, SSEBytesDecoder): + yield from self._decoder.iter_bytes(self.response.iter_bytes()) + else: + yield from self._decoder.iter(self.response.iter_lines()) def __stream__(self) -> Iterator[_T]: cast_to = cast(Any, self._cast_to) @@ -84,6 +89,8 @@ class AsyncStream(Generic[_T]): response: httpx.Response + _decoder: SSEDecoder | SSEBytesDecoder + def __init__( self, *, @@ -94,7 +101,7 @@ def __init__( self.response = response self._cast_to = cast_to self._client = client - self._decoder = SSEDecoder() + self._decoder = client._make_sse_decoder() self._iterator = self.__stream__() async def __anext__(self) -> _T: @@ -105,8 +112,12 @@ async def __aiter__(self) -> AsyncIterator[_T]: yield item async def _iter_events(self) -> AsyncIterator[ServerSentEvent]: - async for sse in self._decoder.aiter(self.response.aiter_lines()): - yield sse + if isinstance(self._decoder, SSEBytesDecoder): + async for sse in self._decoder.aiter_bytes(self.response.aiter_bytes()): + yield sse + else: + async for sse in self._decoder.aiter(self.response.aiter_lines()): + yield sse async def __stream__(self) -> AsyncIterator[_T]: cast_to = cast(Any, self._cast_to) @@ -259,6 +270,17 @@ def decode(self, line: str) -> ServerSentEvent | None: return None +@runtime_checkable +class SSEBytesDecoder(Protocol): + def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + ... + + def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: + """Given an async iterator that yields raw binary data, iterate over it & yield every event encountered""" + ... + + def is_stream_class_type(typ: type) -> TypeGuard[type[Stream[object]] | type[AsyncStream[object]]]: """TypeGuard for determining whether or not the given type is a subclass of `Stream` / `AsyncStream`""" origin = get_origin(typ) or typ diff --git a/src/orb/_types.py b/src/orb/_types.py index c2849de1..4e90cff0 100644 --- a/src/orb/_types.py +++ b/src/orb/_types.py @@ -41,8 +41,10 @@ ProxiesDict = Dict["str | URL", Union[None, str, URL, Proxy]] ProxiesTypes = Union[str, Proxy, ProxiesDict] if TYPE_CHECKING: + Base64FileInput = Union[IO[bytes], PathLike[str]] FileContent = Union[IO[bytes], bytes, PathLike[str]] else: + Base64FileInput = Union[IO[bytes], PathLike] FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8. FileTypes = Union[ # file (or bytes) diff --git a/src/orb/_utils/__init__.py b/src/orb/_utils/__init__.py index b5790a87..56978941 100644 --- a/src/orb/_utils/__init__.py +++ b/src/orb/_utils/__init__.py @@ -44,5 +44,7 @@ from ._transform import ( PropertyInfo as PropertyInfo, transform as transform, + async_transform as async_transform, maybe_transform as maybe_transform, + async_maybe_transform as async_maybe_transform, ) diff --git a/src/orb/_utils/_proxy.py b/src/orb/_utils/_proxy.py index 6f05efcd..b9c12dc3 100644 --- a/src/orb/_utils/_proxy.py +++ b/src/orb/_utils/_proxy.py @@ -45,7 +45,7 @@ def __dir__(self) -> Iterable[str]: @property # type: ignore @override - def __class__(self) -> type: + def __class__(self) -> type: # pyright: ignore proxied = self.__get_proxied__() if issubclass(type(proxied), LazyProxy): return type(proxied) diff --git a/src/orb/_utils/_transform.py b/src/orb/_utils/_transform.py index 2cb7726c..1bd1330c 100644 --- a/src/orb/_utils/_transform.py +++ b/src/orb/_utils/_transform.py @@ -1,9 +1,13 @@ from __future__ import annotations +import io +import base64 +import pathlib from typing import Any, Mapping, TypeVar, cast from datetime import date, datetime from typing_extensions import Literal, get_args, override, get_type_hints +import anyio import pydantic from ._utils import ( @@ -11,6 +15,7 @@ is_mapping, is_iterable, ) +from .._files import is_base64_file_input from ._typing import ( is_list_type, is_union_type, @@ -29,7 +34,7 @@ # TODO: ensure works correctly with forward references in all cases -PropertyFormat = Literal["iso8601", "custom"] +PropertyFormat = Literal["iso8601", "base64", "custom"] class PropertyInfo: @@ -180,11 +185,7 @@ def _transform_recursive( if isinstance(data, pydantic.BaseModel): return model_dump(data, exclude_unset=True) - return _transform_value(data, annotation) - - -def _transform_value(data: object, type_: type) -> object: - annotated_type = _get_annotated_type(type_) + annotated_type = _get_annotated_type(annotation) if annotated_type is None: return data @@ -205,6 +206,22 @@ def _format_data(data: object, format_: PropertyFormat, format_template: str | N if format_ == "custom" and format_template is not None: return data.strftime(format_template) + if format_ == "base64" and is_base64_file_input(data): + binary: str | bytes | None = None + + if isinstance(data, pathlib.Path): + binary = data.read_bytes() + elif isinstance(data, io.IOBase): + binary = data.read() + + if isinstance(binary, str): # type: ignore[unreachable] + binary = binary.encode() + + if not isinstance(binary, bytes): + raise RuntimeError(f"Could not read bytes from {data}; Received {type(binary)}") + + return base64.b64encode(binary).decode("ascii") + return data @@ -222,3 +239,141 @@ def _transform_typeddict( else: result[_maybe_transform_key(key, type_)] = _transform_recursive(value, annotation=type_) return result + + +async def async_maybe_transform( + data: object, + expected_type: object, +) -> Any | None: + """Wrapper over `async_transform()` that allows `None` to be passed. + + See `async_transform()` for more details. + """ + if data is None: + return None + return await async_transform(data, expected_type) + + +async def async_transform( + data: _T, + expected_type: object, +) -> _T: + """Transform dictionaries based off of type information from the given type, for example: + + ```py + class Params(TypedDict, total=False): + card_id: Required[Annotated[str, PropertyInfo(alias="cardID")]] + + + transformed = transform({"card_id": ""}, Params) + # {'cardID': ''} + ``` + + Any keys / data that does not have type information given will be included as is. + + It should be noted that the transformations that this function does are not represented in the type system. + """ + transformed = await _async_transform_recursive(data, annotation=cast(type, expected_type)) + return cast(_T, transformed) + + +async def _async_transform_recursive( + data: object, + *, + annotation: type, + inner_type: type | None = None, +) -> object: + """Transform the given data against the expected type. + + Args: + annotation: The direct type annotation given to the particular piece of data. + This may or may not be wrapped in metadata types, e.g. `Required[T]`, `Annotated[T, ...]` etc + + inner_type: If applicable, this is the "inside" type. This is useful in certain cases where the outside type + is a container type such as `List[T]`. In that case `inner_type` should be set to `T` so that each entry in + the list can be transformed using the metadata from the container type. + + Defaults to the same value as the `annotation` argument. + """ + if inner_type is None: + inner_type = annotation + + stripped_type = strip_annotated_type(inner_type) + if is_typeddict(stripped_type) and is_mapping(data): + return await _async_transform_typeddict(data, stripped_type) + + if ( + # List[T] + (is_list_type(stripped_type) and is_list(data)) + # Iterable[T] + or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + ): + inner_type = extract_type_arg(stripped_type, 0) + return [await _async_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] + + if is_union_type(stripped_type): + # For union types we run the transformation against all subtypes to ensure that everything is transformed. + # + # TODO: there may be edge cases where the same normalized field name will transform to two different names + # in different subtypes. + for subtype in get_args(stripped_type): + data = await _async_transform_recursive(data, annotation=annotation, inner_type=subtype) + return data + + if isinstance(data, pydantic.BaseModel): + return model_dump(data, exclude_unset=True) + + annotated_type = _get_annotated_type(annotation) + if annotated_type is None: + return data + + # ignore the first argument as it is the actual type + annotations = get_args(annotated_type)[1:] + for annotation in annotations: + if isinstance(annotation, PropertyInfo) and annotation.format is not None: + return await _async_format_data(data, annotation.format, annotation.format_template) + + return data + + +async def _async_format_data(data: object, format_: PropertyFormat, format_template: str | None) -> object: + if isinstance(data, (date, datetime)): + if format_ == "iso8601": + return data.isoformat() + + if format_ == "custom" and format_template is not None: + return data.strftime(format_template) + + if format_ == "base64" and is_base64_file_input(data): + binary: str | bytes | None = None + + if isinstance(data, pathlib.Path): + binary = await anyio.Path(data).read_bytes() + elif isinstance(data, io.IOBase): + binary = data.read() + + if isinstance(binary, str): # type: ignore[unreachable] + binary = binary.encode() + + if not isinstance(binary, bytes): + raise RuntimeError(f"Could not read bytes from {data}; Received {type(binary)}") + + return base64.b64encode(binary).decode("ascii") + + return data + + +async def _async_transform_typeddict( + data: Mapping[str, object], + expected_type: type, +) -> Mapping[str, object]: + result: dict[str, object] = {} + annotations = get_type_hints(expected_type, include_extras=True) + for key, value in data.items(): + type_ = annotations.get(key) + if type_ is None: + # we do not have a type annotation for this field, leave it as is + result[key] = value + else: + result[_maybe_transform_key(key, type_)] = await _async_transform_recursive(value, annotation=type_) + return result diff --git a/src/orb/_version.py b/src/orb/_version.py index e1375f76..28cf86a1 100644 --- a/src/orb/_version.py +++ b/src/orb/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. __title__ = "orb" -__version__ = "1.42.3" # x-release-please-version +__version__ = "1.43.0" # x-release-please-version diff --git a/src/orb/resources/beta/price.py b/src/orb/resources/beta/price.py index c2458e49..2cfd8ef2 100644 --- a/src/orb/resources/beta/price.py +++ b/src/orb/resources/beta/price.py @@ -9,7 +9,10 @@ from ... import _legacy_response from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from ..._utils import maybe_transform +from ..._utils import ( + maybe_transform, + async_maybe_transform, +) from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -205,7 +208,7 @@ async def evaluate( raise ValueError(f"Expected a non-empty value for `price_id` but received {price_id!r}") return await self._post( f"/prices/{price_id}/evaluate", - body=maybe_transform( + body=await async_maybe_transform( { "timeframe_end": timeframe_end, "timeframe_start": timeframe_start, diff --git a/src/orb/resources/coupons/coupons.py b/src/orb/resources/coupons/coupons.py index 5e4b5d1f..e7a5a772 100644 --- a/src/orb/resources/coupons/coupons.py +++ b/src/orb/resources/coupons/coupons.py @@ -9,7 +9,10 @@ from ... import _legacy_response from ...types import Coupon, coupon_list_params, coupon_create_params from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from ..._utils import maybe_transform +from ..._utils import ( + maybe_transform, + async_maybe_transform, +) from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -297,7 +300,7 @@ async def create( """ return await self._post( "/coupons", - body=maybe_transform( + body=await async_maybe_transform( { "discount": discount, "redemption_code": redemption_code, diff --git a/src/orb/resources/customers/balance_transactions.py b/src/orb/resources/customers/balance_transactions.py index bbe4b2f3..aaf0f80a 100644 --- a/src/orb/resources/customers/balance_transactions.py +++ b/src/orb/resources/customers/balance_transactions.py @@ -10,7 +10,10 @@ from ... import _legacy_response from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from ..._utils import maybe_transform +from ..._utils import ( + maybe_transform, + async_maybe_transform, +) from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -224,7 +227,7 @@ async def create( raise ValueError(f"Expected a non-empty value for `customer_id` but received {customer_id!r}") return await self._post( f"/customers/{customer_id}/balance_transactions", - body=maybe_transform( + body=await async_maybe_transform( { "amount": amount, "type": type, diff --git a/src/orb/resources/customers/costs.py b/src/orb/resources/customers/costs.py index 2b8bde54..efd66859 100644 --- a/src/orb/resources/customers/costs.py +++ b/src/orb/resources/customers/costs.py @@ -10,7 +10,10 @@ from ... import _legacy_response from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from ..._utils import maybe_transform +from ..._utils import ( + maybe_transform, + async_maybe_transform, +) from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -603,7 +606,7 @@ async def list( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform( + query=await async_maybe_transform( { "timeframe_end": timeframe_end, "timeframe_start": timeframe_start, @@ -794,7 +797,7 @@ async def list_by_external_id( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform( + query=await async_maybe_transform( { "timeframe_end": timeframe_end, "timeframe_start": timeframe_start, diff --git a/src/orb/resources/customers/credits/ledger.py b/src/orb/resources/customers/credits/ledger.py index 1c15e2d2..4ad8ff74 100644 --- a/src/orb/resources/customers/credits/ledger.py +++ b/src/orb/resources/customers/credits/ledger.py @@ -10,7 +10,11 @@ from .... import _legacy_response from ...._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from ...._utils import required_args, maybe_transform +from ...._utils import ( + required_args, + maybe_transform, + async_maybe_transform, +) from ...._compat import cached_property from ...._resource import SyncAPIResource, AsyncAPIResource from ...._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -3185,7 +3189,7 @@ async def create_entry( LedgerCreateEntryResponse, await self._post( f"/customers/{customer_id}/credits/ledger_entry", - body=maybe_transform( + body=await async_maybe_transform( { "amount": amount, "entry_type": entry_type, @@ -4082,7 +4086,7 @@ async def create_entry_by_external_id( LedgerCreateEntryByExternalIDResponse, await self._post( f"/customers/external_customer_id/{external_customer_id}/credits/ledger_entry", - body=maybe_transform( + body=await async_maybe_transform( { "amount": amount, "entry_type": entry_type, diff --git a/src/orb/resources/customers/credits/top_ups.py b/src/orb/resources/customers/credits/top_ups.py index 3b2c203f..f7ae3e91 100644 --- a/src/orb/resources/customers/credits/top_ups.py +++ b/src/orb/resources/customers/credits/top_ups.py @@ -9,7 +9,10 @@ from .... import _legacy_response from ...._types import NOT_GIVEN, Body, Query, Headers, NoneType, NotGiven -from ...._utils import maybe_transform +from ...._utils import ( + maybe_transform, + async_maybe_transform, +) from ...._compat import cached_property from ...._resource import SyncAPIResource, AsyncAPIResource from ...._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -473,7 +476,7 @@ async def create( raise ValueError(f"Expected a non-empty value for `customer_id` but received {customer_id!r}") return await self._post( f"/customers/{customer_id}/credits/top_ups", - body=maybe_transform( + body=await async_maybe_transform( { "amount": amount, "currency": currency, @@ -653,7 +656,7 @@ async def create_by_external_id( ) return await self._post( f"/customers/external_customer_id/{external_customer_id}/credits/top_ups", - body=maybe_transform( + body=await async_maybe_transform( { "amount": amount, "currency": currency, diff --git a/src/orb/resources/customers/customers.py b/src/orb/resources/customers/customers.py index a0607306..db68c2f7 100644 --- a/src/orb/resources/customers/customers.py +++ b/src/orb/resources/customers/customers.py @@ -41,7 +41,10 @@ AsyncCreditsWithStreamingResponse, ) from ..._types import NOT_GIVEN, Body, Query, Headers, NoneType, NotGiven -from ..._utils import maybe_transform +from ..._utils import ( + maybe_transform, + async_maybe_transform, +) from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -1090,7 +1093,7 @@ async def create( """ return await self._post( "/customers", - body=maybe_transform( + body=await async_maybe_transform( { "email": email, "name": name, @@ -1290,7 +1293,7 @@ async def update( raise ValueError(f"Expected a non-empty value for `customer_id` but received {customer_id!r}") return await self._put( f"/customers/{customer_id}", - body=maybe_transform( + body=await async_maybe_transform( { "accounting_sync_configuration": accounting_sync_configuration, "additional_emails": additional_emails, @@ -1682,7 +1685,7 @@ async def update_by_external_id( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._put( f"/customers/external_customer_id/{id}", - body=maybe_transform( + body=await async_maybe_transform( { "accounting_sync_configuration": accounting_sync_configuration, "additional_emails": additional_emails, diff --git a/src/orb/resources/customers/usage.py b/src/orb/resources/customers/usage.py index 29904452..3e25ff6e 100644 --- a/src/orb/resources/customers/usage.py +++ b/src/orb/resources/customers/usage.py @@ -9,7 +9,10 @@ from ... import _legacy_response from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from ..._utils import maybe_transform +from ..._utils import ( + maybe_transform, + async_maybe_transform, +) from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -486,14 +489,14 @@ async def update( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._patch( f"/customers/{id}/usage", - body=maybe_transform({"events": events}, usage_update_params.UsageUpdateParams), + body=await async_maybe_transform({"events": events}, usage_update_params.UsageUpdateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, idempotency_key=idempotency_key, - query=maybe_transform( + query=await async_maybe_transform( { "timeframe_end": timeframe_end, "timeframe_start": timeframe_start, @@ -639,14 +642,16 @@ async def update_by_external_id( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._patch( f"/customers/external_customer_id/{id}/usage", - body=maybe_transform({"events": events}, usage_update_by_external_id_params.UsageUpdateByExternalIDParams), + body=await async_maybe_transform( + {"events": events}, usage_update_by_external_id_params.UsageUpdateByExternalIDParams + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, idempotency_key=idempotency_key, - query=maybe_transform( + query=await async_maybe_transform( { "timeframe_end": timeframe_end, "timeframe_start": timeframe_start, diff --git a/src/orb/resources/events/backfills.py b/src/orb/resources/events/backfills.py index 0c0867d2..1a67c01b 100644 --- a/src/orb/resources/events/backfills.py +++ b/src/orb/resources/events/backfills.py @@ -9,7 +9,10 @@ from ... import _legacy_response from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from ..._utils import maybe_transform +from ..._utils import ( + maybe_transform, + async_maybe_transform, +) from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -393,7 +396,7 @@ async def create( """ return await self._post( "/events/backfills", - body=maybe_transform( + body=await async_maybe_transform( { "timeframe_end": timeframe_end, "timeframe_start": timeframe_start, diff --git a/src/orb/resources/events/events.py b/src/orb/resources/events/events.py index a3fad044..3b0ad3c6 100644 --- a/src/orb/resources/events/events.py +++ b/src/orb/resources/events/events.py @@ -18,7 +18,10 @@ event_update_params, ) from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from ..._utils import maybe_transform +from ..._utils import ( + maybe_transform, + async_maybe_transform, +) from ..._compat import cached_property from .backfills import ( Backfills, @@ -666,7 +669,7 @@ async def update( raise ValueError(f"Expected a non-empty value for `event_id` but received {event_id!r}") return await self._put( f"/events/{event_id}", - body=maybe_transform( + body=await async_maybe_transform( { "event_name": event_name, "properties": properties, @@ -995,14 +998,14 @@ async def ingest( """ return await self._post( "/ingest", - body=maybe_transform({"events": events}, event_ingest_params.EventIngestParams), + body=await async_maybe_transform({"events": events}, event_ingest_params.EventIngestParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, idempotency_key=idempotency_key, - query=maybe_transform( + query=await async_maybe_transform( { "backfill_id": backfill_id, "debug": debug, @@ -1068,7 +1071,7 @@ async def search( """ return await self._post( "/events/search", - body=maybe_transform( + body=await async_maybe_transform( { "event_ids": event_ids, "timeframe_end": timeframe_end, diff --git a/src/orb/resources/invoice_line_items.py b/src/orb/resources/invoice_line_items.py index a22613d8..cc1a3abf 100644 --- a/src/orb/resources/invoice_line_items.py +++ b/src/orb/resources/invoice_line_items.py @@ -10,7 +10,10 @@ from .. import _legacy_response from ..types import InvoiceLineItemCreateResponse, invoice_line_item_create_params from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from .._utils import maybe_transform +from .._utils import ( + maybe_transform, + async_maybe_transform, +) from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -157,7 +160,7 @@ async def create( """ return await self._post( "/invoice_line_items", - body=maybe_transform( + body=await async_maybe_transform( { "amount": amount, "end_date": end_date, diff --git a/src/orb/resources/invoices.py b/src/orb/resources/invoices.py index bf003249..01189ccc 100644 --- a/src/orb/resources/invoices.py +++ b/src/orb/resources/invoices.py @@ -18,7 +18,10 @@ invoice_fetch_upcoming_params, ) from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from .._utils import maybe_transform +from .._utils import ( + maybe_transform, + async_maybe_transform, +) from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -502,7 +505,7 @@ async def create( """ return await self._post( "/invoices", - body=maybe_transform( + body=await async_maybe_transform( { "currency": currency, "invoice_date": invoice_date, @@ -681,7 +684,7 @@ async def fetch_upcoming( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform( + query=await async_maybe_transform( {"subscription_id": subscription_id}, invoice_fetch_upcoming_params.InvoiceFetchUpcomingParams ), ), @@ -775,7 +778,7 @@ async def mark_paid( raise ValueError(f"Expected a non-empty value for `invoice_id` but received {invoice_id!r}") return await self._post( f"/invoices/{invoice_id}/mark_paid", - body=maybe_transform( + body=await async_maybe_transform( { "external_id": external_id, "notes": notes, diff --git a/src/orb/resources/items.py b/src/orb/resources/items.py index b10e4b10..3f83cda3 100644 --- a/src/orb/resources/items.py +++ b/src/orb/resources/items.py @@ -9,7 +9,10 @@ from .. import _legacy_response from ..types import Item, item_list_params, item_create_params from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from .._utils import maybe_transform +from .._utils import ( + maybe_transform, + async_maybe_transform, +) from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -194,7 +197,7 @@ async def create( """ return await self._post( "/items", - body=maybe_transform({"name": name}, item_create_params.ItemCreateParams), + body=await async_maybe_transform({"name": name}, item_create_params.ItemCreateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, diff --git a/src/orb/resources/metrics.py b/src/orb/resources/metrics.py index 21dabdda..0f3187f8 100644 --- a/src/orb/resources/metrics.py +++ b/src/orb/resources/metrics.py @@ -16,7 +16,10 @@ metric_create_params, ) from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from .._utils import maybe_transform +from .._utils import ( + maybe_transform, + async_maybe_transform, +) from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -255,7 +258,7 @@ async def create( """ return await self._post( "/metrics", - body=maybe_transform( + body=await async_maybe_transform( { "description": description, "item_id": item_id, diff --git a/src/orb/resources/plans/external_plan_id.py b/src/orb/resources/plans/external_plan_id.py index a23386f9..de55faee 100644 --- a/src/orb/resources/plans/external_plan_id.py +++ b/src/orb/resources/plans/external_plan_id.py @@ -9,7 +9,10 @@ from ... import _legacy_response from ...types import Plan from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from ..._utils import maybe_transform +from ..._utils import ( + maybe_transform, + async_maybe_transform, +) from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -207,7 +210,7 @@ async def update( ) return await self._put( f"/plans/external_plan_id/{other_external_plan_id}", - body=maybe_transform( + body=await async_maybe_transform( { "external_plan_id": external_plan_id, "metadata": metadata, diff --git a/src/orb/resources/plans/plans.py b/src/orb/resources/plans/plans.py index 81e01710..91588eda 100644 --- a/src/orb/resources/plans/plans.py +++ b/src/orb/resources/plans/plans.py @@ -11,7 +11,10 @@ from ... import _legacy_response from ...types import Plan, plan_list_params, plan_create_params, plan_update_params from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from ..._utils import maybe_transform +from ..._utils import ( + maybe_transform, + async_maybe_transform, +) from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -354,7 +357,7 @@ async def create( """ return await self._post( "/plans", - body=maybe_transform( + body=await async_maybe_transform( { "currency": currency, "name": name, @@ -419,7 +422,7 @@ async def update( raise ValueError(f"Expected a non-empty value for `plan_id` but received {plan_id!r}") return await self._put( f"/plans/{plan_id}", - body=maybe_transform( + body=await async_maybe_transform( { "external_plan_id": external_plan_id, "metadata": metadata, diff --git a/src/orb/resources/prices/prices.py b/src/orb/resources/prices/prices.py index 834c1759..97e75301 100644 --- a/src/orb/resources/prices/prices.py +++ b/src/orb/resources/prices/prices.py @@ -10,7 +10,11 @@ from ... import _legacy_response from ...types import Price, price_list_params, price_create_params from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from ..._utils import required_args, maybe_transform +from ..._utils import ( + required_args, + maybe_transform, + async_maybe_transform, +) from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -884,6 +888,146 @@ def create( """ ... + @overload + def create( + self, + *, + cadence: Literal["annual", "monthly", "quarterly", "one_time"], + currency: str, + item_id: str, + model_type: Literal["tiered_package_with_minimum"], + name: str, + tiered_package_with_minimum_config: Dict[str, object], + billable_metric_id: Optional[str] | NotGiven = NOT_GIVEN, + billed_in_advance: Optional[bool] | NotGiven = NOT_GIVEN, + external_price_id: Optional[str] | NotGiven = NOT_GIVEN, + fixed_price_quantity: Optional[float] | NotGiven = NOT_GIVEN, + invoice_grouping_key: Optional[str] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + idempotency_key: str | None = None, + ) -> Price: + """This endpoint is used to create a [price](../reference/price). + + A price created + using this endpoint is always an add-on, meaning that it’s not associated with a + specific plan and can instead be individually added to subscriptions, including + subscriptions on different plans. + + An `external_price_id` can be optionally specified as an alias to allow + ergonomic interaction with prices in the Orb API. + + See the [Price resource](../reference/price) for the specification of different + price model configurations possible in this endpoint. + + Args: + cadence: The cadence to bill for this price on. + + currency: An ISO 4217 currency string for which this price is billed in. + + item_id: The id of the item the plan will be associated with. + + name: The name of the price. + + billable_metric_id: The id of the billable metric for the price. Only needed if the price is + usage-based. + + billed_in_advance: If the Price represents a fixed cost, the price will be billed in-advance if + this is true, and in-arrears if this is false. + + external_price_id: An alias for the price. + + fixed_price_quantity: If the Price represents a fixed cost, this represents the quantity of units + applied. + + invoice_grouping_key: The property used to group this price on an invoice + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + + idempotency_key: Specify a custom idempotency key for this request + """ + ... + + @overload + def create( + self, + *, + cadence: Literal["annual", "monthly", "quarterly", "one_time"], + currency: str, + item_id: str, + model_type: Literal["unit_with_percent"], + name: str, + unit_with_percent_config: Dict[str, object], + billable_metric_id: Optional[str] | NotGiven = NOT_GIVEN, + billed_in_advance: Optional[bool] | NotGiven = NOT_GIVEN, + external_price_id: Optional[str] | NotGiven = NOT_GIVEN, + fixed_price_quantity: Optional[float] | NotGiven = NOT_GIVEN, + invoice_grouping_key: Optional[str] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + idempotency_key: str | None = None, + ) -> Price: + """This endpoint is used to create a [price](../reference/price). + + A price created + using this endpoint is always an add-on, meaning that it’s not associated with a + specific plan and can instead be individually added to subscriptions, including + subscriptions on different plans. + + An `external_price_id` can be optionally specified as an alias to allow + ergonomic interaction with prices in the Orb API. + + See the [Price resource](../reference/price) for the specification of different + price model configurations possible in this endpoint. + + Args: + cadence: The cadence to bill for this price on. + + currency: An ISO 4217 currency string for which this price is billed in. + + item_id: The id of the item the plan will be associated with. + + name: The name of the price. + + billable_metric_id: The id of the billable metric for the price. Only needed if the price is + usage-based. + + billed_in_advance: If the Price represents a fixed cost, the price will be billed in-advance if + this is true, and in-arrears if this is false. + + external_price_id: An alias for the price. + + fixed_price_quantity: If the Price represents a fixed cost, this represents the quantity of units + applied. + + invoice_grouping_key: The property used to group this price on an invoice + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + + idempotency_key: Specify a custom idempotency key for this request + """ + ... + @required_args( ["cadence", "currency", "item_id", "model_type", "name", "unit_config"], ["cadence", "currency", "item_id", "model_type", "name", "package_config"], @@ -897,6 +1041,8 @@ def create( ["cadence", "currency", "item_id", "model_type", "name", "tiered_package_config"], ["cadence", "currency", "item_id", "model_type", "name", "tiered_with_minimum_config"], ["cadence", "currency", "item_id", "model_type", "name", "package_with_allocation_config"], + ["cadence", "currency", "item_id", "model_type", "name", "tiered_package_with_minimum_config"], + ["cadence", "currency", "item_id", "model_type", "name", "unit_with_percent_config"], ) def create( self, @@ -915,7 +1061,9 @@ def create( | Literal["threshold_total_amount"] | Literal["tiered_package"] | Literal["tiered_with_minimum"] - | Literal["package_with_allocation"], + | Literal["package_with_allocation"] + | Literal["tiered_package_with_minimum"] + | Literal["unit_with_percent"], name: str, unit_config: price_create_params.NewFloatingUnitPriceUnitConfig | NotGiven = NOT_GIVEN, billable_metric_id: Optional[str] | NotGiven = NOT_GIVEN, @@ -934,6 +1082,8 @@ def create( tiered_package_config: Dict[str, object] | NotGiven = NOT_GIVEN, tiered_with_minimum_config: Dict[str, object] | NotGiven = NOT_GIVEN, package_with_allocation_config: Dict[str, object] | NotGiven = NOT_GIVEN, + tiered_package_with_minimum_config: Dict[str, object] | NotGiven = NOT_GIVEN, + unit_with_percent_config: Dict[str, object] | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -970,6 +1120,8 @@ def create( "tiered_package_config": tiered_package_config, "tiered_with_minimum_config": tiered_with_minimum_config, "package_with_allocation_config": package_with_allocation_config, + "tiered_package_with_minimum_config": tiered_package_with_minimum_config, + "unit_with_percent_config": unit_with_percent_config, }, price_create_params.PriceCreateParams, ), @@ -1923,6 +2075,146 @@ async def create( """ ... + @overload + async def create( + self, + *, + cadence: Literal["annual", "monthly", "quarterly", "one_time"], + currency: str, + item_id: str, + model_type: Literal["tiered_package_with_minimum"], + name: str, + tiered_package_with_minimum_config: Dict[str, object], + billable_metric_id: Optional[str] | NotGiven = NOT_GIVEN, + billed_in_advance: Optional[bool] | NotGiven = NOT_GIVEN, + external_price_id: Optional[str] | NotGiven = NOT_GIVEN, + fixed_price_quantity: Optional[float] | NotGiven = NOT_GIVEN, + invoice_grouping_key: Optional[str] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + idempotency_key: str | None = None, + ) -> Price: + """This endpoint is used to create a [price](../reference/price). + + A price created + using this endpoint is always an add-on, meaning that it’s not associated with a + specific plan and can instead be individually added to subscriptions, including + subscriptions on different plans. + + An `external_price_id` can be optionally specified as an alias to allow + ergonomic interaction with prices in the Orb API. + + See the [Price resource](../reference/price) for the specification of different + price model configurations possible in this endpoint. + + Args: + cadence: The cadence to bill for this price on. + + currency: An ISO 4217 currency string for which this price is billed in. + + item_id: The id of the item the plan will be associated with. + + name: The name of the price. + + billable_metric_id: The id of the billable metric for the price. Only needed if the price is + usage-based. + + billed_in_advance: If the Price represents a fixed cost, the price will be billed in-advance if + this is true, and in-arrears if this is false. + + external_price_id: An alias for the price. + + fixed_price_quantity: If the Price represents a fixed cost, this represents the quantity of units + applied. + + invoice_grouping_key: The property used to group this price on an invoice + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + + idempotency_key: Specify a custom idempotency key for this request + """ + ... + + @overload + async def create( + self, + *, + cadence: Literal["annual", "monthly", "quarterly", "one_time"], + currency: str, + item_id: str, + model_type: Literal["unit_with_percent"], + name: str, + unit_with_percent_config: Dict[str, object], + billable_metric_id: Optional[str] | NotGiven = NOT_GIVEN, + billed_in_advance: Optional[bool] | NotGiven = NOT_GIVEN, + external_price_id: Optional[str] | NotGiven = NOT_GIVEN, + fixed_price_quantity: Optional[float] | NotGiven = NOT_GIVEN, + invoice_grouping_key: Optional[str] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + idempotency_key: str | None = None, + ) -> Price: + """This endpoint is used to create a [price](../reference/price). + + A price created + using this endpoint is always an add-on, meaning that it’s not associated with a + specific plan and can instead be individually added to subscriptions, including + subscriptions on different plans. + + An `external_price_id` can be optionally specified as an alias to allow + ergonomic interaction with prices in the Orb API. + + See the [Price resource](../reference/price) for the specification of different + price model configurations possible in this endpoint. + + Args: + cadence: The cadence to bill for this price on. + + currency: An ISO 4217 currency string for which this price is billed in. + + item_id: The id of the item the plan will be associated with. + + name: The name of the price. + + billable_metric_id: The id of the billable metric for the price. Only needed if the price is + usage-based. + + billed_in_advance: If the Price represents a fixed cost, the price will be billed in-advance if + this is true, and in-arrears if this is false. + + external_price_id: An alias for the price. + + fixed_price_quantity: If the Price represents a fixed cost, this represents the quantity of units + applied. + + invoice_grouping_key: The property used to group this price on an invoice + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + + idempotency_key: Specify a custom idempotency key for this request + """ + ... + @required_args( ["cadence", "currency", "item_id", "model_type", "name", "unit_config"], ["cadence", "currency", "item_id", "model_type", "name", "package_config"], @@ -1936,6 +2228,8 @@ async def create( ["cadence", "currency", "item_id", "model_type", "name", "tiered_package_config"], ["cadence", "currency", "item_id", "model_type", "name", "tiered_with_minimum_config"], ["cadence", "currency", "item_id", "model_type", "name", "package_with_allocation_config"], + ["cadence", "currency", "item_id", "model_type", "name", "tiered_package_with_minimum_config"], + ["cadence", "currency", "item_id", "model_type", "name", "unit_with_percent_config"], ) async def create( self, @@ -1954,7 +2248,9 @@ async def create( | Literal["threshold_total_amount"] | Literal["tiered_package"] | Literal["tiered_with_minimum"] - | Literal["package_with_allocation"], + | Literal["package_with_allocation"] + | Literal["tiered_package_with_minimum"] + | Literal["unit_with_percent"], name: str, unit_config: price_create_params.NewFloatingUnitPriceUnitConfig | NotGiven = NOT_GIVEN, billable_metric_id: Optional[str] | NotGiven = NOT_GIVEN, @@ -1973,6 +2269,8 @@ async def create( tiered_package_config: Dict[str, object] | NotGiven = NOT_GIVEN, tiered_with_minimum_config: Dict[str, object] | NotGiven = NOT_GIVEN, package_with_allocation_config: Dict[str, object] | NotGiven = NOT_GIVEN, + tiered_package_with_minimum_config: Dict[str, object] | NotGiven = NOT_GIVEN, + unit_with_percent_config: Dict[str, object] | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -1985,7 +2283,7 @@ async def create( Price, await self._post( "/prices", - body=maybe_transform( + body=await async_maybe_transform( { "cadence": cadence, "currency": currency, @@ -2009,6 +2307,8 @@ async def create( "tiered_package_config": tiered_package_config, "tiered_with_minimum_config": tiered_with_minimum_config, "package_with_allocation_config": package_with_allocation_config, + "tiered_package_with_minimum_config": tiered_package_with_minimum_config, + "unit_with_percent_config": unit_with_percent_config, }, price_create_params.PriceCreateParams, ), diff --git a/src/orb/resources/subscriptions.py b/src/orb/resources/subscriptions.py index d0e61c2c..6c19cb5e 100644 --- a/src/orb/resources/subscriptions.py +++ b/src/orb/resources/subscriptions.py @@ -27,7 +27,10 @@ subscription_unschedule_fixed_fee_quantity_updates_params, ) from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from .._utils import maybe_transform +from .._utils import ( + maybe_transform, + async_maybe_transform, +) from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -2128,7 +2131,7 @@ async def create( """ return await self._post( "/subscriptions", - body=maybe_transform( + body=await async_maybe_transform( { "align_billing_with_subscription_start_date": align_billing_with_subscription_start_date, "auto_collection": auto_collection, @@ -2329,7 +2332,7 @@ async def cancel( raise ValueError(f"Expected a non-empty value for `subscription_id` but received {subscription_id!r}") return await self._post( f"/subscriptions/{subscription_id}/cancel", - body=maybe_transform( + body=await async_maybe_transform( { "cancel_option": cancel_option, "cancellation_date": cancellation_date, @@ -2433,7 +2436,7 @@ async def fetch_costs( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform( + query=await async_maybe_transform( { "timeframe_end": timeframe_end, "timeframe_start": timeframe_start, @@ -2770,7 +2773,7 @@ async def fetch_usage( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform( + query=await async_maybe_transform( { "billable_metric_id": billable_metric_id, "cursor": cursor, @@ -2884,7 +2887,7 @@ async def price_intervals( raise ValueError(f"Expected a non-empty value for `subscription_id` but received {subscription_id!r}") return await self._post( f"/subscriptions/{subscription_id}/price_intervals", - body=maybe_transform( + body=await async_maybe_transform( { "add": add, "edit": edit, @@ -3012,7 +3015,7 @@ async def schedule_plan_change( raise ValueError(f"Expected a non-empty value for `subscription_id` but received {subscription_id!r}") return await self._post( f"/subscriptions/{subscription_id}/schedule_plan_change", - body=maybe_transform( + body=await async_maybe_transform( { "change_option": change_option, "align_billing_with_plan_change_date": align_billing_with_plan_change_date, @@ -3074,7 +3077,7 @@ async def trigger_phase( raise ValueError(f"Expected a non-empty value for `subscription_id` but received {subscription_id!r}") return await self._post( f"/subscriptions/{subscription_id}/trigger_phase", - body=maybe_transform( + body=await async_maybe_transform( {"effective_date": effective_date}, subscription_trigger_phase_params.SubscriptionTriggerPhaseParams ), options=make_request_options( @@ -3168,7 +3171,7 @@ async def unschedule_fixed_fee_quantity_updates( raise ValueError(f"Expected a non-empty value for `subscription_id` but received {subscription_id!r}") return await self._post( f"/subscriptions/{subscription_id}/unschedule_fixed_fee_quantity_updates", - body=maybe_transform( + body=await async_maybe_transform( {"price_id": price_id}, subscription_unschedule_fixed_fee_quantity_updates_params.SubscriptionUnscheduleFixedFeeQuantityUpdatesParams, ), @@ -3280,7 +3283,7 @@ async def update_fixed_fee_quantity( raise ValueError(f"Expected a non-empty value for `subscription_id` but received {subscription_id!r}") return await self._post( f"/subscriptions/{subscription_id}/update_fixed_fee_quantity", - body=maybe_transform( + body=await async_maybe_transform( { "price_id": price_id, "quantity": quantity, diff --git a/src/orb/types/plan_create_params.py b/src/orb/types/plan_create_params.py index d322e1df..45a26ebe 100644 --- a/src/orb/types/plan_create_params.py +++ b/src/orb/types/plan_create_params.py @@ -32,6 +32,7 @@ "PriceNewPlanThresholdTotalAmountPrice", "PriceNewPlanTieredPackagePrice", "PriceNewPlanTieredWithMinimumPrice", + "PriceNewPlanUnitWithPercentPrice", "PriceNewPlanPackageWithAllocationPrice", ] @@ -632,6 +633,45 @@ class PriceNewPlanTieredWithMinimumPrice(TypedDict, total=False): """The property used to group this price on an invoice""" +class PriceNewPlanUnitWithPercentPrice(TypedDict, total=False): + cadence: Required[Literal["annual", "monthly", "quarterly", "one_time"]] + """The cadence to bill for this price on.""" + + item_id: Required[str] + """The id of the item the plan will be associated with.""" + + model_type: Required[Literal["unit_with_percent"]] + + name: Required[str] + """The name of the price.""" + + unit_with_percent_config: Required[Dict[str, object]] + + billable_metric_id: Optional[str] + """The id of the billable metric for the price. + + Only needed if the price is usage-based. + """ + + billed_in_advance: Optional[bool] + """ + If the Price represents a fixed cost, the price will be billed in-advance if + this is true, and in-arrears if this is false. + """ + + external_price_id: Optional[str] + """An alias for the price.""" + + fixed_price_quantity: Optional[float] + """ + If the Price represents a fixed cost, this represents the quantity of units + applied. + """ + + invoice_grouping_key: Optional[str] + """The property used to group this price on an invoice""" + + class PriceNewPlanPackageWithAllocationPrice(TypedDict, total=False): cadence: Required[Literal["annual", "monthly", "quarterly", "one_time"]] """The cadence to bill for this price on.""" @@ -683,5 +723,6 @@ class PriceNewPlanPackageWithAllocationPrice(TypedDict, total=False): PriceNewPlanThresholdTotalAmountPrice, PriceNewPlanTieredPackagePrice, PriceNewPlanTieredWithMinimumPrice, + PriceNewPlanUnitWithPercentPrice, PriceNewPlanPackageWithAllocationPrice, ] diff --git a/src/orb/types/price.py b/src/orb/types/price.py index fd552e90..f68dd778 100644 --- a/src/orb/types/price.py +++ b/src/orb/types/price.py @@ -84,6 +84,11 @@ "PackageWithAllocationPriceItem", "PackageWithAllocationPriceMaximum", "PackageWithAllocationPriceMinimum", + "UnitWithPercentPrice", + "UnitWithPercentPriceBillableMetric", + "UnitWithPercentPriceItem", + "UnitWithPercentPriceMaximum", + "UnitWithPercentPriceMinimum", ] @@ -1057,6 +1062,76 @@ class PackageWithAllocationPrice(BaseModel): price_type: Literal["usage_price", "fixed_price"] +class UnitWithPercentPriceBillableMetric(BaseModel): + id: str + + +class UnitWithPercentPriceItem(BaseModel): + id: str + + name: str + + +class UnitWithPercentPriceMaximum(BaseModel): + applies_to_price_ids: List[str] + """List of price_ids that this maximum amount applies to. + + For plan/plan phase maximums, this can be a subset of prices. + """ + + maximum_amount: str + """Maximum amount applied""" + + +class UnitWithPercentPriceMinimum(BaseModel): + applies_to_price_ids: List[str] + """List of price_ids that this minimum amount applies to. + + For plan/plan phase minimums, this can be a subset of prices. + """ + + minimum_amount: str + """Minimum amount applied""" + + +class UnitWithPercentPrice(BaseModel): + id: str + + billable_metric: Optional[UnitWithPercentPriceBillableMetric] = None + + cadence: Literal["one_time", "monthly", "quarterly", "annual"] + + created_at: datetime + + currency: str + + discount: Optional[Discount] = None + + external_price_id: Optional[str] = None + + fixed_price_quantity: Optional[float] = None + + item: UnitWithPercentPriceItem + + maximum: Optional[UnitWithPercentPriceMaximum] = None + + maximum_amount: Optional[str] = None + + minimum: Optional[UnitWithPercentPriceMinimum] = None + + minimum_amount: Optional[str] = None + + price_model_type: Literal["unit_with_percent"] = FieldInfo(alias="model_type") + + name: str + + plan_phase_order: Optional[int] = None + + price_type: Literal["usage_price", "fixed_price"] + + unit_with_percent_config: Dict[str, object] + + Price = Union[ UnitPrice, PackagePrice, @@ -1070,4 +1145,5 @@ class PackageWithAllocationPrice(BaseModel): TieredPackagePrice, TieredWithMinimumPrice, PackageWithAllocationPrice, + UnitWithPercentPrice, ] diff --git a/src/orb/types/price_create_params.py b/src/orb/types/price_create_params.py index e18b6bdd..f208cb26 100644 --- a/src/orb/types/price_create_params.py +++ b/src/orb/types/price_create_params.py @@ -32,6 +32,8 @@ "NewFloatingTieredPackagePrice", "NewFloatingTieredWithMinimumPrice", "NewFloatingPackageWithAllocationPrice", + "NewFloatingTieredPackageWithMinimumPrice", + "NewFloatingUnitWithPercentPrice", ] @@ -669,6 +671,90 @@ class NewFloatingPackageWithAllocationPrice(TypedDict, total=False): """The property used to group this price on an invoice""" +class NewFloatingTieredPackageWithMinimumPrice(TypedDict, total=False): + cadence: Required[Literal["annual", "monthly", "quarterly", "one_time"]] + """The cadence to bill for this price on.""" + + currency: Required[str] + """An ISO 4217 currency string for which this price is billed in.""" + + item_id: Required[str] + """The id of the item the plan will be associated with.""" + + model_type: Required[Literal["tiered_package_with_minimum"]] + + name: Required[str] + """The name of the price.""" + + tiered_package_with_minimum_config: Required[Dict[str, object]] + + billable_metric_id: Optional[str] + """The id of the billable metric for the price. + + Only needed if the price is usage-based. + """ + + billed_in_advance: Optional[bool] + """ + If the Price represents a fixed cost, the price will be billed in-advance if + this is true, and in-arrears if this is false. + """ + + external_price_id: Optional[str] + """An alias for the price.""" + + fixed_price_quantity: Optional[float] + """ + If the Price represents a fixed cost, this represents the quantity of units + applied. + """ + + invoice_grouping_key: Optional[str] + """The property used to group this price on an invoice""" + + +class NewFloatingUnitWithPercentPrice(TypedDict, total=False): + cadence: Required[Literal["annual", "monthly", "quarterly", "one_time"]] + """The cadence to bill for this price on.""" + + currency: Required[str] + """An ISO 4217 currency string for which this price is billed in.""" + + item_id: Required[str] + """The id of the item the plan will be associated with.""" + + model_type: Required[Literal["unit_with_percent"]] + + name: Required[str] + """The name of the price.""" + + unit_with_percent_config: Required[Dict[str, object]] + + billable_metric_id: Optional[str] + """The id of the billable metric for the price. + + Only needed if the price is usage-based. + """ + + billed_in_advance: Optional[bool] + """ + If the Price represents a fixed cost, the price will be billed in-advance if + this is true, and in-arrears if this is false. + """ + + external_price_id: Optional[str] + """An alias for the price.""" + + fixed_price_quantity: Optional[float] + """ + If the Price represents a fixed cost, this represents the quantity of units + applied. + """ + + invoice_grouping_key: Optional[str] + """The property used to group this price on an invoice""" + + PriceCreateParams = Union[ NewFloatingUnitPrice, NewFloatingPackagePrice, @@ -682,4 +768,6 @@ class NewFloatingPackageWithAllocationPrice(TypedDict, total=False): NewFloatingTieredPackagePrice, NewFloatingTieredWithMinimumPrice, NewFloatingPackageWithAllocationPrice, + NewFloatingTieredPackageWithMinimumPrice, + NewFloatingUnitWithPercentPrice, ] diff --git a/src/orb/types/subscription_create_params.py b/src/orb/types/subscription_create_params.py index 0acbb817..eb540341 100644 --- a/src/orb/types/subscription_create_params.py +++ b/src/orb/types/subscription_create_params.py @@ -48,6 +48,8 @@ "PriceOverrideOverrideTieredWithMinimumPriceDiscount", "PriceOverrideOverridePackageWithAllocationPrice", "PriceOverrideOverridePackageWithAllocationPriceDiscount", + "PriceOverrideOverrideUnitWithPercentPrice", + "PriceOverrideOverrideUnitWithPercentPriceDiscount", ] @@ -813,6 +815,54 @@ class PriceOverrideOverridePackageWithAllocationPrice(TypedDict, total=False): """The subscription's override minimum amount for the plan.""" +class PriceOverrideOverrideUnitWithPercentPriceDiscount(TypedDict, total=False): + discount_type: Required[Literal["percentage", "trial", "usage", "amount"]] + + amount_discount: Optional[str] + """Only available if discount_type is `amount`.""" + + applies_to_price_ids: Optional[List[str]] + """List of price_ids that this discount applies to. + + For plan/plan phase discounts, this can be a subset of prices. + """ + + percentage_discount: Optional[float] + """Only available if discount_type is `percentage`. + + This is a number between 0 and 1. + """ + + trial_amount_discount: Optional[str] + """Only available if discount_type is `trial`""" + + usage_discount: Optional[float] + """Only available if discount_type is `usage`. + + Number of usage units that this discount is for + """ + + +class PriceOverrideOverrideUnitWithPercentPrice(TypedDict, total=False): + id: Required[str] + + model_type: Required[Literal["unit_with_percent"]] + + unit_with_percent_config: Required[Dict[str, object]] + + discount: Optional[PriceOverrideOverrideUnitWithPercentPriceDiscount] + """The subscription's override discount for the plan.""" + + fixed_price_quantity: Optional[float] + """The starting quantity of the price, if the price is a fixed price.""" + + maximum_amount: Optional[str] + """The subscription's override maximum amount for the plan.""" + + minimum_amount: Optional[str] + """The subscription's override minimum amount for the plan.""" + + PriceOverride = Union[ PriceOverrideOverrideUnitPrice, PriceOverrideOverridePackagePrice, @@ -826,4 +876,5 @@ class PriceOverrideOverridePackageWithAllocationPrice(TypedDict, total=False): PriceOverrideOverrideTieredPackagePrice, PriceOverrideOverrideTieredWithMinimumPrice, PriceOverrideOverridePackageWithAllocationPrice, + PriceOverrideOverrideUnitWithPercentPrice, ] diff --git a/src/orb/types/subscription_price_intervals_params.py b/src/orb/types/subscription_price_intervals_params.py index df59c86d..f1ac1551 100644 --- a/src/orb/types/subscription_price_intervals_params.py +++ b/src/orb/types/subscription_price_intervals_params.py @@ -42,6 +42,8 @@ "AddPriceNewFloatingTieredPackagePrice", "AddPriceNewFloatingTieredWithMinimumPrice", "AddPriceNewFloatingPackageWithAllocationPrice", + "AddPriceNewFloatingTieredPackageWithMinimumPrice", + "AddPriceNewFloatingUnitWithPercentPrice", "Edit", "EditFixedFeeQuantityTransition", ] @@ -731,6 +733,90 @@ class AddPriceNewFloatingPackageWithAllocationPrice(TypedDict, total=False): """The property used to group this price on an invoice""" +class AddPriceNewFloatingTieredPackageWithMinimumPrice(TypedDict, total=False): + cadence: Required[Literal["annual", "monthly", "quarterly", "one_time"]] + """The cadence to bill for this price on.""" + + currency: Required[str] + """An ISO 4217 currency string for which this price is billed in.""" + + item_id: Required[str] + """The id of the item the plan will be associated with.""" + + model_type: Required[Literal["tiered_package_with_minimum"]] + + name: Required[str] + """The name of the price.""" + + tiered_package_with_minimum_config: Required[Dict[str, object]] + + billable_metric_id: Optional[str] + """The id of the billable metric for the price. + + Only needed if the price is usage-based. + """ + + billed_in_advance: Optional[bool] + """ + If the Price represents a fixed cost, the price will be billed in-advance if + this is true, and in-arrears if this is false. + """ + + external_price_id: Optional[str] + """An alias for the price.""" + + fixed_price_quantity: Optional[float] + """ + If the Price represents a fixed cost, this represents the quantity of units + applied. + """ + + invoice_grouping_key: Optional[str] + """The property used to group this price on an invoice""" + + +class AddPriceNewFloatingUnitWithPercentPrice(TypedDict, total=False): + cadence: Required[Literal["annual", "monthly", "quarterly", "one_time"]] + """The cadence to bill for this price on.""" + + currency: Required[str] + """An ISO 4217 currency string for which this price is billed in.""" + + item_id: Required[str] + """The id of the item the plan will be associated with.""" + + model_type: Required[Literal["unit_with_percent"]] + + name: Required[str] + """The name of the price.""" + + unit_with_percent_config: Required[Dict[str, object]] + + billable_metric_id: Optional[str] + """The id of the billable metric for the price. + + Only needed if the price is usage-based. + """ + + billed_in_advance: Optional[bool] + """ + If the Price represents a fixed cost, the price will be billed in-advance if + this is true, and in-arrears if this is false. + """ + + external_price_id: Optional[str] + """An alias for the price.""" + + fixed_price_quantity: Optional[float] + """ + If the Price represents a fixed cost, this represents the quantity of units + applied. + """ + + invoice_grouping_key: Optional[str] + """The property used to group this price on an invoice""" + + AddPrice = Union[ AddPriceNewFloatingUnitPrice, AddPriceNewFloatingPackagePrice, @@ -744,6 +830,8 @@ class AddPriceNewFloatingPackageWithAllocationPrice(TypedDict, total=False): AddPriceNewFloatingTieredPackagePrice, AddPriceNewFloatingTieredWithMinimumPrice, AddPriceNewFloatingPackageWithAllocationPrice, + AddPriceNewFloatingTieredPackageWithMinimumPrice, + AddPriceNewFloatingUnitWithPercentPrice, ] diff --git a/src/orb/types/subscription_schedule_plan_change_params.py b/src/orb/types/subscription_schedule_plan_change_params.py index 4da63422..d0ff6755 100644 --- a/src/orb/types/subscription_schedule_plan_change_params.py +++ b/src/orb/types/subscription_schedule_plan_change_params.py @@ -45,6 +45,8 @@ "PriceOverrideOverrideTieredWithMinimumPriceDiscount", "PriceOverrideOverridePackageWithAllocationPrice", "PriceOverrideOverridePackageWithAllocationPriceDiscount", + "PriceOverrideOverrideUnitWithPercentPrice", + "PriceOverrideOverrideUnitWithPercentPriceDiscount", ] @@ -813,6 +815,54 @@ class PriceOverrideOverridePackageWithAllocationPrice(TypedDict, total=False): """The subscription's override minimum amount for the plan.""" +class PriceOverrideOverrideUnitWithPercentPriceDiscount(TypedDict, total=False): + discount_type: Required[Literal["percentage", "trial", "usage", "amount"]] + + amount_discount: Optional[str] + """Only available if discount_type is `amount`.""" + + applies_to_price_ids: Optional[List[str]] + """List of price_ids that this discount applies to. + + For plan/plan phase discounts, this can be a subset of prices. + """ + + percentage_discount: Optional[float] + """Only available if discount_type is `percentage`. + + This is a number between 0 and 1. + """ + + trial_amount_discount: Optional[str] + """Only available if discount_type is `trial`""" + + usage_discount: Optional[float] + """Only available if discount_type is `usage`. + + Number of usage units that this discount is for + """ + + +class PriceOverrideOverrideUnitWithPercentPrice(TypedDict, total=False): + id: Required[str] + + model_type: Required[Literal["unit_with_percent"]] + + unit_with_percent_config: Required[Dict[str, object]] + + discount: Optional[PriceOverrideOverrideUnitWithPercentPriceDiscount] + """The subscription's override discount for the plan.""" + + fixed_price_quantity: Optional[float] + """The starting quantity of the price, if the price is a fixed price.""" + + maximum_amount: Optional[str] + """The subscription's override maximum amount for the plan.""" + + minimum_amount: Optional[str] + """The subscription's override minimum amount for the plan.""" + + PriceOverride = Union[ PriceOverrideOverrideUnitPrice, PriceOverrideOverridePackagePrice, @@ -826,4 +876,5 @@ class PriceOverrideOverridePackageWithAllocationPrice(TypedDict, total=False): PriceOverrideOverrideTieredPackagePrice, PriceOverrideOverrideTieredWithMinimumPrice, PriceOverrideOverridePackageWithAllocationPrice, + PriceOverrideOverrideUnitWithPercentPrice, ] diff --git a/tests/api_resources/test_prices.py b/tests/api_resources/test_prices.py index b9f2e2e5..af10cbb6 100644 --- a/tests/api_resources/test_prices.py +++ b/tests/api_resources/test_prices.py @@ -1017,6 +1017,132 @@ def test_streaming_response_create_overload_12(self, client: Orb) -> None: assert cast(Any, response.is_closed) is True + @parametrize + def test_method_create_overload_13(self, client: Orb) -> None: + price = client.prices.create( + cadence="annual", + currency="string", + item_id="string", + model_type="tiered_package_with_minimum", + name="Annual fee", + tiered_package_with_minimum_config={"foo": "bar"}, + ) + assert_matches_type(Price, price, path=["response"]) + + @parametrize + def test_method_create_with_all_params_overload_13(self, client: Orb) -> None: + price = client.prices.create( + cadence="annual", + currency="string", + item_id="string", + model_type="tiered_package_with_minimum", + name="Annual fee", + tiered_package_with_minimum_config={"foo": "bar"}, + billable_metric_id="string", + billed_in_advance=True, + external_price_id="string", + fixed_price_quantity=0, + invoice_grouping_key="string", + ) + assert_matches_type(Price, price, path=["response"]) + + @parametrize + def test_raw_response_create_overload_13(self, client: Orb) -> None: + response = client.prices.with_raw_response.create( + cadence="annual", + currency="string", + item_id="string", + model_type="tiered_package_with_minimum", + name="Annual fee", + tiered_package_with_minimum_config={"foo": "bar"}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + price = response.parse() + assert_matches_type(Price, price, path=["response"]) + + @parametrize + def test_streaming_response_create_overload_13(self, client: Orb) -> None: + with client.prices.with_streaming_response.create( + cadence="annual", + currency="string", + item_id="string", + model_type="tiered_package_with_minimum", + name="Annual fee", + tiered_package_with_minimum_config={"foo": "bar"}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + price = response.parse() + assert_matches_type(Price, price, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_method_create_overload_14(self, client: Orb) -> None: + price = client.prices.create( + cadence="annual", + currency="string", + item_id="string", + model_type="unit_with_percent", + name="Annual fee", + unit_with_percent_config={"foo": "bar"}, + ) + assert_matches_type(Price, price, path=["response"]) + + @parametrize + def test_method_create_with_all_params_overload_14(self, client: Orb) -> None: + price = client.prices.create( + cadence="annual", + currency="string", + item_id="string", + model_type="unit_with_percent", + name="Annual fee", + unit_with_percent_config={"foo": "bar"}, + billable_metric_id="string", + billed_in_advance=True, + external_price_id="string", + fixed_price_quantity=0, + invoice_grouping_key="string", + ) + assert_matches_type(Price, price, path=["response"]) + + @parametrize + def test_raw_response_create_overload_14(self, client: Orb) -> None: + response = client.prices.with_raw_response.create( + cadence="annual", + currency="string", + item_id="string", + model_type="unit_with_percent", + name="Annual fee", + unit_with_percent_config={"foo": "bar"}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + price = response.parse() + assert_matches_type(Price, price, path=["response"]) + + @parametrize + def test_streaming_response_create_overload_14(self, client: Orb) -> None: + with client.prices.with_streaming_response.create( + cadence="annual", + currency="string", + item_id="string", + model_type="unit_with_percent", + name="Annual fee", + unit_with_percent_config={"foo": "bar"}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + price = response.parse() + assert_matches_type(Price, price, path=["response"]) + + assert cast(Any, response.is_closed) is True + @parametrize def test_method_list(self, client: Orb) -> None: price = client.prices.list() @@ -2091,6 +2217,132 @@ async def test_streaming_response_create_overload_12(self, async_client: AsyncOr assert cast(Any, response.is_closed) is True + @parametrize + async def test_method_create_overload_13(self, async_client: AsyncOrb) -> None: + price = await async_client.prices.create( + cadence="annual", + currency="string", + item_id="string", + model_type="tiered_package_with_minimum", + name="Annual fee", + tiered_package_with_minimum_config={"foo": "bar"}, + ) + assert_matches_type(Price, price, path=["response"]) + + @parametrize + async def test_method_create_with_all_params_overload_13(self, async_client: AsyncOrb) -> None: + price = await async_client.prices.create( + cadence="annual", + currency="string", + item_id="string", + model_type="tiered_package_with_minimum", + name="Annual fee", + tiered_package_with_minimum_config={"foo": "bar"}, + billable_metric_id="string", + billed_in_advance=True, + external_price_id="string", + fixed_price_quantity=0, + invoice_grouping_key="string", + ) + assert_matches_type(Price, price, path=["response"]) + + @parametrize + async def test_raw_response_create_overload_13(self, async_client: AsyncOrb) -> None: + response = await async_client.prices.with_raw_response.create( + cadence="annual", + currency="string", + item_id="string", + model_type="tiered_package_with_minimum", + name="Annual fee", + tiered_package_with_minimum_config={"foo": "bar"}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + price = response.parse() + assert_matches_type(Price, price, path=["response"]) + + @parametrize + async def test_streaming_response_create_overload_13(self, async_client: AsyncOrb) -> None: + async with async_client.prices.with_streaming_response.create( + cadence="annual", + currency="string", + item_id="string", + model_type="tiered_package_with_minimum", + name="Annual fee", + tiered_package_with_minimum_config={"foo": "bar"}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + price = await response.parse() + assert_matches_type(Price, price, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_create_overload_14(self, async_client: AsyncOrb) -> None: + price = await async_client.prices.create( + cadence="annual", + currency="string", + item_id="string", + model_type="unit_with_percent", + name="Annual fee", + unit_with_percent_config={"foo": "bar"}, + ) + assert_matches_type(Price, price, path=["response"]) + + @parametrize + async def test_method_create_with_all_params_overload_14(self, async_client: AsyncOrb) -> None: + price = await async_client.prices.create( + cadence="annual", + currency="string", + item_id="string", + model_type="unit_with_percent", + name="Annual fee", + unit_with_percent_config={"foo": "bar"}, + billable_metric_id="string", + billed_in_advance=True, + external_price_id="string", + fixed_price_quantity=0, + invoice_grouping_key="string", + ) + assert_matches_type(Price, price, path=["response"]) + + @parametrize + async def test_raw_response_create_overload_14(self, async_client: AsyncOrb) -> None: + response = await async_client.prices.with_raw_response.create( + cadence="annual", + currency="string", + item_id="string", + model_type="unit_with_percent", + name="Annual fee", + unit_with_percent_config={"foo": "bar"}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + price = response.parse() + assert_matches_type(Price, price, path=["response"]) + + @parametrize + async def test_streaming_response_create_overload_14(self, async_client: AsyncOrb) -> None: + async with async_client.prices.with_streaming_response.create( + cadence="annual", + currency="string", + item_id="string", + model_type="unit_with_percent", + name="Annual fee", + unit_with_percent_config={"foo": "bar"}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + price = await response.parse() + assert_matches_type(Price, price, path=["response"]) + + assert cast(Any, response.is_closed) is True + @parametrize async def test_method_list(self, async_client: AsyncOrb) -> None: price = await async_client.prices.list() diff --git a/tests/sample_file.txt b/tests/sample_file.txt new file mode 100644 index 00000000..af5626b4 --- /dev/null +++ b/tests/sample_file.txt @@ -0,0 +1 @@ +Hello, world! diff --git a/tests/test_client.py b/tests/test_client.py index 6cfd7173..311da320 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -283,6 +283,16 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT # our default + async def test_invalid_http_client(self) -> None: + with pytest.raises(TypeError, match="Invalid `http_client` arg"): + async with httpx.AsyncClient() as http_client: + Orb( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=cast(Any, http_client), + ) + def test_default_headers_option(self) -> None: client = Orb( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} @@ -983,6 +993,16 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT # our default + def test_invalid_http_client(self) -> None: + with pytest.raises(TypeError, match="Invalid `http_client` arg"): + with httpx.Client() as http_client: + AsyncOrb( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=cast(Any, http_client), + ) + def test_default_headers_option(self) -> None: client = AsyncOrb( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} diff --git a/tests/test_legacy_response.py b/tests/test_legacy_response.py index fb794ee3..fe9c205a 100644 --- a/tests/test_legacy_response.py +++ b/tests/test_legacy_response.py @@ -1,4 +1,6 @@ import json +from typing import cast +from typing_extensions import Annotated import httpx import pytest @@ -63,3 +65,20 @@ def test_response_parse_custom_model(client: Orb) -> None: obj = response.parse(to=CustomModel) assert obj.foo == "hello!" assert obj.bar == 2 + + +def test_response_parse_annotated_type(client: Orb) -> None: + response = LegacyAPIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse( + to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), + ) + assert obj.foo == "hello!" + assert obj.bar == 2 diff --git a/tests/test_models.py b/tests/test_models.py index e0349cba..d9ad5a4c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,14 +1,14 @@ import json from typing import Any, Dict, List, Union, Optional, cast from datetime import datetime, timezone -from typing_extensions import Literal +from typing_extensions import Literal, Annotated import pytest import pydantic from pydantic import Field from orb._compat import PYDANTIC_V2, parse_obj, model_dump, model_json -from orb._models import BaseModel +from orb._models import BaseModel, construct_type class BasicModel(BaseModel): @@ -571,3 +571,15 @@ class OurModel(BaseModel): foo: Optional[str] = None takes_pydantic(OurModel()) + + +def test_annotated_types() -> None: + class Model(BaseModel): + value: str + + m = construct_type( + value={"value": "foo"}, + type_=cast(Any, Annotated[Model, "random metadata"]), + ) + assert isinstance(m, Model) + assert m.value == "foo" diff --git a/tests/test_response.py b/tests/test_response.py index a977692e..e90e201b 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -1,5 +1,6 @@ import json -from typing import List +from typing import List, cast +from typing_extensions import Annotated import httpx import pytest @@ -157,3 +158,37 @@ async def test_async_response_parse_custom_model(async_client: AsyncOrb) -> None obj = await response.parse(to=CustomModel) assert obj.foo == "hello!" assert obj.bar == 2 + + +def test_response_parse_annotated_type(client: Orb) -> None: + response = APIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse( + to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), + ) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +async def test_async_response_parse_annotated_type(async_client: AsyncOrb) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse( + to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), + ) + assert obj.foo == "hello!" + assert obj.bar == 2 diff --git a/tests/test_transform.py b/tests/test_transform.py index 293573ba..7d7e7341 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -1,22 +1,50 @@ from __future__ import annotations -from typing import Any, List, Union, Iterable, Optional, cast +import io +import pathlib +from typing import Any, List, Union, TypeVar, Iterable, Optional, cast from datetime import date, datetime from typing_extensions import Required, Annotated, TypedDict import pytest -from orb._utils import PropertyInfo, transform, parse_datetime +from orb._types import Base64FileInput +from orb._utils import ( + PropertyInfo, + transform as _transform, + parse_datetime, + async_transform as _async_transform, +) from orb._compat import PYDANTIC_V2 from orb._models import BaseModel +_T = TypeVar("_T") + +SAMPLE_FILE_PATH = pathlib.Path(__file__).parent.joinpath("sample_file.txt") + + +async def transform( + data: _T, + expected_type: object, + use_async: bool, +) -> _T: + if use_async: + return await _async_transform(data, expected_type=expected_type) + + return _transform(data, expected_type=expected_type) + + +parametrize = pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) + class Foo1(TypedDict): foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] -def test_top_level_alias() -> None: - assert transform({"foo_bar": "hello"}, expected_type=Foo1) == {"fooBar": "hello"} +@parametrize +@pytest.mark.asyncio +async def test_top_level_alias(use_async: bool) -> None: + assert await transform({"foo_bar": "hello"}, expected_type=Foo1, use_async=use_async) == {"fooBar": "hello"} class Foo2(TypedDict): @@ -32,9 +60,11 @@ class Baz2(TypedDict): my_baz: Annotated[str, PropertyInfo(alias="myBaz")] -def test_recursive_typeddict() -> None: - assert transform({"bar": {"this_thing": 1}}, Foo2) == {"bar": {"this__thing": 1}} - assert transform({"bar": {"baz": {"my_baz": "foo"}}}, Foo2) == {"bar": {"Baz": {"myBaz": "foo"}}} +@parametrize +@pytest.mark.asyncio +async def test_recursive_typeddict(use_async: bool) -> None: + assert await transform({"bar": {"this_thing": 1}}, Foo2, use_async) == {"bar": {"this__thing": 1}} + assert await transform({"bar": {"baz": {"my_baz": "foo"}}}, Foo2, use_async) == {"bar": {"Baz": {"myBaz": "foo"}}} class Foo3(TypedDict): @@ -45,8 +75,10 @@ class Bar3(TypedDict): my_field: Annotated[str, PropertyInfo(alias="myField")] -def test_list_of_typeddict() -> None: - result = transform({"things": [{"my_field": "foo"}, {"my_field": "foo2"}]}, expected_type=Foo3) +@parametrize +@pytest.mark.asyncio +async def test_list_of_typeddict(use_async: bool) -> None: + result = await transform({"things": [{"my_field": "foo"}, {"my_field": "foo2"}]}, Foo3, use_async) assert result == {"things": [{"myField": "foo"}, {"myField": "foo2"}]} @@ -62,10 +94,14 @@ class Baz4(TypedDict): foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] -def test_union_of_typeddict() -> None: - assert transform({"foo": {"foo_bar": "bar"}}, Foo4) == {"foo": {"fooBar": "bar"}} - assert transform({"foo": {"foo_baz": "baz"}}, Foo4) == {"foo": {"fooBaz": "baz"}} - assert transform({"foo": {"foo_baz": "baz", "foo_bar": "bar"}}, Foo4) == {"foo": {"fooBaz": "baz", "fooBar": "bar"}} +@parametrize +@pytest.mark.asyncio +async def test_union_of_typeddict(use_async: bool) -> None: + assert await transform({"foo": {"foo_bar": "bar"}}, Foo4, use_async) == {"foo": {"fooBar": "bar"}} + assert await transform({"foo": {"foo_baz": "baz"}}, Foo4, use_async) == {"foo": {"fooBaz": "baz"}} + assert await transform({"foo": {"foo_baz": "baz", "foo_bar": "bar"}}, Foo4, use_async) == { + "foo": {"fooBaz": "baz", "fooBar": "bar"} + } class Foo5(TypedDict): @@ -80,9 +116,11 @@ class Baz5(TypedDict): foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] -def test_union_of_list() -> None: - assert transform({"foo": {"foo_bar": "bar"}}, Foo5) == {"FOO": {"fooBar": "bar"}} - assert transform( +@parametrize +@pytest.mark.asyncio +async def test_union_of_list(use_async: bool) -> None: + assert await transform({"foo": {"foo_bar": "bar"}}, Foo5, use_async) == {"FOO": {"fooBar": "bar"}} + assert await transform( { "foo": [ {"foo_baz": "baz"}, @@ -90,6 +128,7 @@ def test_union_of_list() -> None: ] }, Foo5, + use_async, ) == {"FOO": [{"fooBaz": "baz"}, {"fooBaz": "baz"}]} @@ -97,8 +136,10 @@ class Foo6(TypedDict): bar: Annotated[str, PropertyInfo(alias="Bar")] -def test_includes_unknown_keys() -> None: - assert transform({"bar": "bar", "baz_": {"FOO": 1}}, Foo6) == { +@parametrize +@pytest.mark.asyncio +async def test_includes_unknown_keys(use_async: bool) -> None: + assert await transform({"bar": "bar", "baz_": {"FOO": 1}}, Foo6, use_async) == { "Bar": "bar", "baz_": {"FOO": 1}, } @@ -113,9 +154,11 @@ class Bar7(TypedDict): foo: str -def test_ignores_invalid_input() -> None: - assert transform({"bar": ""}, Foo7) == {"bAr": ""} - assert transform({"foo": ""}, Foo7) == {"foo": ""} +@parametrize +@pytest.mark.asyncio +async def test_ignores_invalid_input(use_async: bool) -> None: + assert await transform({"bar": ""}, Foo7, use_async) == {"bAr": ""} + assert await transform({"foo": ""}, Foo7, use_async) == {"foo": ""} class DatetimeDict(TypedDict, total=False): @@ -134,52 +177,66 @@ class DateDict(TypedDict, total=False): foo: Annotated[date, PropertyInfo(format="iso8601")] -def test_iso8601_format() -> None: +@parametrize +@pytest.mark.asyncio +async def test_iso8601_format(use_async: bool) -> None: dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") - assert transform({"foo": dt}, DatetimeDict) == {"foo": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] + assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] dt = dt.replace(tzinfo=None) - assert transform({"foo": dt}, DatetimeDict) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap] + assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap] - assert transform({"foo": None}, DateDict) == {"foo": None} # type: ignore[comparison-overlap] - assert transform({"foo": date.fromisoformat("2023-02-23")}, DateDict) == {"foo": "2023-02-23"} # type: ignore[comparison-overlap] + assert await transform({"foo": None}, DateDict, use_async) == {"foo": None} # type: ignore[comparison-overlap] + assert await transform({"foo": date.fromisoformat("2023-02-23")}, DateDict, use_async) == {"foo": "2023-02-23"} # type: ignore[comparison-overlap] -def test_optional_iso8601_format() -> None: +@parametrize +@pytest.mark.asyncio +async def test_optional_iso8601_format(use_async: bool) -> None: dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") - assert transform({"bar": dt}, DatetimeDict) == {"bar": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] + assert await transform({"bar": dt}, DatetimeDict, use_async) == {"bar": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] - assert transform({"bar": None}, DatetimeDict) == {"bar": None} + assert await transform({"bar": None}, DatetimeDict, use_async) == {"bar": None} -def test_required_iso8601_format() -> None: +@parametrize +@pytest.mark.asyncio +async def test_required_iso8601_format(use_async: bool) -> None: dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") - assert transform({"required": dt}, DatetimeDict) == {"required": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] + assert await transform({"required": dt}, DatetimeDict, use_async) == { + "required": "2023-02-23T14:16:36.337692+00:00" + } # type: ignore[comparison-overlap] - assert transform({"required": None}, DatetimeDict) == {"required": None} + assert await transform({"required": None}, DatetimeDict, use_async) == {"required": None} -def test_union_datetime() -> None: +@parametrize +@pytest.mark.asyncio +async def test_union_datetime(use_async: bool) -> None: dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") - assert transform({"union": dt}, DatetimeDict) == { # type: ignore[comparison-overlap] + assert await transform({"union": dt}, DatetimeDict, use_async) == { # type: ignore[comparison-overlap] "union": "2023-02-23T14:16:36.337692+00:00" } - assert transform({"union": "foo"}, DatetimeDict) == {"union": "foo"} + assert await transform({"union": "foo"}, DatetimeDict, use_async) == {"union": "foo"} -def test_nested_list_iso6801_format() -> None: +@parametrize +@pytest.mark.asyncio +async def test_nested_list_iso6801_format(use_async: bool) -> None: dt1 = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") dt2 = parse_datetime("2022-01-15T06:34:23Z") - assert transform({"list_": [dt1, dt2]}, DatetimeDict) == { # type: ignore[comparison-overlap] + assert await transform({"list_": [dt1, dt2]}, DatetimeDict, use_async) == { # type: ignore[comparison-overlap] "list_": ["2023-02-23T14:16:36.337692+00:00", "2022-01-15T06:34:23+00:00"] } -def test_datetime_custom_format() -> None: +@parametrize +@pytest.mark.asyncio +async def test_datetime_custom_format(use_async: bool) -> None: dt = parse_datetime("2022-01-15T06:34:23Z") - result = transform(dt, Annotated[datetime, PropertyInfo(format="custom", format_template="%H")]) + result = await transform(dt, Annotated[datetime, PropertyInfo(format="custom", format_template="%H")], use_async) assert result == "06" # type: ignore[comparison-overlap] @@ -187,47 +244,59 @@ class DateDictWithRequiredAlias(TypedDict, total=False): required_prop: Required[Annotated[date, PropertyInfo(format="iso8601", alias="prop")]] -def test_datetime_with_alias() -> None: - assert transform({"required_prop": None}, DateDictWithRequiredAlias) == {"prop": None} # type: ignore[comparison-overlap] - assert transform({"required_prop": date.fromisoformat("2023-02-23")}, DateDictWithRequiredAlias) == { - "prop": "2023-02-23" - } # type: ignore[comparison-overlap] +@parametrize +@pytest.mark.asyncio +async def test_datetime_with_alias(use_async: bool) -> None: + assert await transform({"required_prop": None}, DateDictWithRequiredAlias, use_async) == {"prop": None} # type: ignore[comparison-overlap] + assert await transform( + {"required_prop": date.fromisoformat("2023-02-23")}, DateDictWithRequiredAlias, use_async + ) == {"prop": "2023-02-23"} # type: ignore[comparison-overlap] class MyModel(BaseModel): foo: str -def test_pydantic_model_to_dictionary() -> None: - assert transform(MyModel(foo="hi!"), Any) == {"foo": "hi!"} - assert transform(MyModel.construct(foo="hi!"), Any) == {"foo": "hi!"} +@parametrize +@pytest.mark.asyncio +async def test_pydantic_model_to_dictionary(use_async: bool) -> None: + assert await transform(MyModel(foo="hi!"), Any, use_async) == {"foo": "hi!"} + assert await transform(MyModel.construct(foo="hi!"), Any, use_async) == {"foo": "hi!"} -def test_pydantic_empty_model() -> None: - assert transform(MyModel.construct(), Any) == {} +@parametrize +@pytest.mark.asyncio +async def test_pydantic_empty_model(use_async: bool) -> None: + assert await transform(MyModel.construct(), Any, use_async) == {} -def test_pydantic_unknown_field() -> None: - assert transform(MyModel.construct(my_untyped_field=True), Any) == {"my_untyped_field": True} +@parametrize +@pytest.mark.asyncio +async def test_pydantic_unknown_field(use_async: bool) -> None: + assert await transform(MyModel.construct(my_untyped_field=True), Any, use_async) == {"my_untyped_field": True} -def test_pydantic_mismatched_types() -> None: +@parametrize +@pytest.mark.asyncio +async def test_pydantic_mismatched_types(use_async: bool) -> None: model = MyModel.construct(foo=True) if PYDANTIC_V2: with pytest.warns(UserWarning): - params = transform(model, Any) + params = await transform(model, Any, use_async) else: - params = transform(model, Any) + params = await transform(model, Any, use_async) assert params == {"foo": True} -def test_pydantic_mismatched_object_type() -> None: +@parametrize +@pytest.mark.asyncio +async def test_pydantic_mismatched_object_type(use_async: bool) -> None: model = MyModel.construct(foo=MyModel.construct(hello="world")) if PYDANTIC_V2: with pytest.warns(UserWarning): - params = transform(model, Any) + params = await transform(model, Any, use_async) else: - params = transform(model, Any) + params = await transform(model, Any, use_async) assert params == {"foo": {"hello": "world"}} @@ -235,10 +304,12 @@ class ModelNestedObjects(BaseModel): nested: MyModel -def test_pydantic_nested_objects() -> None: +@parametrize +@pytest.mark.asyncio +async def test_pydantic_nested_objects(use_async: bool) -> None: model = ModelNestedObjects.construct(nested={"foo": "stainless"}) assert isinstance(model.nested, MyModel) - assert transform(model, Any) == {"nested": {"foo": "stainless"}} + assert await transform(model, Any, use_async) == {"nested": {"foo": "stainless"}} class ModelWithDefaultField(BaseModel): @@ -247,24 +318,26 @@ class ModelWithDefaultField(BaseModel): with_str_default: str = "foo" -def test_pydantic_default_field() -> None: +@parametrize +@pytest.mark.asyncio +async def test_pydantic_default_field(use_async: bool) -> None: # should be excluded when defaults are used model = ModelWithDefaultField.construct() assert model.with_none_default is None assert model.with_str_default == "foo" - assert transform(model, Any) == {} + assert await transform(model, Any, use_async) == {} # should be included when the default value is explicitly given model = ModelWithDefaultField.construct(with_none_default=None, with_str_default="foo") assert model.with_none_default is None assert model.with_str_default == "foo" - assert transform(model, Any) == {"with_none_default": None, "with_str_default": "foo"} + assert await transform(model, Any, use_async) == {"with_none_default": None, "with_str_default": "foo"} # should be included when a non-default value is explicitly given model = ModelWithDefaultField.construct(with_none_default="bar", with_str_default="baz") assert model.with_none_default == "bar" assert model.with_str_default == "baz" - assert transform(model, Any) == {"with_none_default": "bar", "with_str_default": "baz"} + assert await transform(model, Any, use_async) == {"with_none_default": "bar", "with_str_default": "baz"} class TypedDictIterableUnion(TypedDict): @@ -279,21 +352,57 @@ class Baz8(TypedDict): foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] -def test_iterable_of_dictionaries() -> None: - assert transform({"foo": [{"foo_baz": "bar"}]}, TypedDictIterableUnion) == {"FOO": [{"fooBaz": "bar"}]} - assert cast(Any, transform({"foo": ({"foo_baz": "bar"},)}, TypedDictIterableUnion)) == {"FOO": [{"fooBaz": "bar"}]} +@parametrize +@pytest.mark.asyncio +async def test_iterable_of_dictionaries(use_async: bool) -> None: + assert await transform({"foo": [{"foo_baz": "bar"}]}, TypedDictIterableUnion, use_async) == { + "FOO": [{"fooBaz": "bar"}] + } + assert cast(Any, await transform({"foo": ({"foo_baz": "bar"},)}, TypedDictIterableUnion, use_async)) == { + "FOO": [{"fooBaz": "bar"}] + } def my_iter() -> Iterable[Baz8]: yield {"foo_baz": "hello"} yield {"foo_baz": "world"} - assert transform({"foo": my_iter()}, TypedDictIterableUnion) == {"FOO": [{"fooBaz": "hello"}, {"fooBaz": "world"}]} + assert await transform({"foo": my_iter()}, TypedDictIterableUnion, use_async) == { + "FOO": [{"fooBaz": "hello"}, {"fooBaz": "world"}] + } class TypedDictIterableUnionStr(TypedDict): foo: Annotated[Union[str, Iterable[Baz8]], PropertyInfo(alias="FOO")] -def test_iterable_union_str() -> None: - assert transform({"foo": "bar"}, TypedDictIterableUnionStr) == {"FOO": "bar"} - assert cast(Any, transform(iter([{"foo_baz": "bar"}]), Union[str, Iterable[Baz8]])) == [{"fooBaz": "bar"}] +@parametrize +@pytest.mark.asyncio +async def test_iterable_union_str(use_async: bool) -> None: + assert await transform({"foo": "bar"}, TypedDictIterableUnionStr, use_async) == {"FOO": "bar"} + assert cast(Any, await transform(iter([{"foo_baz": "bar"}]), Union[str, Iterable[Baz8]], use_async)) == [ + {"fooBaz": "bar"} + ] + + +class TypedDictBase64Input(TypedDict): + foo: Annotated[Union[str, Base64FileInput], PropertyInfo(format="base64")] + + +@parametrize +@pytest.mark.asyncio +async def test_base64_file_input(use_async: bool) -> None: + # strings are left as-is + assert await transform({"foo": "bar"}, TypedDictBase64Input, use_async) == {"foo": "bar"} + + # pathlib.Path is automatically converted to base64 + assert await transform({"foo": SAMPLE_FILE_PATH}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQo=" + } # type: ignore[comparison-overlap] + + # io instances are automatically converted to base64 + assert await transform({"foo": io.StringIO("Hello, world!")}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQ==" + } # type: ignore[comparison-overlap] + assert await transform({"foo": io.BytesIO(b"Hello, world!")}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQ==" + } # type: ignore[comparison-overlap] diff --git a/tests/utils.py b/tests/utils.py index 3d26d42a..7ce2711c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -14,6 +14,8 @@ is_list, is_list_type, is_union_type, + extract_type_arg, + is_annotated_type, ) from orb._compat import PYDANTIC_V2, field_outer_type, get_model_fields from orb._models import BaseModel @@ -49,6 +51,10 @@ def assert_matches_type( path: list[str], allow_none: bool = False, ) -> None: + # unwrap `Annotated[T, ...]` -> `T` + if is_annotated_type(type_): + type_ = extract_type_arg(type_, 0) + if allow_none and value is None: return