diff --git a/.release-please-manifest.json b/.release-please-manifest.json index d157e03..01f6bba 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "7.6.1" + ".": "7.7.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 647de16..92aa691 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 78 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/courier%2Fcourier-b79e0eb1ab06f4076c48fa519e2b2ad792a0c483a5d017e43c938ca4c4be6988.yml -openapi_spec_hash: cb3cc2c1145503e5737a880326857aa4 -config_hash: ff903e824043dc81aca51a0ce21896d6 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/courier%2Fcourier-e3e54d99e2a73fd87519270f2685131050d342e86a4e96130247b854deae5c20.yml +openapi_spec_hash: 897a3fbee24f24d021d6af0df480220c +config_hash: 66a5c28bb74d78454456d9ce7d1c0a0c diff --git a/CHANGELOG.md b/CHANGELOG.md index f32c125..597a6d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 7.7.0 (2026-01-14) + +Full Changelog: [v7.6.1...v7.7.0](https://github.com/trycourier/courier-python/compare/v7.6.1...v7.7.0) + +### Features + +* **client:** add support for binary request streaming ([f0cc366](https://github.com/trycourier/courier-python/commit/f0cc366e61a95d66d2cf01477822c21625e3a2a3)) + + +### Chores + +* **internal:** regenerate SDK with no functional changes ([a9d8cf1](https://github.com/trycourier/courier-python/commit/a9d8cf18d10f801475fb21b74546781769244cb4)) +* **internal:** regenerate SDK with no functional changes ([4b373e6](https://github.com/trycourier/courier-python/commit/4b373e6fabfd0005eb5f4d954fc9f2826120cc09)) + ## 7.6.1 (2026-01-12) Full Changelog: [v7.6.0...v7.6.1](https://github.com/trycourier/courier-python/compare/v7.6.0...v7.6.1) diff --git a/api.md b/api.md index 23fedde..b0e8a70 100644 --- a/api.md +++ b/api.md @@ -6,6 +6,7 @@ from courier.types import ( AirshipProfileAudience, Alignment, AudienceFilter, + AudienceFilterConfig, AudienceRecipient, ChannelClassification, ChannelPreference, @@ -24,6 +25,7 @@ from courier.types import ( ElementalQuoteNodeWithType, ElementalTextNodeWithType, Expo, + FilterConfig, Intercom, IntercomRecipient, ListFilter, @@ -91,9 +93,6 @@ Types: ```python from courier.types import ( Audience, - Filter, - NestedFilterConfig, - SingleFilterConfig, AudienceUpdateResponse, AudienceListResponse, AudienceListMembersResponse, diff --git a/pyproject.toml b/pyproject.toml index a5e75c3..3e769ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "trycourier" -version = "7.6.1" +version = "7.7.0" description = "The official Python library for the Courier API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/courier/_base_client.py b/src/courier/_base_client.py index ab0624d..724a399 100644 --- a/src/courier/_base_client.py +++ b/src/courier/_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, @@ -477,8 +480,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,7 +546,13 @@ 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 @@ -1194,6 +1214,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 +1227,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 +1241,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 +1254,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,11 +1282,23 @@ def patch( *, 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="patch", url=path, json_data=body, files=to_httpx_files(files), **options + method="patch", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return self.request(cast_to, opts) @@ -1261,11 +1308,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) @@ -1275,9 +1334,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( @@ -1717,6 +1786,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, @@ -1729,6 +1799,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], @@ -1742,6 +1813,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool, @@ -1754,13 +1826,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) @@ -1770,11 +1854,28 @@ async def patch( *, 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="patch", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="patch", + url=path, + json_data=body, + content=content, + files=await async_to_httpx_files(files), + **options, ) return await self.request(cast_to, opts) @@ -1784,11 +1885,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) @@ -1798,9 +1911,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/courier/_models.py b/src/courier/_models.py index ca9500b..29070e0 100644 --- a/src/courier/_models.py +++ b/src/courier/_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/courier/_types.py b/src/courier/_types.py index e4deef5..e92110d 100644 --- a/src/courier/_types.py +++ b/src/courier/_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/courier/_version.py b/src/courier/_version.py index 5a8e351..27c3ad5 100644 --- a/src/courier/_version.py +++ b/src/courier/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "courier" -__version__ = "7.6.1" # x-release-please-version +__version__ = "7.7.0" # x-release-please-version diff --git a/src/courier/resources/audiences.py b/src/courier/resources/audiences.py index 0ef979c..449c9ff 100644 --- a/src/courier/resources/audiences.py +++ b/src/courier/resources/audiences.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Optional +from typing_extensions import Literal import httpx @@ -19,10 +20,10 @@ ) from .._base_client import make_request_options from ..types.audience import Audience -from ..types.filter_param import FilterParam from ..types.audience_list_response import AudienceListResponse from ..types.audience_update_response import AudienceUpdateResponse from ..types.audience_list_members_response import AudienceListMembersResponse +from ..types.shared_params.audience_filter_config import AudienceFilterConfig __all__ = ["AudiencesResource", "AsyncAudiencesResource"] @@ -85,8 +86,9 @@ def update( audience_id: str, *, description: Optional[str] | Omit = omit, - filter: Optional[FilterParam] | Omit = omit, + filter: Optional[AudienceFilterConfig] | Omit = omit, name: Optional[str] | Omit = omit, + operator: Optional[Literal["AND", "OR"]] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -100,10 +102,12 @@ def update( Args: description: A description of the audience - filter: A single filter to use for filtering + filter: Filter configuration for audience membership containing an array of filter rules name: The name of the audience + operator: The logical operator (AND/OR) for the top-level filter + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -121,6 +125,7 @@ def update( "description": description, "filter": filter, "name": name, + "operator": operator, }, audience_update_params.AudienceUpdateParams, ), @@ -300,8 +305,9 @@ async def update( audience_id: str, *, description: Optional[str] | Omit = omit, - filter: Optional[FilterParam] | Omit = omit, + filter: Optional[AudienceFilterConfig] | Omit = omit, name: Optional[str] | Omit = omit, + operator: Optional[Literal["AND", "OR"]] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -315,10 +321,12 @@ async def update( Args: description: A description of the audience - filter: A single filter to use for filtering + filter: Filter configuration for audience membership containing an array of filter rules name: The name of the audience + operator: The logical operator (AND/OR) for the top-level filter + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -336,6 +344,7 @@ async def update( "description": description, "filter": filter, "name": name, + "operator": operator, }, audience_update_params.AudienceUpdateParams, ), diff --git a/src/courier/types/__init__.py b/src/courier/types/__init__.py index e5cb054..128f19b 100644 --- a/src/courier/types/__init__.py +++ b/src/courier/types/__init__.py @@ -2,21 +2,12 @@ from __future__ import annotations -from . import ( - shared, - tenants, - audience, - nested_filter_config, - audience_list_response, - audience_update_response, - notification_list_response, -) +from . import shared, tenants, audience, audience_list_response, audience_update_response, notification_list_response from .. import _compat from .logo import Logo as Logo from .brand import Brand as Brand from .check import Check as Check from .icons import Icons as Icons -from .filter import Filter as Filter from .shared import ( Utm as Utm, Expo as Expo, @@ -34,6 +25,7 @@ ListFilter as ListFilter, Preference as Preference, UserProfile as UserProfile, + FilterConfig as FilterConfig, ElementalNode as ElementalNode, ListRecipient as ListRecipient, SendToChannel as SendToChannel, @@ -64,6 +56,7 @@ WebhookProfileType as WebhookProfileType, SendToMsTeamsUserID as SendToMsTeamsUserID, SlackBaseProperties as SlackBaseProperties, + AudienceFilterConfig as AudienceFilterConfig, ElementalChannelNode as ElementalChannelNode, ListPatternRecipient as ListPatternRecipient, RecipientPreferences as RecipientPreferences, @@ -96,7 +89,6 @@ from .brand_colors import BrandColors as BrandColors from .email_footer import EmailFooter as EmailFooter from .email_header import EmailHeader as EmailHeader -from .filter_param import FilterParam as FilterParam from .brand_snippet import BrandSnippet as BrandSnippet from .brand_settings import BrandSettings as BrandSettings from .brand_snippets import BrandSnippets as BrandSnippets @@ -129,8 +121,6 @@ from .brand_snippets_param import BrandSnippetsParam as BrandSnippetsParam from .brand_template_param import BrandTemplateParam as BrandTemplateParam from .inbound_bulk_message import InboundBulkMessage as InboundBulkMessage -from .nested_filter_config import NestedFilterConfig as NestedFilterConfig -from .single_filter_config import SingleFilterConfig as SingleFilterConfig from .tenant_list_response import TenantListResponse as TenantListResponse from .tenant_update_params import TenantUpdateParams as TenantUpdateParams from .brand_settings_in_app import BrandSettingsInApp as BrandSettingsInApp @@ -173,9 +163,7 @@ from .bulk_retrieve_job_response import BulkRetrieveJobResponse as BulkRetrieveJobResponse from .inbound_bulk_message_param import InboundBulkMessageParam as InboundBulkMessageParam from .inbound_track_event_params import InboundTrackEventParams as InboundTrackEventParams -from .nested_filter_config_param import NestedFilterConfigParam as NestedFilterConfigParam from .notification_list_response import NotificationListResponse as NotificationListResponse -from .single_filter_config_param import SingleFilterConfigParam as SingleFilterConfigParam from .tenant_list_users_response import TenantListUsersResponse as TenantListUsersResponse from .brand_settings_in_app_param import BrandSettingsInAppParam as BrandSettingsInAppParam from .audience_list_members_params import AudienceListMembersParams as AudienceListMembersParams @@ -195,17 +183,19 @@ # See: https://github.com/pydantic/pydantic/issues/11250 for more context. if _compat.PYDANTIC_V1: audience.Audience.update_forward_refs() # type: ignore - nested_filter_config.NestedFilterConfig.update_forward_refs() # type: ignore audience_update_response.AudienceUpdateResponse.update_forward_refs() # type: ignore audience_list_response.AudienceListResponse.update_forward_refs() # type: ignore notification_list_response.NotificationListResponse.update_forward_refs() # type: ignore tenants.template_list_response.TemplateListResponse.update_forward_refs() # type: ignore + shared.audience_filter_config.AudienceFilterConfig.update_forward_refs() # type: ignore + shared.filter_config.FilterConfig.update_forward_refs() # type: ignore shared.message_routing.MessageRouting.update_forward_refs() # type: ignore else: audience.Audience.model_rebuild(_parent_namespace_depth=0) - nested_filter_config.NestedFilterConfig.model_rebuild(_parent_namespace_depth=0) audience_update_response.AudienceUpdateResponse.model_rebuild(_parent_namespace_depth=0) audience_list_response.AudienceListResponse.model_rebuild(_parent_namespace_depth=0) notification_list_response.NotificationListResponse.model_rebuild(_parent_namespace_depth=0) tenants.template_list_response.TemplateListResponse.model_rebuild(_parent_namespace_depth=0) + shared.audience_filter_config.AudienceFilterConfig.model_rebuild(_parent_namespace_depth=0) + shared.filter_config.FilterConfig.model_rebuild(_parent_namespace_depth=0) shared.message_routing.MessageRouting.model_rebuild(_parent_namespace_depth=0) diff --git a/src/courier/types/audience.py b/src/courier/types/audience.py index d23c328..fdc5b95 100644 --- a/src/courier/types/audience.py +++ b/src/courier/types/audience.py @@ -2,6 +2,9 @@ from __future__ import annotations +from typing import Optional +from typing_extensions import Literal + from .._models import BaseModel __all__ = ["Audience"] @@ -16,13 +19,18 @@ class Audience(BaseModel): description: str """A description of the audience""" - filter: "Filter" - """A single filter to use for filtering""" - name: str """The name of the audience""" updated_at: str + filter: Optional["AudienceFilterConfig"] = None + """ + Filter configuration for audience membership containing an array of filter rules + """ + + operator: Optional[Literal["AND", "OR"]] = None + """The logical operator (AND/OR) for the top-level filter""" + -from .filter import Filter +from .shared.audience_filter_config import AudienceFilterConfig diff --git a/src/courier/types/audience_update_params.py b/src/courier/types/audience_update_params.py index 8b23789..735af9d 100644 --- a/src/courier/types/audience_update_params.py +++ b/src/courier/types/audience_update_params.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Optional -from typing_extensions import TypedDict +from typing_extensions import Literal, TypedDict __all__ = ["AudienceUpdateParams"] @@ -12,11 +12,16 @@ class AudienceUpdateParams(TypedDict, total=False): description: Optional[str] """A description of the audience""" - filter: Optional["FilterParam"] - """A single filter to use for filtering""" + filter: Optional["AudienceFilterConfig"] + """ + Filter configuration for audience membership containing an array of filter rules + """ name: Optional[str] """The name of the audience""" + operator: Optional[Literal["AND", "OR"]] + """The logical operator (AND/OR) for the top-level filter""" -from .filter_param import FilterParam + +from .shared_params.audience_filter_config import AudienceFilterConfig diff --git a/src/courier/types/filter.py b/src/courier/types/filter.py deleted file mode 100644 index ff47733..0000000 --- a/src/courier/types/filter.py +++ /dev/null @@ -1,18 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import TYPE_CHECKING, Union -from typing_extensions import TypeAlias, TypeAliasType - -from .._compat import PYDANTIC_V1 -from .single_filter_config import SingleFilterConfig - -__all__ = ["Filter"] - -if TYPE_CHECKING or not PYDANTIC_V1: - Filter = TypeAliasType("Filter", Union[SingleFilterConfig, "NestedFilterConfig"]) -else: - Filter: TypeAlias = Union[SingleFilterConfig, "NestedFilterConfig"] - -from .nested_filter_config import NestedFilterConfig diff --git a/src/courier/types/filter_param.py b/src/courier/types/filter_param.py deleted file mode 100644 index 98114c9..0000000 --- a/src/courier/types/filter_param.py +++ /dev/null @@ -1,18 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import TYPE_CHECKING, Union -from typing_extensions import TypeAlias, TypeAliasType - -from .._compat import PYDANTIC_V1 -from .single_filter_config_param import SingleFilterConfigParam - -__all__ = ["FilterParam"] - -if TYPE_CHECKING or not PYDANTIC_V1: - FilterParam = TypeAliasType("FilterParam", Union[SingleFilterConfigParam, "NestedFilterConfigParam"]) -else: - FilterParam: TypeAlias = Union[SingleFilterConfigParam, "NestedFilterConfigParam"] - -from .nested_filter_config_param import NestedFilterConfigParam diff --git a/src/courier/types/nested_filter_config.py b/src/courier/types/nested_filter_config.py deleted file mode 100644 index 3ead986..0000000 --- a/src/courier/types/nested_filter_config.py +++ /dev/null @@ -1,36 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import List -from typing_extensions import Literal - -from .._models import BaseModel - -__all__ = ["NestedFilterConfig"] - - -class NestedFilterConfig(BaseModel): - operator: Literal[ - "ENDS_WITH", - "EQ", - "EXISTS", - "GT", - "GTE", - "INCLUDES", - "IS_AFTER", - "IS_BEFORE", - "LT", - "LTE", - "NEQ", - "OMIT", - "STARTS_WITH", - "AND", - "OR", - ] - """The operator to use for filtering""" - - rules: List["Filter"] - - -from .filter import Filter diff --git a/src/courier/types/nested_filter_config_param.py b/src/courier/types/nested_filter_config_param.py deleted file mode 100644 index 9cfb644..0000000 --- a/src/courier/types/nested_filter_config_param.py +++ /dev/null @@ -1,36 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import Iterable -from typing_extensions import Literal, Required, TypedDict - -__all__ = ["NestedFilterConfigParam"] - - -class NestedFilterConfigParam(TypedDict, total=False): - operator: Required[ - Literal[ - "ENDS_WITH", - "EQ", - "EXISTS", - "GT", - "GTE", - "INCLUDES", - "IS_AFTER", - "IS_BEFORE", - "LT", - "LTE", - "NEQ", - "OMIT", - "STARTS_WITH", - "AND", - "OR", - ] - ] - """The operator to use for filtering""" - - rules: Required[Iterable["FilterParam"]] - - -from .filter_param import FilterParam diff --git a/src/courier/types/shared/__init__.py b/src/courier/types/shared/__init__.py index fd02dd5..57c8dea 100644 --- a/src/courier/types/shared/__init__.py +++ b/src/courier/types/shared/__init__.py @@ -16,6 +16,7 @@ from .device_type import DeviceType as DeviceType from .list_filter import ListFilter as ListFilter from .user_profile import UserProfile as UserProfile +from .filter_config import FilterConfig as FilterConfig from .elemental_node import ElementalNode as ElementalNode from .list_recipient import ListRecipient as ListRecipient from .user_recipient import UserRecipient as UserRecipient @@ -45,6 +46,7 @@ from .send_to_slack_channel import SendToSlackChannel as SendToSlackChannel from .send_to_slack_user_id import SendToSlackUserID as SendToSlackUserID from .slack_base_properties import SlackBaseProperties as SlackBaseProperties +from .audience_filter_config import AudienceFilterConfig as AudienceFilterConfig from .channel_classification import ChannelClassification as ChannelClassification from .elemental_channel_node import ElementalChannelNode as ElementalChannelNode from .list_pattern_recipient import ListPatternRecipient as ListPatternRecipient diff --git a/src/courier/types/shared/audience_filter_config.py b/src/courier/types/shared/audience_filter_config.py new file mode 100644 index 0000000..bc36426 --- /dev/null +++ b/src/courier/types/shared/audience_filter_config.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List + +from ..._models import BaseModel + +__all__ = ["AudienceFilterConfig"] + + +class AudienceFilterConfig(BaseModel): + """ + Filter configuration for audience membership containing an array of filter rules + """ + + filters: List["FilterConfig"] + """Array of filter rules (single conditions or nested groups)""" + + +from .filter_config import FilterConfig diff --git a/src/courier/types/shared/elemental_channel_node.py b/src/courier/types/shared/elemental_channel_node.py index 59d14aa..cf34cac 100644 --- a/src/courier/types/shared/elemental_channel_node.py +++ b/src/courier/types/shared/elemental_channel_node.py @@ -19,7 +19,7 @@ class ElementalChannelNode(ElementalBaseNode): [control flow docs](https://www.courier.com/docs/platform/content/elemental/control-flow/) for more details. """ - channel: str + channel: Optional[str] = None """The channel the contents of this element should be applied to. Can be `email`, `push`, `direct_message`, `sms` or a provider such as slack diff --git a/src/courier/types/shared/filter_config.py b/src/courier/types/shared/filter_config.py new file mode 100644 index 0000000..203485a --- /dev/null +++ b/src/courier/types/shared/filter_config.py @@ -0,0 +1,41 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List, Optional + +from ..._models import BaseModel + +__all__ = ["FilterConfig"] + + +class FilterConfig(BaseModel): + """ + A filter rule that can be either a single condition (with path/value) or a nested group (with filters array). Use comparison operators (EQ, GT, etc.) for single conditions, and logical operators (AND, OR) for nested groups. + """ + + operator: str + """The operator for this filter. + + Use comparison operators (EQ, GT, LT, GTE, LTE, NEQ, EXISTS, INCLUDES, + STARTS_WITH, ENDS_WITH, IS_BEFORE, IS_AFTER, OMIT) for single conditions, or + logical operators (AND, OR) for nested filter groups. + """ + + filters: Optional[List["FilterConfig"]] = None + """Nested filter rules to combine with AND/OR. + + Required for nested filter groups, not used for single filter conditions. + """ + + path: Optional[str] = None + """The attribute path from the user profile to filter on. + + Required for single filter conditions, not used for nested filter groups. + """ + + value: Optional[str] = None + """The value to compare against. + + Required for single filter conditions, not used for nested filter groups. + """ diff --git a/src/courier/types/shared_params/__init__.py b/src/courier/types/shared_params/__init__.py index be5b8bf..9ebe052 100644 --- a/src/courier/types/shared_params/__init__.py +++ b/src/courier/types/shared_params/__init__.py @@ -7,6 +7,7 @@ from .pagerduty import Pagerduty as Pagerduty from .preference import Preference as Preference from .list_filter import ListFilter as ListFilter +from .filter_config import FilterConfig as FilterConfig from .elemental_node import ElementalNode as ElementalNode from .list_recipient import ListRecipient as ListRecipient from .user_recipient import UserRecipient as UserRecipient @@ -30,6 +31,7 @@ from .recipient_preferences import RecipientPreferences as RecipientPreferences from .send_to_slack_channel import SendToSlackChannel as SendToSlackChannel from .send_to_slack_user_id import SendToSlackUserID as SendToSlackUserID +from .audience_filter_config import AudienceFilterConfig as AudienceFilterConfig from .channel_classification import ChannelClassification as ChannelClassification from .elemental_channel_node import ElementalChannelNode as ElementalChannelNode from .list_pattern_recipient import ListPatternRecipient as ListPatternRecipient diff --git a/src/courier/types/shared_params/audience_filter_config.py b/src/courier/types/shared_params/audience_filter_config.py new file mode 100644 index 0000000..9996b97 --- /dev/null +++ b/src/courier/types/shared_params/audience_filter_config.py @@ -0,0 +1,20 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Iterable +from typing_extensions import Required, TypedDict + +__all__ = ["AudienceFilterConfig"] + + +class AudienceFilterConfig(TypedDict, total=False): + """ + Filter configuration for audience membership containing an array of filter rules + """ + + filters: Required[Iterable["FilterConfig"]] + """Array of filter rules (single conditions or nested groups)""" + + +from .filter_config import FilterConfig diff --git a/src/courier/types/shared_params/elemental_channel_node.py b/src/courier/types/shared_params/elemental_channel_node.py index 035608a..bffdf2d 100644 --- a/src/courier/types/shared_params/elemental_channel_node.py +++ b/src/courier/types/shared_params/elemental_channel_node.py @@ -3,7 +3,6 @@ from __future__ import annotations from typing import Dict, Optional -from typing_extensions import Required from .elemental_base_node import ElementalBaseNode @@ -22,7 +21,7 @@ class ElementalChannelNode(ElementalBaseNode, total=False): [control flow docs](https://www.courier.com/docs/platform/content/elemental/control-flow/) for more details. """ - channel: Required[str] + channel: str """The channel the contents of this element should be applied to. Can be `email`, `push`, `direct_message`, `sms` or a provider such as slack diff --git a/src/courier/types/shared_params/filter_config.py b/src/courier/types/shared_params/filter_config.py new file mode 100644 index 0000000..616e2f3 --- /dev/null +++ b/src/courier/types/shared_params/filter_config.py @@ -0,0 +1,40 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Iterable +from typing_extensions import Required, TypedDict + +__all__ = ["FilterConfig"] + + +class FilterConfig(TypedDict, total=False): + """ + A filter rule that can be either a single condition (with path/value) or a nested group (with filters array). Use comparison operators (EQ, GT, etc.) for single conditions, and logical operators (AND, OR) for nested groups. + """ + + operator: Required[str] + """The operator for this filter. + + Use comparison operators (EQ, GT, LT, GTE, LTE, NEQ, EXISTS, INCLUDES, + STARTS_WITH, ENDS_WITH, IS_BEFORE, IS_AFTER, OMIT) for single conditions, or + logical operators (AND, OR) for nested filter groups. + """ + + filters: Iterable["FilterConfig"] + """Nested filter rules to combine with AND/OR. + + Required for nested filter groups, not used for single filter conditions. + """ + + path: str + """The attribute path from the user profile to filter on. + + Required for single filter conditions, not used for nested filter groups. + """ + + value: str + """The value to compare against. + + Required for single filter conditions, not used for nested filter groups. + """ diff --git a/src/courier/types/single_filter_config.py b/src/courier/types/single_filter_config.py deleted file mode 100644 index 7ce85e3..0000000 --- a/src/courier/types/single_filter_config.py +++ /dev/null @@ -1,37 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from .._models import BaseModel - -__all__ = ["SingleFilterConfig"] - - -class SingleFilterConfig(BaseModel): - operator: Literal[ - "ENDS_WITH", - "EQ", - "EXISTS", - "GT", - "GTE", - "INCLUDES", - "IS_AFTER", - "IS_BEFORE", - "LT", - "LTE", - "NEQ", - "OMIT", - "STARTS_WITH", - "AND", - "OR", - ] - """The operator to use for filtering""" - - path: str - """ - The attribute name from profile whose value will be operated against the filter - value - """ - - value: str - """The value to use for filtering""" diff --git a/src/courier/types/single_filter_config_param.py b/src/courier/types/single_filter_config_param.py deleted file mode 100644 index 1f2b24e..0000000 --- a/src/courier/types/single_filter_config_param.py +++ /dev/null @@ -1,39 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Literal, Required, TypedDict - -__all__ = ["SingleFilterConfigParam"] - - -class SingleFilterConfigParam(TypedDict, total=False): - operator: Required[ - Literal[ - "ENDS_WITH", - "EQ", - "EXISTS", - "GT", - "GTE", - "INCLUDES", - "IS_AFTER", - "IS_BEFORE", - "LT", - "LTE", - "NEQ", - "OMIT", - "STARTS_WITH", - "AND", - "OR", - ] - ] - """The operator to use for filtering""" - - path: Required[str] - """ - The attribute name from profile whose value will be operated against the filter - value - """ - - value: Required[str] - """The value to use for filtering""" diff --git a/tests/api_resources/test_audiences.py b/tests/api_resources/test_audiences.py index d1b0cc9..045b3ea 100644 --- a/tests/api_resources/test_audiences.py +++ b/tests/api_resources/test_audiences.py @@ -79,11 +79,17 @@ def test_method_update_with_all_params(self, client: Courier) -> None: audience_id="audience_id", description="description", filter={ - "operator": "ENDS_WITH", - "path": "path", - "value": "value", + "filters": [ + { + "operator": "operator", + "filters": [], + "path": "path", + "value": "value", + } + ] }, name="name", + operator="AND", ) assert_matches_type(AudienceUpdateResponse, audience, path=["response"]) @@ -313,11 +319,17 @@ async def test_method_update_with_all_params(self, async_client: AsyncCourier) - audience_id="audience_id", description="description", filter={ - "operator": "ENDS_WITH", - "path": "path", - "value": "value", + "filters": [ + { + "operator": "operator", + "filters": [], + "path": "path", + "value": "value", + } + ] }, name="name", + operator="AND", ) assert_matches_type(AudienceUpdateResponse, audience, path=["response"]) diff --git a/tests/test_client.py b/tests/test_client.py index 7ded852..f023e65 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") api_key = "My API Key" @@ -50,6 +52,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: Courier | AsyncCourier) -> int: transport = client._client._transport assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) @@ -500,6 +553,70 @@ def test_multipart_repeating_array(self, client: Courier) -> None: b"", ] + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload(self, respx_mock: MockRouter, client: Courier) -> 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 Courier( + base_url=base_url, + api_key=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: Courier) -> 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: Courier) -> None: class Model1(BaseModel): @@ -1319,6 +1436,72 @@ def test_multipart_repeating_array(self, async_client: AsyncCourier) -> None: b"", ] + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload(self, respx_mock: MockRouter, async_client: AsyncCourier) -> 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 AsyncCourier( + base_url=base_url, + api_key=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: AsyncCourier + ) -> 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: AsyncCourier) -> None: class Model1(BaseModel):