diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77fd8e0..ac8c55b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/postgrid-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | @@ -44,7 +44,7 @@ jobs: id-token: write runs-on: ${{ github.repository == 'stainless-sdks/postgrid-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | @@ -63,7 +63,7 @@ jobs: - name: Get GitHub OIDC Token if: github.repository == 'stainless-sdks/postgrid-python' id: github-oidc - uses: actions/github-script@v6 + uses: actions/github-script@v8 with: script: core.setOutput('github_token', await core.getIDToken()); @@ -81,7 +81,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/postgrid-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index dcaf2d4..a674d45 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index 69b5676..7d1fa04 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'postgrid/postgrid-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check release environment run: | diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 337d15d..656a2ef 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.0.3" + ".": "2.1.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 65302ee..629c1b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 2.1.0 (2026-01-30) + +Full Changelog: [v2.0.3...v2.1.0](https://github.com/postgrid/postgrid-python/compare/v2.0.3...v2.1.0) + +### Features + +* **client:** add custom JSON encoder for extended type support ([7daf14d](https://github.com/postgrid/postgrid-python/commit/7daf14d1ea87c9036961bbcc15399e90ed118cbb)) +* **client:** add support for binary request streaming ([9c92b6a](https://github.com/postgrid/postgrid-python/commit/9c92b6a0f2411b015adf447dffa582836bc1ac4a)) + + +### Bug Fixes + +* **client:** loosen auth header validation ([af955ca](https://github.com/postgrid/postgrid-python/commit/af955ca7182b4f032d6d9c10faf060668fa43248)) +* use async_to_httpx_files in patch method ([a3b137a](https://github.com/postgrid/postgrid-python/commit/a3b137ae1ab33697009d7b508a8d22f7349b9dec)) + + +### Chores + +* **ci:** upgrade `actions/github-script` ([e2ea5fd](https://github.com/postgrid/postgrid-python/commit/e2ea5fdecfc467358d53198d37f153ea7f7bf0cf)) +* **internal:** add `--fix` argument to lint script ([e8d9998](https://github.com/postgrid/postgrid-python/commit/e8d9998a7bc735ebb82e4071f479b6d811746abd)) +* **internal:** add missing files argument to base client ([6dbed91](https://github.com/postgrid/postgrid-python/commit/6dbed918a54c190cda4e2b7c1c8b56867bde3562)) +* **internal:** codegen related update ([eb02a83](https://github.com/postgrid/postgrid-python/commit/eb02a837ede94d5440593cb9c921c6e47ae5a0ab)) +* **internal:** update `actions/checkout` version ([0d17288](https://github.com/postgrid/postgrid-python/commit/0d1728886ee999032574dd9877bc85c84930bc3b)) +* speedup initial import ([32fb2cc](https://github.com/postgrid/postgrid-python/commit/32fb2cceb7859e77a4840e4f45bc782123aa0359)) + ## 2.0.3 (2025-12-09) Full Changelog: [v2.0.2...v2.0.3](https://github.com/postgrid/postgrid-python/compare/v2.0.2...v2.0.3) diff --git a/LICENSE b/LICENSE index ab63a50..1a60946 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2025 PostGrid + Copyright 2026 PostGrid Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/pyproject.toml b/pyproject.toml index b2bcfdf..892ac70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "postgrid-python" -version = "2.0.3" +version = "2.1.0" description = "The official Python library for the PostGrid API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/scripts/lint b/scripts/lint index 0512393..2483883 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,8 +4,13 @@ set -e cd "$(dirname "$0")/.." -echo "==> Running lints" -rye run lint +if [ "$1" = "--fix" ]; then + echo "==> Running lints with --fix" + rye run fix:ruff +else + echo "==> Running lints" + rye run lint +fi echo "==> Making sure it imports" rye run python -c 'import postgrid' diff --git a/src/postgrid/_base_client.py b/src/postgrid/_base_client.py index 614d3cf..0dc721f 100644 --- a/src/postgrid/_base_client.py +++ b/src/postgrid/_base_client.py @@ -9,6 +9,7 @@ import inspect import logging import platform +import warnings import email.utils from types import TracebackType from random import random @@ -51,9 +52,11 @@ ResponseT, AnyMapping, PostParser, + BinaryTypes, RequestFiles, HttpxSendArgs, RequestOptions, + AsyncBinaryTypes, HttpxRequestFiles, ModelBuilderProtocol, not_given, @@ -83,6 +86,7 @@ APIConnectionError, APIResponseValidationError, ) +from ._utils._json import openapi_dumps log: logging.Logger = logging.getLogger(__name__) @@ -477,8 +481,19 @@ def _build_request( retries_taken: int = 0, ) -> httpx.Request: if log.isEnabledFor(logging.DEBUG): - log.debug("Request options: %s", model_dump(options, exclude_unset=True)) - + log.debug( + "Request options: %s", + model_dump( + options, + exclude_unset=True, + # Pydantic v1 can't dump every type we support in content, so we exclude it for now. + exclude={ + "content", + } + if PYDANTIC_V1 + else {}, + ), + ) kwargs: dict[str, Any] = {} json_data = options.json_data @@ -532,10 +547,18 @@ def _build_request( is_body_allowed = options.method.lower() != "get" if is_body_allowed: - if isinstance(json_data, bytes): + if options.content is not None and json_data is not None: + raise TypeError("Passing both `content` and `json_data` is not supported") + if options.content is not None and files is not None: + raise TypeError("Passing both `content` and `files` is not supported") + if options.content is not None: + kwargs["content"] = options.content + elif isinstance(json_data, bytes): kwargs["content"] = json_data - else: - kwargs["json"] = json_data if is_given(json_data) else None + elif not files: + # Don't set content when JSON is sent as multipart/form-data, + # since httpx's content param overrides other body arguments + kwargs["content"] = openapi_dumps(json_data) if is_given(json_data) and json_data is not None else None kwargs["files"] = files else: headers.pop("Content-Type", None) @@ -1194,6 +1217,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[False] = False, @@ -1206,6 +1230,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[True], @@ -1219,6 +1244,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool, @@ -1231,13 +1257,25 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool = False, stream_cls: type[_StreamT] | None = None, ) -> ResponseT | _StreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=to_httpx_files(files), **options + method="post", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) @@ -1247,9 +1285,24 @@ def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="patch", url=path, json_data=body, content=content, files=to_httpx_files(files), **options + ) return self.request(cast_to, opts) def put( @@ -1258,11 +1311,23 @@ def put( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=to_httpx_files(files), **options + method="put", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return self.request(cast_to, opts) @@ -1272,9 +1337,19 @@ def delete( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) return self.request(cast_to, opts) def get_api_list( @@ -1714,6 +1789,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[False] = False, @@ -1726,6 +1802,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[True], @@ -1739,6 +1816,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool, @@ -1751,13 +1829,25 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool = False, stream_cls: type[_AsyncStreamT] | None = None, ) -> ResponseT | _AsyncStreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="post", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) @@ -1767,9 +1857,29 @@ async def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="patch", + url=path, + json_data=body, + content=content, + files=await async_to_httpx_files(files), + **options, + ) return await self.request(cast_to, opts) async def put( @@ -1778,11 +1888,23 @@ async def put( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="put", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts) @@ -1792,9 +1914,19 @@ async def delete( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) return await self.request(cast_to, opts) def get_api_list( diff --git a/src/postgrid/_client.py b/src/postgrid/_client.py index 58281bc..b292045 100644 --- a/src/postgrid/_client.py +++ b/src/postgrid/_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import Any, Mapping +from typing import TYPE_CHECKING, Any, Mapping from typing_extensions import Self, override import httpx @@ -21,8 +21,8 @@ not_given, ) from ._utils import is_given, get_async_library +from ._compat import cached_property from ._version import __version__ -from .resources import address_verification, intl_address_verification from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import APIStatusError from ._base_client import ( @@ -30,7 +30,15 @@ SyncAPIClient, AsyncAPIClient, ) -from .resources.print_mail import print_mail + +if TYPE_CHECKING: + from .resources import print_mail, address_verification, intl_address_verification + from .resources.address_verification import AddressVerificationResource, AsyncAddressVerificationResource + from .resources.print_mail.print_mail import PrintMailResource, AsyncPrintMailResource + from .resources.intl_address_verification import ( + IntlAddressVerificationResource, + AsyncIntlAddressVerificationResource, + ) __all__ = [ "Timeout", @@ -45,12 +53,6 @@ class PostGrid(SyncAPIClient): - address_verification: address_verification.AddressVerificationResource - intl_address_verification: intl_address_verification.IntlAddressVerificationResource - print_mail: print_mail.PrintMailResource - with_raw_response: PostGridWithRawResponse - with_streaming_response: PostGridWithStreamedResponse - # client options address_verification_api_key: str | None print_mail_api_key: str | None @@ -109,11 +111,31 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.address_verification = address_verification.AddressVerificationResource(self) - self.intl_address_verification = intl_address_verification.IntlAddressVerificationResource(self) - self.print_mail = print_mail.PrintMailResource(self) - self.with_raw_response = PostGridWithRawResponse(self) - self.with_streaming_response = PostGridWithStreamedResponse(self) + @cached_property + def address_verification(self) -> AddressVerificationResource: + from .resources.address_verification import AddressVerificationResource + + return AddressVerificationResource(self) + + @cached_property + def intl_address_verification(self) -> IntlAddressVerificationResource: + from .resources.intl_address_verification import IntlAddressVerificationResource + + return IntlAddressVerificationResource(self) + + @cached_property + def print_mail(self) -> PrintMailResource: + from .resources.print_mail import PrintMailResource + + return PrintMailResource(self) + + @cached_property + def with_raw_response(self) -> PostGridWithRawResponse: + return PostGridWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> PostGridWithStreamedResponse: + return PostGridWithStreamedResponse(self) @override def _prepare_request( @@ -164,14 +186,7 @@ def default_headers(self) -> dict[str, str | Omit]: @override def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None: - if self.address_verification_api_key and headers.get("X-API-Key"): - return - if isinstance(custom_headers.get("X-API-Key"), Omit): - return - - if self.print_mail_api_key and headers.get("X-API-Key"): - return - if isinstance(custom_headers.get("X-API-Key"), Omit): + if headers.get("X-API-Key") or isinstance(custom_headers.get("X-API-Key"), Omit): return raise TypeError( @@ -266,12 +281,6 @@ def _make_status_error( class AsyncPostGrid(AsyncAPIClient): - address_verification: address_verification.AsyncAddressVerificationResource - intl_address_verification: intl_address_verification.AsyncIntlAddressVerificationResource - print_mail: print_mail.AsyncPrintMailResource - with_raw_response: AsyncPostGridWithRawResponse - with_streaming_response: AsyncPostGridWithStreamedResponse - # client options address_verification_api_key: str | None print_mail_api_key: str | None @@ -330,11 +339,31 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.address_verification = address_verification.AsyncAddressVerificationResource(self) - self.intl_address_verification = intl_address_verification.AsyncIntlAddressVerificationResource(self) - self.print_mail = print_mail.AsyncPrintMailResource(self) - self.with_raw_response = AsyncPostGridWithRawResponse(self) - self.with_streaming_response = AsyncPostGridWithStreamedResponse(self) + @cached_property + def address_verification(self) -> AsyncAddressVerificationResource: + from .resources.address_verification import AsyncAddressVerificationResource + + return AsyncAddressVerificationResource(self) + + @cached_property + def intl_address_verification(self) -> AsyncIntlAddressVerificationResource: + from .resources.intl_address_verification import AsyncIntlAddressVerificationResource + + return AsyncIntlAddressVerificationResource(self) + + @cached_property + def print_mail(self) -> AsyncPrintMailResource: + from .resources.print_mail import AsyncPrintMailResource + + return AsyncPrintMailResource(self) + + @cached_property + def with_raw_response(self) -> AsyncPostGridWithRawResponse: + return AsyncPostGridWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncPostGridWithStreamedResponse: + return AsyncPostGridWithStreamedResponse(self) @override async def _prepare_request( @@ -385,14 +414,7 @@ def default_headers(self) -> dict[str, str | Omit]: @override def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None: - if self.address_verification_api_key and headers.get("X-API-Key"): - return - if isinstance(custom_headers.get("X-API-Key"), Omit): - return - - if self.print_mail_api_key and headers.get("X-API-Key"): - return - if isinstance(custom_headers.get("X-API-Key"), Omit): + if headers.get("X-API-Key") or isinstance(custom_headers.get("X-API-Key"), Omit): return raise TypeError( @@ -487,49 +509,109 @@ def _make_status_error( class PostGridWithRawResponse: + _client: PostGrid + def __init__(self, client: PostGrid) -> None: - self.address_verification = address_verification.AddressVerificationResourceWithRawResponse( - client.address_verification - ) - self.intl_address_verification = intl_address_verification.IntlAddressVerificationResourceWithRawResponse( - client.intl_address_verification - ) - self.print_mail = print_mail.PrintMailResourceWithRawResponse(client.print_mail) + self._client = client + + @cached_property + def address_verification(self) -> address_verification.AddressVerificationResourceWithRawResponse: + from .resources.address_verification import AddressVerificationResourceWithRawResponse + + return AddressVerificationResourceWithRawResponse(self._client.address_verification) + + @cached_property + def intl_address_verification(self) -> intl_address_verification.IntlAddressVerificationResourceWithRawResponse: + from .resources.intl_address_verification import IntlAddressVerificationResourceWithRawResponse + + return IntlAddressVerificationResourceWithRawResponse(self._client.intl_address_verification) + + @cached_property + def print_mail(self) -> print_mail.PrintMailResourceWithRawResponse: + from .resources.print_mail import PrintMailResourceWithRawResponse + + return PrintMailResourceWithRawResponse(self._client.print_mail) class AsyncPostGridWithRawResponse: + _client: AsyncPostGrid + def __init__(self, client: AsyncPostGrid) -> None: - self.address_verification = address_verification.AsyncAddressVerificationResourceWithRawResponse( - client.address_verification - ) - self.intl_address_verification = intl_address_verification.AsyncIntlAddressVerificationResourceWithRawResponse( - client.intl_address_verification - ) - self.print_mail = print_mail.AsyncPrintMailResourceWithRawResponse(client.print_mail) + self._client = client + + @cached_property + def address_verification(self) -> address_verification.AsyncAddressVerificationResourceWithRawResponse: + from .resources.address_verification import AsyncAddressVerificationResourceWithRawResponse + + return AsyncAddressVerificationResourceWithRawResponse(self._client.address_verification) + + @cached_property + def intl_address_verification( + self, + ) -> intl_address_verification.AsyncIntlAddressVerificationResourceWithRawResponse: + from .resources.intl_address_verification import AsyncIntlAddressVerificationResourceWithRawResponse + + return AsyncIntlAddressVerificationResourceWithRawResponse(self._client.intl_address_verification) + + @cached_property + def print_mail(self) -> print_mail.AsyncPrintMailResourceWithRawResponse: + from .resources.print_mail import AsyncPrintMailResourceWithRawResponse + + return AsyncPrintMailResourceWithRawResponse(self._client.print_mail) class PostGridWithStreamedResponse: + _client: PostGrid + def __init__(self, client: PostGrid) -> None: - self.address_verification = address_verification.AddressVerificationResourceWithStreamingResponse( - client.address_verification - ) - self.intl_address_verification = intl_address_verification.IntlAddressVerificationResourceWithStreamingResponse( - client.intl_address_verification - ) - self.print_mail = print_mail.PrintMailResourceWithStreamingResponse(client.print_mail) + self._client = client + + @cached_property + def address_verification(self) -> address_verification.AddressVerificationResourceWithStreamingResponse: + from .resources.address_verification import AddressVerificationResourceWithStreamingResponse + + return AddressVerificationResourceWithStreamingResponse(self._client.address_verification) + + @cached_property + def intl_address_verification( + self, + ) -> intl_address_verification.IntlAddressVerificationResourceWithStreamingResponse: + from .resources.intl_address_verification import IntlAddressVerificationResourceWithStreamingResponse + + return IntlAddressVerificationResourceWithStreamingResponse(self._client.intl_address_verification) + + @cached_property + def print_mail(self) -> print_mail.PrintMailResourceWithStreamingResponse: + from .resources.print_mail import PrintMailResourceWithStreamingResponse + + return PrintMailResourceWithStreamingResponse(self._client.print_mail) class AsyncPostGridWithStreamedResponse: + _client: AsyncPostGrid + def __init__(self, client: AsyncPostGrid) -> None: - self.address_verification = address_verification.AsyncAddressVerificationResourceWithStreamingResponse( - client.address_verification - ) - self.intl_address_verification = ( - intl_address_verification.AsyncIntlAddressVerificationResourceWithStreamingResponse( - client.intl_address_verification - ) - ) - self.print_mail = print_mail.AsyncPrintMailResourceWithStreamingResponse(client.print_mail) + self._client = client + + @cached_property + def address_verification(self) -> address_verification.AsyncAddressVerificationResourceWithStreamingResponse: + from .resources.address_verification import AsyncAddressVerificationResourceWithStreamingResponse + + return AsyncAddressVerificationResourceWithStreamingResponse(self._client.address_verification) + + @cached_property + def intl_address_verification( + self, + ) -> intl_address_verification.AsyncIntlAddressVerificationResourceWithStreamingResponse: + from .resources.intl_address_verification import AsyncIntlAddressVerificationResourceWithStreamingResponse + + return AsyncIntlAddressVerificationResourceWithStreamingResponse(self._client.intl_address_verification) + + @cached_property + def print_mail(self) -> print_mail.AsyncPrintMailResourceWithStreamingResponse: + from .resources.print_mail import AsyncPrintMailResourceWithStreamingResponse + + return AsyncPrintMailResourceWithStreamingResponse(self._client.print_mail) Client = PostGrid diff --git a/src/postgrid/_compat.py b/src/postgrid/_compat.py index bdef67f..786ff42 100644 --- a/src/postgrid/_compat.py +++ b/src/postgrid/_compat.py @@ -139,6 +139,7 @@ def model_dump( exclude_defaults: bool = False, warnings: bool = True, mode: Literal["json", "python"] = "python", + by_alias: bool | None = None, ) -> dict[str, Any]: if (not PYDANTIC_V1) or hasattr(model, "model_dump"): return model.model_dump( @@ -148,13 +149,12 @@ def model_dump( exclude_defaults=exclude_defaults, # warnings are not supported in Pydantic v1 warnings=True if PYDANTIC_V1 else warnings, + by_alias=by_alias, ) return cast( "dict[str, Any]", model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast] - exclude=exclude, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, + exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, by_alias=bool(by_alias) ), ) diff --git a/src/postgrid/_models.py b/src/postgrid/_models.py index ca9500b..29070e0 100644 --- a/src/postgrid/_models.py +++ b/src/postgrid/_models.py @@ -3,7 +3,20 @@ import os import inspect import weakref -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast +from typing import ( + IO, + TYPE_CHECKING, + Any, + Type, + Union, + Generic, + TypeVar, + Callable, + Iterable, + Optional, + AsyncIterable, + cast, +) from datetime import date, datetime from typing_extensions import ( List, @@ -787,6 +800,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): timeout: float | Timeout | None files: HttpxRequestFiles | None idempotency_key: str + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] json_data: Body extra_json: AnyMapping follow_redirects: bool @@ -805,6 +819,7 @@ class FinalRequestOptions(pydantic.BaseModel): post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() follow_redirects: Union[bool, None] = None + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None # It should be noted that we cannot use `json` here as that would override # a BaseModel method in an incompatible fashion. json_data: Union[Body, None] = None diff --git a/src/postgrid/_types.py b/src/postgrid/_types.py index 7be3f6a..ce5c982 100644 --- a/src/postgrid/_types.py +++ b/src/postgrid/_types.py @@ -13,9 +13,11 @@ Mapping, TypeVar, Callable, + Iterable, Iterator, Optional, Sequence, + AsyncIterable, ) from typing_extensions import ( Set, @@ -56,6 +58,13 @@ else: Base64FileInput = Union[IO[bytes], PathLike] FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8. + + +# Used for sending raw binary data / streaming data in request bodies +# e.g. for file uploads without multipart encoding +BinaryTypes = Union[bytes, bytearray, IO[bytes], Iterable[bytes]] +AsyncBinaryTypes = Union[bytes, bytearray, IO[bytes], AsyncIterable[bytes]] + FileTypes = Union[ # file (or bytes) FileContent, diff --git a/src/postgrid/_utils/_json.py b/src/postgrid/_utils/_json.py new file mode 100644 index 0000000..6058421 --- /dev/null +++ b/src/postgrid/_utils/_json.py @@ -0,0 +1,35 @@ +import json +from typing import Any +from datetime import datetime +from typing_extensions import override + +import pydantic + +from .._compat import model_dump + + +def openapi_dumps(obj: Any) -> bytes: + """ + Serialize an object to UTF-8 encoded JSON bytes. + + Extends the standard json.dumps with support for additional types + commonly used in the SDK, such as `datetime`, `pydantic.BaseModel`, etc. + """ + return json.dumps( + obj, + cls=_CustomEncoder, + # Uses the same defaults as httpx's JSON serialization + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ).encode() + + +class _CustomEncoder(json.JSONEncoder): + @override + def default(self, o: Any) -> Any: + if isinstance(o, datetime): + return o.isoformat() + if isinstance(o, pydantic.BaseModel): + return model_dump(o, exclude_unset=True, mode="json", by_alias=True) + return super().default(o) diff --git a/src/postgrid/_version.py b/src/postgrid/_version.py index 0544c30..1b45248 100644 --- a/src/postgrid/_version.py +++ b/src/postgrid/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "postgrid" -__version__ = "2.0.3" # x-release-please-version +__version__ = "2.1.0" # x-release-please-version diff --git a/tests/test_client.py b/tests/test_client.py index 937363b..e21e154 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,10 +8,11 @@ import json import asyncio import inspect +import dataclasses import tracemalloc -from typing import Any, Union, cast +from typing import Any, Union, TypeVar, Callable, Iterable, Iterator, Optional, Coroutine, cast from unittest import mock -from typing_extensions import Literal +from typing_extensions import Literal, AsyncIterator, override import httpx import pytest @@ -36,6 +37,7 @@ from .utils import update_env +T = TypeVar("T") base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") address_verification_api_key = "My Address Verification API Key" print_mail_api_key = "My Print Mail API Key" @@ -51,6 +53,57 @@ def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float: return 0.1 +def mirror_request_content(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=request.content) + + +# note: we can't use the httpx.MockTransport class as it consumes the request +# body itself, which means we can't test that the body is read lazily +class MockTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): + def __init__( + self, + handler: Callable[[httpx.Request], httpx.Response] + | Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]], + ) -> None: + self.handler = handler + + @override + def handle_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert not inspect.iscoroutinefunction(self.handler), "handler must not be a coroutine function" + assert inspect.isfunction(self.handler), "handler must be a function" + return self.handler(request) + + @override + async def handle_async_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert inspect.iscoroutinefunction(self.handler), "handler must be a coroutine function" + return await self.handler(request) + + +@dataclasses.dataclass +class Counter: + value: int = 0 + + +def _make_sync_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> Iterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + +async def _make_async_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> AsyncIterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + def _get_open_connections(client: PostGrid | AsyncPostGrid) -> int: transport = client._client._transport assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) @@ -567,6 +620,71 @@ def test_multipart_repeating_array(self, client: PostGrid) -> None: b"", ] + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload(self, respx_mock: MockRouter, client: PostGrid) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + def test_binary_content_upload_with_iterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_sync_iterator([file_content], counter=counter) + + def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=request.read()) + + with PostGrid( + base_url=base_url, + address_verification_api_key=address_verification_api_key, + print_mail_api_key=print_mail_api_key, + _strict_response_validation=True, + http_client=httpx.Client(transport=MockTransport(handler=mock_handler)), + ) as client: + response = client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload_with_body_is_deprecated(self, respx_mock: MockRouter, client: PostGrid) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + @pytest.mark.respx(base_url=base_url) def test_basic_union_response(self, respx_mock: MockRouter, client: PostGrid) -> None: class Model1(BaseModel): @@ -1507,6 +1625,73 @@ def test_multipart_repeating_array(self, async_client: AsyncPostGrid) -> None: b"", ] + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload(self, respx_mock: MockRouter, async_client: AsyncPostGrid) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = await async_client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + async def test_binary_content_upload_with_asynciterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_async_iterator([file_content], counter=counter) + + async def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=await request.aread()) + + async with AsyncPostGrid( + base_url=base_url, + address_verification_api_key=address_verification_api_key, + print_mail_api_key=print_mail_api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(transport=MockTransport(handler=mock_handler)), + ) as client: + response = await client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload_with_body_is_deprecated( + self, respx_mock: MockRouter, async_client: AsyncPostGrid + ) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = await async_client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + @pytest.mark.respx(base_url=base_url) async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncPostGrid) -> None: class Model1(BaseModel): diff --git a/tests/test_utils/test_json.py b/tests/test_utils/test_json.py new file mode 100644 index 0000000..97f987b --- /dev/null +++ b/tests/test_utils/test_json.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import datetime +from typing import Union + +import pydantic + +from postgrid import _compat +from postgrid._utils._json import openapi_dumps + + +class TestOpenapiDumps: + def test_basic(self) -> None: + data = {"key": "value", "number": 42} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"key":"value","number":42}' + + def test_datetime_serialization(self) -> None: + dt = datetime.datetime(2023, 1, 1, 12, 0, 0) + data = {"datetime": dt} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"datetime":"2023-01-01T12:00:00"}' + + def test_pydantic_model_serialization(self) -> None: + class User(pydantic.BaseModel): + first_name: str + last_name: str + age: int + + model_instance = User(first_name="John", last_name="Kramer", age=83) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"first_name":"John","last_name":"Kramer","age":83}}' + + def test_pydantic_model_with_default_values(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + score: int = 0 + + model_instance = User(name="Alice") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Alice"}}' + + def test_pydantic_model_with_default_values_overridden(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + + model_instance = User(name="Bob", role="admin", active=False) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Bob","role":"admin","active":false}}' + + def test_pydantic_model_with_alias(self) -> None: + class User(pydantic.BaseModel): + first_name: str = pydantic.Field(alias="firstName") + last_name: str = pydantic.Field(alias="lastName") + + model_instance = User(firstName="John", lastName="Doe") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"firstName":"John","lastName":"Doe"}}' + + def test_pydantic_model_with_alias_and_default(self) -> None: + class User(pydantic.BaseModel): + user_name: str = pydantic.Field(alias="userName") + user_role: str = pydantic.Field(default="member", alias="userRole") + is_active: bool = pydantic.Field(default=True, alias="isActive") + + model_instance = User(userName="charlie") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"charlie"}}' + + model_with_overrides = User(userName="diana", userRole="admin", isActive=False) + data = {"model": model_with_overrides} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"diana","userRole":"admin","isActive":false}}' + + def test_pydantic_model_with_nested_models_and_defaults(self) -> None: + class Address(pydantic.BaseModel): + street: str + city: str = "Unknown" + + class User(pydantic.BaseModel): + name: str + address: Address + verified: bool = False + + if _compat.PYDANTIC_V1: + # to handle forward references in Pydantic v1 + User.update_forward_refs(**locals()) # type: ignore[reportDeprecated] + + address = Address(street="123 Main St") + user = User(name="Diana", address=address) + data = {"user": user} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"user":{"name":"Diana","address":{"street":"123 Main St"}}}' + + address_with_city = Address(street="456 Oak Ave", city="Boston") + user_verified = User(name="Eve", address=address_with_city, verified=True) + data = {"user": user_verified} + json_bytes = openapi_dumps(data) + assert ( + json_bytes == b'{"user":{"name":"Eve","address":{"street":"456 Oak Ave","city":"Boston"},"verified":true}}' + ) + + def test_pydantic_model_with_optional_fields(self) -> None: + class User(pydantic.BaseModel): + name: str + email: Union[str, None] + phone: Union[str, None] + + model_with_none = User(name="Eve", email=None, phone=None) + data = {"model": model_with_none} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Eve","email":null,"phone":null}}' + + model_with_values = User(name="Frank", email="frank@example.com", phone=None) + data = {"model": model_with_values} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Frank","email":"frank@example.com","phone":null}}'