From 7a8cea9054c4c620a621d79eb15ba0159deadd39 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 02:29:05 +0000 Subject: [PATCH 1/5] chore(docs): remove reference to rye shell --- CONTRIBUTING.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a0b8175c..2a13546b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,7 @@ $ rye sync --all-features You can then run scripts using `rye run python script.py` or by activating the virtual environment: ```sh -$ rye shell -# or manually activate - https://docs.python.org/3/library/venv.html#how-venvs-work +# Activate the virtual environment - https://docs.python.org/3/library/venv.html#how-venvs-work $ source .venv/bin/activate # now you can omit the `rye run` prefix From bd973709e9044293a48639c3a9fb403f7448f697 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 02:41:20 +0000 Subject: [PATCH 2/5] chore(docs): remove unnecessary param examples --- README.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/README.md b/README.md index 66e6a229..172b23cd 100644 --- a/README.md +++ b/README.md @@ -155,12 +155,7 @@ client = Knock() page = client.users.list_messages( user_id="user-123", - inserted_at={ - "gt": "gt", - "gte": "gte", - "lt": "lt", - "lte": "lte", - }, + inserted_at={}, ) print(page.items) ``` From 89019e20bc3f965a662dfa8abc33b4608b5b2e12 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 03:44:44 +0000 Subject: [PATCH 3/5] feat(client): add follow_redirects request option --- src/knockapi/_base_client.py | 6 ++++ src/knockapi/_models.py | 2 ++ src/knockapi/_types.py | 2 ++ tests/test_client.py | 54 ++++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/src/knockapi/_base_client.py b/src/knockapi/_base_client.py index bfcb2585..5e636d56 100644 --- a/src/knockapi/_base_client.py +++ b/src/knockapi/_base_client.py @@ -960,6 +960,9 @@ def request( if self.custom_auth is not None: kwargs["auth"] = self.custom_auth + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + log.debug("Sending HTTP Request: %s %s", request.method, request.url) response = None @@ -1460,6 +1463,9 @@ async def request( if self.custom_auth is not None: kwargs["auth"] = self.custom_auth + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + log.debug("Sending HTTP Request: %s %s", request.method, request.url) response = None diff --git a/src/knockapi/_models.py b/src/knockapi/_models.py index 798956f1..4f214980 100644 --- a/src/knockapi/_models.py +++ b/src/knockapi/_models.py @@ -737,6 +737,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): idempotency_key: str json_data: Body extra_json: AnyMapping + follow_redirects: bool @final @@ -750,6 +751,7 @@ class FinalRequestOptions(pydantic.BaseModel): files: Union[HttpxRequestFiles, None] = None idempotency_key: Union[str, None] = None post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() + follow_redirects: Union[bool, None] = None # It should be noted that we cannot use `json` here as that would override # a BaseModel method in an incompatible fashion. diff --git a/src/knockapi/_types.py b/src/knockapi/_types.py index 08853b66..44bb250b 100644 --- a/src/knockapi/_types.py +++ b/src/knockapi/_types.py @@ -100,6 +100,7 @@ class RequestOptions(TypedDict, total=False): params: Query extra_json: AnyMapping idempotency_key: str + follow_redirects: bool # Sentinel class used until PEP 0661 is accepted @@ -215,3 +216,4 @@ class _GenericAlias(Protocol): class HttpxSendArgs(TypedDict, total=False): auth: httpx.Auth + follow_redirects: bool diff --git a/tests/test_client.py b/tests/test_client.py index bb4bbd21..6d019cda 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -806,6 +806,33 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects(self, respx_mock: MockRouter) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + self.client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" + class TestAsyncKnock: client = AsyncKnock(base_url=base_url, api_key=api_key, _strict_response_validation=True) @@ -1623,3 +1650,30 @@ async def test_main() -> None: raise AssertionError("calling get_platform using asyncify resulted in a hung process") time.sleep(0.1) + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects(self, respx_mock: MockRouter) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + await self.client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" From d1373fca14df49d70f39a976cfc06655d47e63f4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 4 Jun 2025 15:47:51 +0000 Subject: [PATCH 4/5] feat(api): api update --- .stats.yml | 4 +- src/knockapi/resources/schedules/schedules.py | 28 ++- src/knockapi/types/schedule_create_params.py | 14 +- tests/api_resources/test_schedules.py | 173 +++++++++++++----- 4 files changed, 164 insertions(+), 55 deletions(-) diff --git a/.stats.yml b/.stats.yml index 37b615fd..1cdc6aa6 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 89 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/knock%2Fknock-2c885f8f3762bcc577255242aa61b3ad890170497c865f664d8ab42c8872d97c.yml -openapi_spec_hash: e0f6dd3ae8329fa8b96d1ffdc937cbc9 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/knock%2Fknock-5ce0d88003acccf6111cd40663ad7b4d468696fc439fca41d88087ab84b465c3.yml +openapi_spec_hash: ff864d7f3cad6a98a0e39fee0fb2b16a config_hash: 2ae8965d371a03bd30c6a56819c04cf2 diff --git a/src/knockapi/resources/schedules/schedules.py b/src/knockapi/resources/schedules/schedules.py index d4cd91e6..b3059c11 100644 --- a/src/knockapi/resources/schedules/schedules.py +++ b/src/knockapi/resources/schedules/schedules.py @@ -73,10 +73,11 @@ def create( self, *, recipients: List[RecipientRequestParam], - repeats: Iterable[ScheduleRepeatRuleParam], workflow: str, + actor: Optional[RecipientRequestParam] | NotGiven = NOT_GIVEN, data: Optional[Dict[str, object]] | NotGiven = NOT_GIVEN, ending_at: Union[str, datetime, None] | NotGiven = NOT_GIVEN, + repeats: Iterable[ScheduleRepeatRuleParam] | NotGiven = NOT_GIVEN, scheduled_at: Union[str, datetime, None] | NotGiven = NOT_GIVEN, tenant: Optional[InlineTenantRequestParam] | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -96,10 +97,12 @@ def create( Args: recipients: The recipients to set the schedule for. Limited to 100 recipients per request. - repeats: The repeat rule for the schedule. - workflow: The key of the workflow. + actor: Specifies a recipient in a request. This can either be a user identifier + (string), an inline user request (object), or an inline object request, which is + determined by the presence of a `collection` property. + data: An optional map of data to pass into the workflow execution. There is a 1024 byte limit on the size of any single string value (with the exception of [email attachments](/integrations/email/attachments)), and a 10MB limit on the @@ -107,6 +110,8 @@ def create( ending_at: The ending date and time for the schedule. + repeats: The repeat rule for the schedule. + scheduled_at: The starting date and time for the schedule. tenant: An request to set a tenant inline. @@ -124,10 +129,11 @@ def create( body=maybe_transform( { "recipients": recipients, - "repeats": repeats, "workflow": workflow, + "actor": actor, "data": data, "ending_at": ending_at, + "repeats": repeats, "scheduled_at": scheduled_at, "tenant": tenant, }, @@ -338,10 +344,11 @@ async def create( self, *, recipients: List[RecipientRequestParam], - repeats: Iterable[ScheduleRepeatRuleParam], workflow: str, + actor: Optional[RecipientRequestParam] | NotGiven = NOT_GIVEN, data: Optional[Dict[str, object]] | NotGiven = NOT_GIVEN, ending_at: Union[str, datetime, None] | NotGiven = NOT_GIVEN, + repeats: Iterable[ScheduleRepeatRuleParam] | NotGiven = NOT_GIVEN, scheduled_at: Union[str, datetime, None] | NotGiven = NOT_GIVEN, tenant: Optional[InlineTenantRequestParam] | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -361,10 +368,12 @@ async def create( Args: recipients: The recipients to set the schedule for. Limited to 100 recipients per request. - repeats: The repeat rule for the schedule. - workflow: The key of the workflow. + actor: Specifies a recipient in a request. This can either be a user identifier + (string), an inline user request (object), or an inline object request, which is + determined by the presence of a `collection` property. + data: An optional map of data to pass into the workflow execution. There is a 1024 byte limit on the size of any single string value (with the exception of [email attachments](/integrations/email/attachments)), and a 10MB limit on the @@ -372,6 +381,8 @@ async def create( ending_at: The ending date and time for the schedule. + repeats: The repeat rule for the schedule. + scheduled_at: The starting date and time for the schedule. tenant: An request to set a tenant inline. @@ -389,10 +400,11 @@ async def create( body=await async_maybe_transform( { "recipients": recipients, - "repeats": repeats, "workflow": workflow, + "actor": actor, "data": data, "ending_at": ending_at, + "repeats": repeats, "scheduled_at": scheduled_at, "tenant": tenant, }, diff --git a/src/knockapi/types/schedule_create_params.py b/src/knockapi/types/schedule_create_params.py index fdd08054..b7da2a45 100644 --- a/src/knockapi/types/schedule_create_params.py +++ b/src/knockapi/types/schedule_create_params.py @@ -18,12 +18,17 @@ class ScheduleCreateParams(TypedDict, total=False): recipients: Required[List[RecipientRequestParam]] """The recipients to set the schedule for. Limited to 100 recipients per request.""" - repeats: Required[Iterable[ScheduleRepeatRuleParam]] - """The repeat rule for the schedule.""" - workflow: Required[str] """The key of the workflow.""" + actor: Optional[RecipientRequestParam] + """Specifies a recipient in a request. + + This can either be a user identifier (string), an inline user request (object), + or an inline object request, which is determined by the presence of a + `collection` property. + """ + data: Optional[Dict[str, object]] """An optional map of data to pass into the workflow execution. @@ -35,6 +40,9 @@ class ScheduleCreateParams(TypedDict, total=False): ending_at: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """The ending date and time for the schedule.""" + repeats: Iterable[ScheduleRepeatRuleParam] + """The repeat rule for the schedule.""" + scheduled_at: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """The starting date and time for the schedule.""" diff --git a/tests/api_resources/test_schedules.py b/tests/api_resources/test_schedules.py index 51a527b4..8e90eeb4 100644 --- a/tests/api_resources/test_schedules.py +++ b/tests/api_resources/test_schedules.py @@ -15,6 +15,7 @@ ScheduleDeleteResponse, ScheduleUpdateResponse, ) +from knockapi._utils import parse_datetime from knockapi.pagination import SyncEntriesCursor, AsyncEntriesCursor base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -30,12 +31,6 @@ class TestSchedules: def test_method_create(self, client: Knock) -> None: schedule = client.schedules.create( recipients=["user_123"], - repeats=[ - { - "_typename": "ScheduleRepeat", - "frequency": "daily", - } - ], workflow="comment-created", ) assert_matches_type(ScheduleCreateResponse, schedule, path=["response"]) @@ -47,6 +42,71 @@ def test_method_create(self, client: Knock) -> None: def test_method_create_with_all_params(self, client: Knock) -> None: schedule = client.schedules.create( recipients=["user_123"], + workflow="comment-created", + actor={ + "id": "user_1", + "avatar": "avatar", + "channel_data": {"97c5837d-c65c-4d54-aa39-080eeb81c69d": {"tokens": ["push_token_xxx"]}}, + "created_at": parse_datetime("2019-12-27T18:11:19.117Z"), + "email": "email", + "locale": "locale", + "name": "name", + "phone_number": "phone_number", + "preferences": { + "default": { + "categories": { + "transactional": { + "channel_types": { + "chat": True, + "email": False, + "http": True, + "in_app_feed": True, + "push": True, + "sms": True, + }, + "conditions": [ + { + "argument": "frog_genome", + "operator": "contains", + "variable": "specimen.dna_sequence", + } + ], + } + }, + "channel_types": { + "chat": True, + "email": True, + "http": True, + "in_app_feed": True, + "push": True, + "sms": True, + }, + "workflows": { + "dinosaurs-loose": { + "channel_types": { + "chat": True, + "email": False, + "http": True, + "in_app_feed": True, + "push": True, + "sms": True, + }, + "conditions": [ + { + "argument": "frog_genome", + "operator": "contains", + "variable": "specimen.dna_sequence", + } + ], + }, + "welcome-sequence": True, + }, + } + }, + "timezone": "timezone", + }, + data={"key": "bar"}, + ending_at=None, repeats=[ { "_typename": "ScheduleRepeat", @@ -58,9 +118,6 @@ def test_method_create_with_all_params(self, client: Knock) -> None: "minutes": None, } ], - workflow="comment-created", - data={"key": "bar"}, - ending_at=None, scheduled_at=None, tenant="acme_corp", ) @@ -73,12 +130,6 @@ def test_method_create_with_all_params(self, client: Knock) -> None: def test_raw_response_create(self, client: Knock) -> None: response = client.schedules.with_raw_response.create( recipients=["user_123"], - repeats=[ - { - "_typename": "ScheduleRepeat", - "frequency": "daily", - } - ], workflow="comment-created", ) @@ -94,12 +145,6 @@ def test_raw_response_create(self, client: Knock) -> None: def test_streaming_response_create(self, client: Knock) -> None: with client.schedules.with_streaming_response.create( recipients=["user_123"], - repeats=[ - { - "_typename": "ScheduleRepeat", - "frequency": "daily", - } - ], workflow="comment-created", ) as response: assert not response.is_closed @@ -282,12 +327,6 @@ class TestAsyncSchedules: async def test_method_create(self, async_client: AsyncKnock) -> None: schedule = await async_client.schedules.create( recipients=["user_123"], - repeats=[ - { - "_typename": "ScheduleRepeat", - "frequency": "daily", - } - ], workflow="comment-created", ) assert_matches_type(ScheduleCreateResponse, schedule, path=["response"]) @@ -299,6 +338,71 @@ async def test_method_create(self, async_client: AsyncKnock) -> None: async def test_method_create_with_all_params(self, async_client: AsyncKnock) -> None: schedule = await async_client.schedules.create( recipients=["user_123"], + workflow="comment-created", + actor={ + "id": "user_1", + "avatar": "avatar", + "channel_data": {"97c5837d-c65c-4d54-aa39-080eeb81c69d": {"tokens": ["push_token_xxx"]}}, + "created_at": parse_datetime("2019-12-27T18:11:19.117Z"), + "email": "email", + "locale": "locale", + "name": "name", + "phone_number": "phone_number", + "preferences": { + "default": { + "categories": { + "transactional": { + "channel_types": { + "chat": True, + "email": False, + "http": True, + "in_app_feed": True, + "push": True, + "sms": True, + }, + "conditions": [ + { + "argument": "frog_genome", + "operator": "contains", + "variable": "specimen.dna_sequence", + } + ], + } + }, + "channel_types": { + "chat": True, + "email": True, + "http": True, + "in_app_feed": True, + "push": True, + "sms": True, + }, + "workflows": { + "dinosaurs-loose": { + "channel_types": { + "chat": True, + "email": False, + "http": True, + "in_app_feed": True, + "push": True, + "sms": True, + }, + "conditions": [ + { + "argument": "frog_genome", + "operator": "contains", + "variable": "specimen.dna_sequence", + } + ], + }, + "welcome-sequence": True, + }, + } + }, + "timezone": "timezone", + }, + data={"key": "bar"}, + ending_at=None, repeats=[ { "_typename": "ScheduleRepeat", @@ -310,9 +414,6 @@ async def test_method_create_with_all_params(self, async_client: AsyncKnock) -> "minutes": None, } ], - workflow="comment-created", - data={"key": "bar"}, - ending_at=None, scheduled_at=None, tenant="acme_corp", ) @@ -325,12 +426,6 @@ async def test_method_create_with_all_params(self, async_client: AsyncKnock) -> async def test_raw_response_create(self, async_client: AsyncKnock) -> None: response = await async_client.schedules.with_raw_response.create( recipients=["user_123"], - repeats=[ - { - "_typename": "ScheduleRepeat", - "frequency": "daily", - } - ], workflow="comment-created", ) @@ -346,12 +441,6 @@ async def test_raw_response_create(self, async_client: AsyncKnock) -> None: async def test_streaming_response_create(self, async_client: AsyncKnock) -> None: async with async_client.schedules.with_streaming_response.create( recipients=["user_123"], - repeats=[ - { - "_typename": "ScheduleRepeat", - "frequency": "daily", - } - ], workflow="comment-created", ) as response: assert not response.is_closed From 9ab5857509a9ec202a71f02d285287e2e2c411cb Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 4 Jun 2025 15:48:08 +0000 Subject: [PATCH 5/5] release: 1.5.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- src/knockapi/_version.py | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3e9af1b3..fbd9082d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.4.0" + ".": "1.5.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1142ce8c..86442113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 1.5.0 (2025-06-04) + +Full Changelog: [v1.4.0...v1.5.0](https://github.com/knocklabs/knock-python/compare/v1.4.0...v1.5.0) + +### Features + +* **api:** api update ([d1373fc](https://github.com/knocklabs/knock-python/commit/d1373fca14df49d70f39a976cfc06655d47e63f4)) +* **client:** add follow_redirects request option ([89019e2](https://github.com/knocklabs/knock-python/commit/89019e20bc3f965a662dfa8abc33b4608b5b2e12)) + + +### Chores + +* **docs:** remove reference to rye shell ([7a8cea9](https://github.com/knocklabs/knock-python/commit/7a8cea9054c4c620a621d79eb15ba0159deadd39)) +* **docs:** remove unnecessary param examples ([bd97370](https://github.com/knocklabs/knock-python/commit/bd973709e9044293a48639c3a9fb403f7448f697)) + ## 1.4.0 (2025-05-29) Full Changelog: [v1.3.0...v1.4.0](https://github.com/knocklabs/knock-python/compare/v1.3.0...v1.4.0) diff --git a/pyproject.toml b/pyproject.toml index 5f67ee84..6c307a7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "knockapi" -version = "1.4.0" +version = "1.5.0" description = "The official Python library for the knock API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/knockapi/_version.py b/src/knockapi/_version.py index 9946b73d..5d594ace 100644 --- a/src/knockapi/_version.py +++ b/src/knockapi/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "knockapi" -__version__ = "1.4.0" # x-release-please-version +__version__ = "1.5.0" # x-release-please-version