diff --git a/.release-please-manifest.json b/.release-please-manifest.json index d615a41..ace108e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "7.17.1" + ".": "7.18.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 5fa6994..4fd0815 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 134 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/courier/courier-97bb4b698571b6dbe884e93397d14aff0ec7e7279de272a15fa0defb3b2515d5.yml -openapi_spec_hash: c33bf8733151f4f5693788b6e57a8344 -config_hash: 74aad10d1512ec69541ece3ca51cf7fa +configured_endpoints: 135 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/courier/courier-e593915c67b3e0e375b6f9cf57d9931f86bc3ee4fd6759ba0e8098d5421fa04e.yml +openapi_spec_hash: 66cc0ce5dda5bda9f416ea3e53c3f5a0 +config_hash: 66d7703eac15d2affc326ac25b55bcd6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db886a..0ad99a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 7.18.0 (2026-06-30) + +Full Changelog: [v7.17.1...v7.18.0](https://github.com/trycourier/courier-python/compare/v7.17.1...v7.18.0) + +### Features + +* **openapi:** Journeys cancel-by-token endpoint + send-node experiments (C-18986) ([2506b29](https://github.com/trycourier/courier-python/commit/2506b29674ce56a9d4cfff46b39c849f28ab2875)) + + +### Bug Fixes + +* **types:** avoid type-checker errors on params with additional properties ([2898ba5](https://github.com/trycourier/courier-python/commit/2898ba510e0aeb8de9da57e4f50e6951af14f223)) + ## 7.17.1 (2026-06-25) Full Changelog: [v7.17.0...v7.17.1](https://github.com/trycourier/courier-python/compare/v7.17.0...v7.17.1) diff --git a/api.md b/api.md index 7392eb5..b685418 100644 --- a/api.md +++ b/api.md @@ -197,6 +197,8 @@ Types: ```python from courier.types import ( + CancelJourneyRequest, + CancelJourneyResponse, CreateJourneyRequest, Journey, JourneyAINode, @@ -208,6 +210,8 @@ from courier.types import ( JourneyDelayDurationNode, JourneyDelayUntilNode, JourneyExitNode, + JourneyExperiment, + JourneyExperimentVariant, JourneyFetchGetDeleteNode, JourneyFetchPostPutNode, JourneyMergeStrategy, @@ -239,6 +243,7 @@ Methods: - client.journeys.retrieve(template_id, \*\*params) -> JourneyResponse - client.journeys.list(\*\*params) -> JourneysListResponse - client.journeys.archive(template_id) -> None +- client.journeys.cancel(\*\*params) -> CancelJourneyResponse - client.journeys.invoke(template_id, \*\*params) -> JourneysInvokeResponse - client.journeys.list_versions(template_id) -> JourneyVersionsListResponse - client.journeys.publish(template_id, \*\*params) -> JourneyResponse diff --git a/pyproject.toml b/pyproject.toml index 6456aac..b52e825 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "trycourier" -version = "7.17.1" +version = "7.18.0" description = "The official Python library for the Courier API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/courier/_version.py b/src/courier/_version.py index b849cde..de99489 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.17.1" # x-release-please-version +__version__ = "7.18.0" # x-release-please-version diff --git a/src/courier/resources/journeys/journeys.py b/src/courier/resources/journeys/journeys.py index 255d21d..5d4ee72 100644 --- a/src/courier/resources/journeys/journeys.py +++ b/src/courier/resources/journeys/journeys.py @@ -2,14 +2,15 @@ from __future__ import annotations -from typing import Dict, Iterable -from typing_extensions import Literal +from typing import Any, Dict, Iterable, cast +from typing_extensions import Literal, overload import httpx from ...types import ( JourneyState, journey_list_params, + journey_cancel_params, journey_create_params, journey_invoke_params, journey_publish_params, @@ -17,7 +18,7 @@ journey_retrieve_params, ) from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given -from ..._utils import path_template, maybe_transform, async_maybe_transform +from ..._utils import path_template, required_args, maybe_transform, async_maybe_transform from ..._compat import cached_property from .templates import ( TemplatesResource, @@ -39,6 +40,7 @@ from ...types.journey_response import JourneyResponse from ...types.journey_node_param import JourneyNodeParam from ...types.journeys_list_response import JourneysListResponse +from ...types.cancel_journey_response import CancelJourneyResponse from ...types.journeys_invoke_response import JourneysInvokeResponse from ...types.journey_versions_list_response import JourneyVersionsListResponse @@ -247,6 +249,105 @@ def archive( cast_to=NoneType, ) + @overload + def cancel( + self, + *, + cancelation_token: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CancelJourneyResponse: + """Cancel journey runs. + + The request body must contain EXACTLY ONE of + `cancelation_token` (cancels every run associated with the token) or `run_id` + (cancels a single tenant-scoped run). Supplying both or neither is a `400`. A + `run_id` that does not exist for the caller's tenant returns `404`. Cancelation + is idempotent and non-clobbering: a run that has already finished + (`PROCESSED`/`ERROR`) or was already `CANCELED` is left untouched and its + current status is echoed back. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + def cancel( + self, + *, + run_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CancelJourneyResponse: + """Cancel journey runs. + + The request body must contain EXACTLY ONE of + `cancelation_token` (cancels every run associated with the token) or `run_id` + (cancels a single tenant-scoped run). Supplying both or neither is a `400`. A + `run_id` that does not exist for the caller's tenant returns `404`. Cancelation + is idempotent and non-clobbering: a run that has already finished + (`PROCESSED`/`ERROR`) or was already `CANCELED` is left untouched and its + current status is echoed back. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @required_args(["cancelation_token"], ["run_id"]) + def cancel( + self, + *, + cancelation_token: str | Omit = omit, + run_id: str | 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, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CancelJourneyResponse: + return cast( + CancelJourneyResponse, + self._post( + "/journeys/cancel", + body=maybe_transform( + { + "cancelation_token": cancelation_token, + "run_id": run_id, + }, + journey_cancel_params.JourneyCancelParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=cast( + Any, CancelJourneyResponse + ), # Union types cannot be passed in as arguments in the type system + ), + ) + def invoke( self, template_id: str, @@ -635,6 +736,105 @@ async def archive( cast_to=NoneType, ) + @overload + async def cancel( + self, + *, + cancelation_token: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CancelJourneyResponse: + """Cancel journey runs. + + The request body must contain EXACTLY ONE of + `cancelation_token` (cancels every run associated with the token) or `run_id` + (cancels a single tenant-scoped run). Supplying both or neither is a `400`. A + `run_id` that does not exist for the caller's tenant returns `404`. Cancelation + is idempotent and non-clobbering: a run that has already finished + (`PROCESSED`/`ERROR`) or was already `CANCELED` is left untouched and its + current status is echoed back. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + async def cancel( + self, + *, + run_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CancelJourneyResponse: + """Cancel journey runs. + + The request body must contain EXACTLY ONE of + `cancelation_token` (cancels every run associated with the token) or `run_id` + (cancels a single tenant-scoped run). Supplying both or neither is a `400`. A + `run_id` that does not exist for the caller's tenant returns `404`. Cancelation + is idempotent and non-clobbering: a run that has already finished + (`PROCESSED`/`ERROR`) or was already `CANCELED` is left untouched and its + current status is echoed back. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @required_args(["cancelation_token"], ["run_id"]) + async def cancel( + self, + *, + cancelation_token: str | Omit = omit, + run_id: str | 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, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CancelJourneyResponse: + return cast( + CancelJourneyResponse, + await self._post( + "/journeys/cancel", + body=await async_maybe_transform( + { + "cancelation_token": cancelation_token, + "run_id": run_id, + }, + journey_cancel_params.JourneyCancelParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=cast( + Any, CancelJourneyResponse + ), # Union types cannot be passed in as arguments in the type system + ), + ) + async def invoke( self, template_id: str, @@ -837,6 +1037,9 @@ def __init__(self, journeys: JourneysResource) -> None: self.archive = to_raw_response_wrapper( journeys.archive, ) + self.cancel = to_raw_response_wrapper( + journeys.cancel, + ) self.invoke = to_raw_response_wrapper( journeys.invoke, ) @@ -871,6 +1074,9 @@ def __init__(self, journeys: AsyncJourneysResource) -> None: self.archive = async_to_raw_response_wrapper( journeys.archive, ) + self.cancel = async_to_raw_response_wrapper( + journeys.cancel, + ) self.invoke = async_to_raw_response_wrapper( journeys.invoke, ) @@ -905,6 +1111,9 @@ def __init__(self, journeys: JourneysResource) -> None: self.archive = to_streamed_response_wrapper( journeys.archive, ) + self.cancel = to_streamed_response_wrapper( + journeys.cancel, + ) self.invoke = to_streamed_response_wrapper( journeys.invoke, ) @@ -939,6 +1148,9 @@ def __init__(self, journeys: AsyncJourneysResource) -> None: self.archive = async_to_streamed_response_wrapper( journeys.archive, ) + self.cancel = async_to_streamed_response_wrapper( + journeys.cancel, + ) self.invoke = async_to_streamed_response_wrapper( journeys.invoke, ) diff --git a/src/courier/types/__init__.py b/src/courier/types/__init__.py index ce39a5b..a6a96a0 100644 --- a/src/courier/types/__init__.py +++ b/src/courier/types/__init__.py @@ -133,6 +133,7 @@ from .brand_colors_param import BrandColorsParam as BrandColorsParam from .email_footer_param import EmailFooterParam as EmailFooterParam from .email_header_param import EmailHeaderParam as EmailHeaderParam +from .journey_experiment import JourneyExperiment as JourneyExperiment from .journey_node_param import JourneyNodeParam as JourneyNodeParam from .list_list_response import ListListResponse as ListListResponse from .list_update_params import ListUpdateParams as ListUpdateParams @@ -160,6 +161,7 @@ from .brand_settings_in_app import BrandSettingsInApp as BrandSettingsInApp from .bulk_add_users_params import BulkAddUsersParams as BulkAddUsersParams from .journey_ai_node_param import JourneyAINodeParam as JourneyAINodeParam +from .journey_cancel_params import JourneyCancelParams as JourneyCancelParams from .journey_create_params import JourneyCreateParams as JourneyCreateParams from .journey_invoke_params import JourneyInvokeParams as JourneyInvokeParams from .message_list_response import MessageListResponse as MessageListResponse @@ -185,6 +187,7 @@ from .subscription_topic_new import SubscriptionTopicNew as SubscriptionTopicNew from .audit_event_list_params import AuditEventListParams as AuditEventListParams from .auth_issue_token_params import AuthIssueTokenParams as AuthIssueTokenParams +from .cancel_journey_response import CancelJourneyResponse as CancelJourneyResponse from .journey_condition_group import JourneyConditionGroup as JourneyConditionGroup from .journey_exit_node_param import JourneyExitNodeParam as JourneyExitNodeParam from .journey_retrieve_params import JourneyRetrieveParams as JourneyRetrieveParams @@ -197,6 +200,7 @@ from .bulk_list_users_response import BulkListUsersResponse as BulkListUsersResponse from .journey_conditions_field import JourneyConditionsField as JourneyConditionsField from .journey_delay_until_node import JourneyDelayUntilNode as JourneyDelayUntilNode +from .journey_experiment_param import JourneyExperimentParam as JourneyExperimentParam from .journey_template_summary import JourneyTemplateSummary as JourneyTemplateSummary from .journeys_invoke_response import JourneysInvokeResponse as JourneysInvokeResponse from .message_content_response import MessageContentResponse as MessageContentResponse @@ -219,6 +223,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 .journey_experiment_variant import JourneyExperimentVariant as JourneyExperimentVariant from .notification_create_params import NotificationCreateParams as NotificationCreateParams from .notification_list_response import NotificationListResponse as NotificationListResponse from .tenant_list_users_response import TenantListUsersResponse as TenantListUsersResponse @@ -230,6 +235,7 @@ from .notification_template_state import NotificationTemplateState as NotificationTemplateState from .tenant_template_input_param import TenantTemplateInputParam as TenantTemplateInputParam from .audience_list_members_params import AudienceListMembersParams as AudienceListMembersParams +from .cancel_journey_request_param import CancelJourneyRequestParam as CancelJourneyRequestParam from .inbound_track_event_response import InboundTrackEventResponse as InboundTrackEventResponse from .journey_condition_atom_param import JourneyConditionAtomParam as JourneyConditionAtomParam from .journey_segment_trigger_node import JourneySegmentTriggerNode as JourneySegmentTriggerNode @@ -264,6 +270,7 @@ from .notification_put_element_params import NotificationPutElementParams as NotificationPutElementParams from .routing_strategy_replace_params import RoutingStrategyReplaceParams as RoutingStrategyReplaceParams from .base_template_tenant_association import BaseTemplateTenantAssociation as BaseTemplateTenantAssociation +from .journey_experiment_variant_param import JourneyExperimentVariantParam as JourneyExperimentVariantParam from .automation_template_list_response import AutomationTemplateListResponse as AutomationTemplateListResponse from .journey_delay_duration_node_param import JourneyDelayDurationNodeParam as JourneyDelayDurationNodeParam from .journey_fetch_post_put_node_param import JourneyFetchPostPutNodeParam as JourneyFetchPostPutNodeParam diff --git a/src/courier/types/brand_colors_param.py b/src/courier/types/brand_colors_param.py index 0ec9776..109ab25 100644 --- a/src/courier/types/brand_colors_param.py +++ b/src/courier/types/brand_colors_param.py @@ -7,7 +7,11 @@ __all__ = ["BrandColorsParam"] -class BrandColorsParam(TypedDict, total=False, extra_items=str): # type: ignore[call-arg] +class BrandColorsParam( # type: ignore[call-arg] + TypedDict, + total=False, + extra_items=str, # pyright: ignore[reportGeneralTypeIssues] +): primary: str secondary: str diff --git a/src/courier/types/cancel_journey_request_param.py b/src/courier/types/cancel_journey_request_param.py new file mode 100644 index 0000000..20736ed --- /dev/null +++ b/src/courier/types/cancel_journey_request_param.py @@ -0,0 +1,19 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import Required, TypeAlias, TypedDict + +__all__ = ["CancelJourneyRequestParam", "ByCancelationToken", "ByRunID"] + + +class ByCancelationToken(TypedDict, total=False): + cancelation_token: Required[str] + + +class ByRunID(TypedDict, total=False): + run_id: Required[str] + + +CancelJourneyRequestParam: TypeAlias = Union[ByCancelationToken, ByRunID] diff --git a/src/courier/types/cancel_journey_response.py b/src/courier/types/cancel_journey_response.py new file mode 100644 index 0000000..c91e00e --- /dev/null +++ b/src/courier/types/cancel_journey_response.py @@ -0,0 +1,27 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union +from typing_extensions import TypeAlias + +from .._models import BaseModel + +__all__ = ["CancelJourneyResponse", "TokenBranch", "RunIDBranch"] + + +class TokenBranch(BaseModel): + cancelation_token: str + + +class RunIDBranch(BaseModel): + run_id: str + + status: str + """The run's resulting status. + + `CANCELED` when the run was active and we canceled it; `PROCESSED` or `ERROR` + when the run had already finished and was left untouched; `CANCELED` for an + already-canceled run. + """ + + +CancelJourneyResponse: TypeAlias = Union[TokenBranch, RunIDBranch] diff --git a/src/courier/types/journey_cancel_params.py b/src/courier/types/journey_cancel_params.py new file mode 100644 index 0000000..3287312 --- /dev/null +++ b/src/courier/types/journey_cancel_params.py @@ -0,0 +1,19 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import Required, TypeAlias, TypedDict + +__all__ = ["JourneyCancelParams", "ByCancelationToken", "ByRunID"] + + +class ByCancelationToken(TypedDict, total=False): + cancelation_token: Required[str] + + +class ByRunID(TypedDict, total=False): + run_id: Required[str] + + +JourneyCancelParams: TypeAlias = Union[ByCancelationToken, ByRunID] diff --git a/src/courier/types/journey_experiment.py b/src/courier/types/journey_experiment.py new file mode 100644 index 0000000..a6521d2 --- /dev/null +++ b/src/courier/types/journey_experiment.py @@ -0,0 +1,35 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from pydantic import Field as FieldInfo + +from .._models import BaseModel +from .journey_experiment_variant import JourneyExperimentVariant + +__all__ = ["JourneyExperiment"] + + +class JourneyExperiment(BaseModel): + """A/B experiment config for a send node. + + The recipient is deterministically bucketed by `bucketingKey` and routed to one of the `variants` in proportion to its `weight`. Present on a send node INSTEAD OF `message.template`. + """ + + bucketing_key: str = FieldInfo(alias="bucketingKey") + """The value used to deterministically assign a recipient to a variant. + + Must be non-empty with no leading or trailing whitespace. + """ + + variants: List[JourneyExperimentVariant] + """Between 2 and 10 weighted template variants.""" + + id: Optional[str] = None + """Server-authoritative experiment id (prefixed `exp_`). + + Omit to have the server mint one; when supplied it must be a valid `exp_` id. + """ + + name: Optional[str] = None + """Optional, cosmetic display name for the experiment.""" diff --git a/src/courier/types/journey_experiment_param.py b/src/courier/types/journey_experiment_param.py new file mode 100644 index 0000000..45770ef --- /dev/null +++ b/src/courier/types/journey_experiment_param.py @@ -0,0 +1,36 @@ +# 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, Annotated, TypedDict + +from .._utils import PropertyInfo +from .journey_experiment_variant_param import JourneyExperimentVariantParam + +__all__ = ["JourneyExperimentParam"] + + +class JourneyExperimentParam(TypedDict, total=False): + """A/B experiment config for a send node. + + The recipient is deterministically bucketed by `bucketingKey` and routed to one of the `variants` in proportion to its `weight`. Present on a send node INSTEAD OF `message.template`. + """ + + bucketing_key: Required[Annotated[str, PropertyInfo(alias="bucketingKey")]] + """The value used to deterministically assign a recipient to a variant. + + Must be non-empty with no leading or trailing whitespace. + """ + + variants: Required[Iterable[JourneyExperimentVariantParam]] + """Between 2 and 10 weighted template variants.""" + + id: str + """Server-authoritative experiment id (prefixed `exp_`). + + Omit to have the server mint one; when supplied it must be a valid `exp_` id. + """ + + name: str + """Optional, cosmetic display name for the experiment.""" diff --git a/src/courier/types/journey_experiment_variant.py b/src/courier/types/journey_experiment_variant.py new file mode 100644 index 0000000..7bb3d7a --- /dev/null +++ b/src/courier/types/journey_experiment_variant.py @@ -0,0 +1,27 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["JourneyExperimentVariant"] + + +class JourneyExperimentVariant(BaseModel): + """A single weighted arm of an experiment. + + Variant ids must be unique within the experiment and the sum of all variant weights must be greater than 0. Weights are relative (no sum-to-100 requirement) — routing normalizes them proportionally. + """ + + id: str + + template_id: str = FieldInfo(alias="templateId") + """The notification template sent for this variant.""" + + weight: float + """Relative routing weight. Must be non-negative.""" + + name: Optional[str] = None + """Optional, cosmetic display name for the variant.""" diff --git a/src/courier/types/journey_experiment_variant_param.py b/src/courier/types/journey_experiment_variant_param.py new file mode 100644 index 0000000..ece7b09 --- /dev/null +++ b/src/courier/types/journey_experiment_variant_param.py @@ -0,0 +1,27 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["JourneyExperimentVariantParam"] + + +class JourneyExperimentVariantParam(TypedDict, total=False): + """A single weighted arm of an experiment. + + Variant ids must be unique within the experiment and the sum of all variant weights must be greater than 0. Weights are relative (no sum-to-100 requirement) — routing normalizes them proportionally. + """ + + id: Required[str] + + template_id: Required[Annotated[str, PropertyInfo(alias="templateId")]] + """The notification template sent for this variant.""" + + weight: Required[float] + """Relative routing weight. Must be non-negative.""" + + name: str + """Optional, cosmetic display name for the variant.""" diff --git a/src/courier/types/journey_send_node.py b/src/courier/types/journey_send_node.py index 34d87ef..d346cac 100644 --- a/src/courier/types/journey_send_node.py +++ b/src/courier/types/journey_send_node.py @@ -4,6 +4,7 @@ from typing_extensions import Literal from .._models import BaseModel +from .journey_experiment import JourneyExperiment from .journey_conditions_field import JourneyConditionsField __all__ = ["JourneySendNode", "Message", "MessageDelay", "MessageTo"] @@ -24,19 +25,19 @@ class MessageTo(BaseModel): class Message(BaseModel): - template: str - data: Optional[Dict[str, object]] = None delay: Optional[MessageDelay] = None + template: Optional[str] = None + to: Optional[MessageTo] = None class JourneySendNode(BaseModel): - """Send a notification template to the recipient. + """Send to the recipient. - Optionally override the recipient address, delay the send, or attach `data`. + A send node sources its content from EXACTLY ONE of `message.template` (a single notification template) or `experiment` (an A/B split across weighted template variants) — supplying both, or neither, is rejected. Optionally override the recipient address, delay the send, or attach `data`. """ message: Message @@ -51,3 +52,11 @@ class JourneySendNode(BaseModel): Accepts a single condition atom, an AND/OR group, or an AND/OR nested group. Omit the `conditions` property entirely to express "no conditions". """ + + experiment: Optional[JourneyExperiment] = None + """A/B experiment config for a send node. + + The recipient is deterministically bucketed by `bucketingKey` and routed to one + of the `variants` in proportion to its `weight`. Present on a send node INSTEAD + OF `message.template`. + """ diff --git a/src/courier/types/journey_send_node_param.py b/src/courier/types/journey_send_node_param.py index 2d6e33a..5e051e7 100644 --- a/src/courier/types/journey_send_node_param.py +++ b/src/courier/types/journey_send_node_param.py @@ -5,6 +5,7 @@ from typing import Dict from typing_extensions import Literal, Required, TypedDict +from .journey_experiment_param import JourneyExperimentParam from .journey_conditions_field_param import JourneyConditionsFieldParam __all__ = ["JourneySendNodeParam", "Message", "MessageDelay", "MessageTo"] @@ -25,19 +26,19 @@ class MessageTo(TypedDict, total=False): class Message(TypedDict, total=False): - template: Required[str] - data: Dict[str, object] delay: MessageDelay + template: str + to: MessageTo class JourneySendNodeParam(TypedDict, total=False): - """Send a notification template to the recipient. + """Send to the recipient. - Optionally override the recipient address, delay the send, or attach `data`. + A send node sources its content from EXACTLY ONE of `message.template` (a single notification template) or `experiment` (an A/B split across weighted template variants) — supplying both, or neither, is rejected. Optionally override the recipient address, delay the send, or attach `data`. """ message: Required[Message] @@ -52,3 +53,11 @@ class JourneySendNodeParam(TypedDict, total=False): Accepts a single condition atom, an AND/OR group, or an AND/OR nested group. Omit the `conditions` property entirely to express "no conditions". """ + + experiment: JourneyExperimentParam + """A/B experiment config for a send node. + + The recipient is deterministically bucketed by `bucketingKey` and routed to one + of the `variants` in proportion to its `weight`. Present on a send node INSTEAD + OF `message.template`. + """ diff --git a/src/courier/types/journeys/template_put_locale_params.py b/src/courier/types/journeys/template_put_locale_params.py index a8f9e43..1c54728 100644 --- a/src/courier/types/journeys/template_put_locale_params.py +++ b/src/courier/types/journeys/template_put_locale_params.py @@ -23,6 +23,10 @@ class TemplatePutLocaleParams(TypedDict, total=False): """Template state. Defaults to `DRAFT`.""" -class Element(TypedDict, total=False, extra_items=object): # type: ignore[call-arg] +class Element( # type: ignore[call-arg] + TypedDict, + total=False, + extra_items=object, # pyright: ignore[reportGeneralTypeIssues] +): id: Required[str] """Target element ID.""" diff --git a/src/courier/types/notification_put_locale_params.py b/src/courier/types/notification_put_locale_params.py index e64ceed..6b71449 100644 --- a/src/courier/types/notification_put_locale_params.py +++ b/src/courier/types/notification_put_locale_params.py @@ -20,6 +20,10 @@ class NotificationPutLocaleParams(TypedDict, total=False): """Template state. Defaults to `DRAFT`.""" -class Element(TypedDict, total=False, extra_items=object): # type: ignore[call-arg] +class Element( # type: ignore[call-arg] + TypedDict, + total=False, + extra_items=object, # pyright: ignore[reportGeneralTypeIssues] +): id: Required[str] """Target element ID.""" diff --git a/tests/api_resources/test_journeys.py b/tests/api_resources/test_journeys.py index 36dca1f..22962b2 100644 --- a/tests/api_resources/test_journeys.py +++ b/tests/api_resources/test_journeys.py @@ -12,6 +12,7 @@ from courier.types import ( JourneyResponse, JourneysListResponse, + CancelJourneyResponse, JourneysInvokeResponse, JourneyVersionsListResponse, ) @@ -242,6 +243,74 @@ def test_path_params_archive(self, client: Courier) -> None: "", ) + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_cancel_overload_1(self, client: Courier) -> None: + journey = client.journeys.cancel( + cancelation_token="x", + ) + assert_matches_type(CancelJourneyResponse, journey, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_cancel_overload_1(self, client: Courier) -> None: + response = client.journeys.with_raw_response.cancel( + cancelation_token="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + journey = response.parse() + assert_matches_type(CancelJourneyResponse, journey, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_cancel_overload_1(self, client: Courier) -> None: + with client.journeys.with_streaming_response.cancel( + cancelation_token="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + journey = response.parse() + assert_matches_type(CancelJourneyResponse, journey, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_cancel_overload_2(self, client: Courier) -> None: + journey = client.journeys.cancel( + run_id="x", + ) + assert_matches_type(CancelJourneyResponse, journey, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_cancel_overload_2(self, client: Courier) -> None: + response = client.journeys.with_raw_response.cancel( + run_id="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + journey = response.parse() + assert_matches_type(CancelJourneyResponse, journey, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_cancel_overload_2(self, client: Courier) -> None: + with client.journeys.with_streaming_response.cancel( + run_id="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + journey = response.parse() + assert_matches_type(CancelJourneyResponse, journey, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize def test_method_invoke(self, client: Courier) -> None: @@ -707,6 +776,74 @@ async def test_path_params_archive(self, async_client: AsyncCourier) -> None: "", ) + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_cancel_overload_1(self, async_client: AsyncCourier) -> None: + journey = await async_client.journeys.cancel( + cancelation_token="x", + ) + assert_matches_type(CancelJourneyResponse, journey, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_cancel_overload_1(self, async_client: AsyncCourier) -> None: + response = await async_client.journeys.with_raw_response.cancel( + cancelation_token="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + journey = await response.parse() + assert_matches_type(CancelJourneyResponse, journey, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_cancel_overload_1(self, async_client: AsyncCourier) -> None: + async with async_client.journeys.with_streaming_response.cancel( + cancelation_token="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + journey = await response.parse() + assert_matches_type(CancelJourneyResponse, journey, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_cancel_overload_2(self, async_client: AsyncCourier) -> None: + journey = await async_client.journeys.cancel( + run_id="x", + ) + assert_matches_type(CancelJourneyResponse, journey, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_cancel_overload_2(self, async_client: AsyncCourier) -> None: + response = await async_client.journeys.with_raw_response.cancel( + run_id="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + journey = await response.parse() + assert_matches_type(CancelJourneyResponse, journey, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_cancel_overload_2(self, async_client: AsyncCourier) -> None: + async with async_client.journeys.with_streaming_response.cancel( + run_id="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + journey = await response.parse() + assert_matches_type(CancelJourneyResponse, journey, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize async def test_method_invoke(self, async_client: AsyncCourier) -> None: