diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eae0d61..27b6866 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,10 +35,10 @@ jobs: - name: Run lints run: ./scripts/lint - upload: + build: if: github.repository == 'stainless-sdks/knock-python' && (github.event_name == 'push' || github.event.pull_request.head.repo.fork) timeout-minutes: 10 - name: upload + name: build permissions: contents: read id-token: write @@ -46,6 +46,20 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Install dependencies + run: rye sync --all-features + + - name: Run build + run: rye build + - name: Get GitHub OIDC Token id: github-oidc uses: actions/github-script@v6 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c3c9552..eb4e0db 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.9.0" + ".": "1.10.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index dbb9c52..f2f0049 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-fa68e7353b6d2eb51af35279f2f591a7d6168c5ade69c3cf40b5aae119ebcce1.yml -openapi_spec_hash: f9315c4f1d89624aa2447499dbfe734c -config_hash: b4c547c1d4c8cd0834bc793ddf5388ee +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/knock%2Fknock-592610bb4a05ea45115eba7544acd8efbab327749d0f78bd83e164aa305dc0a7.yml +openapi_spec_hash: 6a65b9127625d9479ba6bff2ba3f8d37 +config_hash: c835f0912492c3f1189f78f876c7c90c diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dd2d87..de62b44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 1.10.0 (2025-07-15) + +Full Changelog: [v1.9.0...v1.10.0](https://github.com/knocklabs/knock-python/compare/v1.9.0...v1.10.0) + +### Features + +* **api:** api update ([7673085](https://github.com/knocklabs/knock-python/commit/7673085795939d4ce7e23cf0f2926fa8c6a08978)) +* **api:** api update ([b9f028b](https://github.com/knocklabs/knock-python/commit/b9f028b69089460aa522b023a272182803b6143c)) +* clean up environment call outs ([fb2f0d5](https://github.com/knocklabs/knock-python/commit/fb2f0d522f82069c40d931fe26722fc52be70967)) + + +### Bug Fixes + +* **client:** don't send Content-Type header on GET requests ([64799d2](https://github.com/knocklabs/knock-python/commit/64799d2f0f8288c23e86ed7df45eeb211866ac0b)) +* **parsing:** correctly handle nested discriminated unions ([e8e1932](https://github.com/knocklabs/knock-python/commit/e8e1932417093bf612a0705cc7fada4a980f3a39)) + + +### Chores + +* **ci:** change upload type ([e71a7c5](https://github.com/knocklabs/knock-python/commit/e71a7c512b98a9910e1596462504105cf3e9d58d)) +* **internal:** bump pinned h11 dep ([f2b5336](https://github.com/knocklabs/knock-python/commit/f2b53364b5dd9cfcc15bea2204dc42bc6e49d0f6)) +* **package:** mark python 3.13 as supported ([ee5b244](https://github.com/knocklabs/knock-python/commit/ee5b244a23c584a6ccd61e85c56bb9e3e85dcb8d)) +* **readme:** fix version rendering on pypi ([a2fae26](https://github.com/knocklabs/knock-python/commit/a2fae26e5dfa3c003f3f1cf2b45bf55fe47db724)) + ## 1.9.0 (2025-06-30) Full Changelog: [v1.8.1...v1.9.0](https://github.com/knocklabs/knock-python/compare/v1.8.1...v1.9.0) diff --git a/README.md b/README.md index bd20f9a..432a104 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Knock Python API library -[![PyPI version]()](https://pypi.org/project/knockapi/) + +[![PyPI version](https://img.shields.io/pypi/v/knockapi.svg?label=pypi%20(stable))](https://pypi.org/project/knockapi/) The Knock Python library provides convenient access to the Knock REST API from any Python 3.8+ application. The library includes type definitions for all request params and response fields, @@ -86,7 +87,6 @@ pip install knockapi[aiohttp] Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: ```python -import os import asyncio from knockapi import DefaultAioHttpClient from knockapi import AsyncKnock @@ -94,7 +94,7 @@ from knockapi import AsyncKnock async def main() -> None: async with AsyncKnock( - api_key=os.environ.get("KNOCK_API_KEY"), # This is the default and can be omitted + api_key="My API Key", http_client=DefaultAioHttpClient(), ) as client: response = await client.workflows.trigger( diff --git a/pyproject.toml b/pyproject.toml index e6f0854..d933e7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "knockapi" -version = "1.9.0" +version = "1.10.0" description = "The official Python library for the knock API" dynamic = ["readme"] license = "Apache-2.0" @@ -24,6 +24,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: MacOS", @@ -38,7 +39,7 @@ Homepage = "https://github.com/knocklabs/knock-python" Repository = "https://github.com/knocklabs/knock-python" [project.optional-dependencies] -aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.6"] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"] [tool.rye] managed = true diff --git a/requirements-dev.lock b/requirements-dev.lock index cec598e..ed67aae 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -48,15 +48,15 @@ filelock==3.12.4 frozenlist==1.6.2 # via aiohttp # via aiosignal -h11==0.14.0 +h11==0.16.0 # via httpcore -httpcore==1.0.2 +httpcore==1.0.9 # via httpx httpx==0.28.1 # via httpx-aiohttp # via knockapi # via respx -httpx-aiohttp==0.1.6 +httpx-aiohttp==0.1.8 # via knockapi idna==3.4 # via anyio diff --git a/requirements.lock b/requirements.lock index 3385b79..e36853c 100644 --- a/requirements.lock +++ b/requirements.lock @@ -36,14 +36,14 @@ exceptiongroup==1.2.2 frozenlist==1.6.2 # via aiohttp # via aiosignal -h11==0.14.0 +h11==0.16.0 # via httpcore -httpcore==1.0.2 +httpcore==1.0.9 # via httpx httpx==0.28.1 # via httpx-aiohttp # via knockapi -httpx-aiohttp==0.1.6 +httpx-aiohttp==0.1.8 # via knockapi idna==3.4 # via anyio diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh index 597153b..9dbd17e 100755 --- a/scripts/utils/upload-artifact.sh +++ b/scripts/utils/upload-artifact.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash set -exuo pipefail -RESPONSE=$(curl -X POST "$URL" \ +FILENAME=$(basename dist/*.whl) + +RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ -H "Authorization: Bearer $AUTH" \ -H "Content-Type: application/json") @@ -12,13 +14,13 @@ if [[ "$SIGNED_URL" == "null" ]]; then exit 1 fi -UPLOAD_RESPONSE=$(tar -cz . | curl -v -X PUT \ - -H "Content-Type: application/gzip" \ - --data-binary @- "$SIGNED_URL" 2>&1) +UPLOAD_RESPONSE=$(curl -v -X PUT \ + -H "Content-Type: binary/octet-stream" \ + --data-binary "@dist/$FILENAME" "$SIGNED_URL" 2>&1) if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then echo -e "\033[32mUploaded build to Stainless storage.\033[0m" - echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/knock-python/$SHA'\033[0m" + echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/knock-python/$SHA/$FILENAME'\033[0m" else echo -e "\033[31mFailed to upload artifact.\033[0m" exit 1 diff --git a/src/knockapi/_base_client.py b/src/knockapi/_base_client.py index b0599a6..f15433e 100644 --- a/src/knockapi/_base_client.py +++ b/src/knockapi/_base_client.py @@ -529,6 +529,15 @@ def _build_request( # work around https://github.com/encode/httpx/discussions/2880 kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} + is_body_allowed = options.method.lower() != "get" + + if is_body_allowed: + kwargs["json"] = json_data if is_given(json_data) else None + kwargs["files"] = files + else: + headers.pop("Content-Type", None) + kwargs.pop("data", None) + # TODO: report this error to httpx return self._client.build_request( # pyright: ignore[reportUnknownMemberType] headers=headers, @@ -540,8 +549,6 @@ def _build_request( # so that passing a `TypedDict` doesn't cause an error. # https://github.com/microsoft/pyright/issues/3526#event-6715453066 params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None, - json=json_data if is_given(json_data) else None, - files=files, **kwargs, ) diff --git a/src/knockapi/_models.py b/src/knockapi/_models.py index 4f21498..528d568 100644 --- a/src/knockapi/_models.py +++ b/src/knockapi/_models.py @@ -2,9 +2,10 @@ import os import inspect -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, cast +from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast from datetime import date, datetime from typing_extensions import ( + List, Unpack, Literal, ClassVar, @@ -366,7 +367,7 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object: if type_ is None: raise RuntimeError(f"Unexpected field type is None for {key}") - return construct_type(value=value, type_=type_) + return construct_type(value=value, type_=type_, metadata=getattr(field, "metadata", None)) def is_basemodel(type_: type) -> bool: @@ -420,7 +421,7 @@ def construct_type_unchecked(*, value: object, type_: type[_T]) -> _T: return cast(_T, construct_type(value=value, type_=type_)) -def construct_type(*, value: object, type_: object) -> object: +def construct_type(*, value: object, type_: object, metadata: Optional[List[Any]] = None) -> object: """Loose coercion to the expected type with construction of nested values. If the given value does not match the expected type then it is returned as-is. @@ -438,8 +439,10 @@ def construct_type(*, value: object, type_: object) -> object: type_ = type_.__value__ # type: ignore[unreachable] # unwrap `Annotated[T, ...]` -> `T` - if is_annotated_type(type_): - meta: tuple[Any, ...] = get_args(type_)[1:] + if metadata is not None: + meta: tuple[Any, ...] = tuple(metadata) + elif is_annotated_type(type_): + meta = get_args(type_)[1:] type_ = extract_type_arg(type_, 0) else: meta = tuple() diff --git a/src/knockapi/_version.py b/src/knockapi/_version.py index 887fa8d..d6ce324 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.9.0" # x-release-please-version +__version__ = "1.10.0" # x-release-please-version diff --git a/src/knockapi/resources/schedules/schedules.py b/src/knockapi/resources/schedules/schedules.py index fb031f4..bf836a7 100644 --- a/src/knockapi/resources/schedules/schedules.py +++ b/src/knockapi/resources/schedules/schedules.py @@ -103,10 +103,10 @@ def create( (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 - size of the full `data` payload. + data: An optional map of data to pass into the workflow execution. There is a 10MB + limit on the size of the full `data` payload. Any individual string value + greater than 1024 bytes in length will be + [truncated](/developer-tools/api-logs#log-truncation) in your logs. ending_at: The ending date and time for the schedule. @@ -175,10 +175,10 @@ def update( actor: A reference to a recipient, either a user identifier (string) or an object reference (ID, collection). - 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 - size of the full `data` payload. + data: An optional map of data to pass into the workflow execution. There is a 10MB + limit on the size of the full `data` payload. Any individual string value + greater than 1024 bytes in length will be + [truncated](/developer-tools/api-logs#log-truncation) in your logs. ending_at: The ending date and time for the schedule. @@ -374,10 +374,10 @@ async def create( (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 - size of the full `data` payload. + data: An optional map of data to pass into the workflow execution. There is a 10MB + limit on the size of the full `data` payload. Any individual string value + greater than 1024 bytes in length will be + [truncated](/developer-tools/api-logs#log-truncation) in your logs. ending_at: The ending date and time for the schedule. @@ -446,10 +446,10 @@ async def update( actor: A reference to a recipient, either a user identifier (string) or an object reference (ID, collection). - 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 - size of the full `data` payload. + data: An optional map of data to pass into the workflow execution. There is a 10MB + limit on the size of the full `data` payload. Any individual string value + greater than 1024 bytes in length will be + [truncated](/developer-tools/api-logs#log-truncation) in your logs. ending_at: The ending date and time for the schedule. diff --git a/src/knockapi/resources/workflows.py b/src/knockapi/resources/workflows.py index c7d7ffe..e23b0f0 100644 --- a/src/knockapi/resources/workflows.py +++ b/src/knockapi/resources/workflows.py @@ -136,10 +136,10 @@ def trigger( subsequent cancellation. Should be unique across trigger requests to avoid unintentional cancellations. - 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 - size of the full `data` payload. + data: An optional map of data to pass into the workflow execution. There is a 10MB + limit on the size of the full `data` payload. Any individual string value + greater than 1024 bytes in length will be + [truncated](/developer-tools/api-logs#log-truncation) in your logs. tenant: An request to set a tenant inline. @@ -282,10 +282,10 @@ async def trigger( subsequent cancellation. Should be unique across trigger requests to avoid unintentional cancellations. - 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 - size of the full `data` payload. + data: An optional map of data to pass into the workflow execution. There is a 10MB + limit on the size of the full `data` payload. Any individual string value + greater than 1024 bytes in length will be + [truncated](/developer-tools/api-logs#log-truncation) in your logs. tenant: An request to set a tenant inline. diff --git a/src/knockapi/types/providers/slack_list_channels_params.py b/src/knockapi/types/providers/slack_list_channels_params.py index c98c919..0e9ffb4 100644 --- a/src/knockapi/types/providers/slack_list_channels_params.py +++ b/src/knockapi/types/providers/slack_list_channels_params.py @@ -38,5 +38,6 @@ class QueryOptions(TypedDict, total=False): """ Mix and match channel types by providing a comma-separated list of any combination of public_channel, private_channel, mpim, im. Defaults to - `"public_channel,private_channel"`. + `"public_channel,private_channel"`. If the user's Slack ID is unavailable, this + option is ignored and only public channels are returned. """ diff --git a/src/knockapi/types/schedule.py b/src/knockapi/types/schedule.py index 68db5b0..56fd93e 100644 --- a/src/knockapi/types/schedule.py +++ b/src/knockapi/types/schedule.py @@ -40,9 +40,9 @@ class Schedule(BaseModel): data: Optional[Dict[str, object]] = None """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 size of the full `data` payload. + There is a 10MB limit on the size of the full `data` payload. Any individual + string value greater than 1024 bytes in length will be + [truncated](/developer-tools/api-logs#log-truncation) in your logs. """ last_occurrence_at: Optional[datetime] = None diff --git a/src/knockapi/types/schedule_create_params.py b/src/knockapi/types/schedule_create_params.py index b7da2a4..b9956fa 100644 --- a/src/knockapi/types/schedule_create_params.py +++ b/src/knockapi/types/schedule_create_params.py @@ -32,9 +32,9 @@ class ScheduleCreateParams(TypedDict, total=False): data: Optional[Dict[str, object]] """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 size of the full `data` payload. + There is a 10MB limit on the size of the full `data` payload. Any individual + string value greater than 1024 bytes in length will be + [truncated](/developer-tools/api-logs#log-truncation) in your logs. """ ending_at: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] diff --git a/src/knockapi/types/schedule_update_params.py b/src/knockapi/types/schedule_update_params.py index 8af465b..8863edd 100644 --- a/src/knockapi/types/schedule_update_params.py +++ b/src/knockapi/types/schedule_update_params.py @@ -27,9 +27,9 @@ class ScheduleUpdateParams(TypedDict, total=False): data: Optional[Dict[str, object]] """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 size of the full `data` payload. + There is a 10MB limit on the size of the full `data` payload. Any individual + string value greater than 1024 bytes in length will be + [truncated](/developer-tools/api-logs#log-truncation) in your logs. """ ending_at: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] diff --git a/src/knockapi/types/schedules/bulk_create_params.py b/src/knockapi/types/schedules/bulk_create_params.py index 090a693..6b59e5c 100644 --- a/src/knockapi/types/schedules/bulk_create_params.py +++ b/src/knockapi/types/schedules/bulk_create_params.py @@ -34,9 +34,9 @@ class Schedule(TypedDict, total=False): data: Optional[Dict[str, object]] """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 size of the full `data` payload. + There is a 10MB limit on the size of the full `data` payload. Any individual + string value greater than 1024 bytes in length will be + [truncated](/developer-tools/api-logs#log-truncation) in your logs. """ ending_at: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] diff --git a/src/knockapi/types/workflow_trigger_params.py b/src/knockapi/types/workflow_trigger_params.py index 300e75c..9f73210 100644 --- a/src/knockapi/types/workflow_trigger_params.py +++ b/src/knockapi/types/workflow_trigger_params.py @@ -39,9 +39,9 @@ class WorkflowTriggerParams(TypedDict, total=False): data: Optional[Dict[str, object]] """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 size of the full `data` payload. + There is a 10MB limit on the size of the full `data` payload. Any individual + string value greater than 1024 bytes in length will be + [truncated](/developer-tools/api-logs#log-truncation) in your logs. """ tenant: Optional[InlineTenantRequestParam] diff --git a/tests/test_client.py b/tests/test_client.py index 1eb01bb..380fc02 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -462,7 +462,7 @@ def test_request_extra_query(self) -> None: def test_multipart_repeating_array(self, client: Knock) -> None: request = client._build_request( FinalRequestOptions.construct( - method="get", + method="post", url="/foo", headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, json_data={"array": ["foo", "bar"]}, @@ -1261,7 +1261,7 @@ def test_request_extra_query(self) -> None: def test_multipart_repeating_array(self, async_client: AsyncKnock) -> None: request = async_client._build_request( FinalRequestOptions.construct( - method="get", + method="post", url="/foo", headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, json_data={"array": ["foo", "bar"]}, diff --git a/tests/test_models.py b/tests/test_models.py index 8a84231..86f6091 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -889,3 +889,48 @@ class ModelB(BaseModel): ) assert isinstance(m, ModelB) + + +def test_nested_discriminated_union() -> None: + class InnerType1(BaseModel): + type: Literal["type_1"] + + class InnerModel(BaseModel): + inner_value: str + + class InnerType2(BaseModel): + type: Literal["type_2"] + some_inner_model: InnerModel + + class Type1(BaseModel): + base_type: Literal["base_type_1"] + value: Annotated[ + Union[ + InnerType1, + InnerType2, + ], + PropertyInfo(discriminator="type"), + ] + + class Type2(BaseModel): + base_type: Literal["base_type_2"] + + T = Annotated[ + Union[ + Type1, + Type2, + ], + PropertyInfo(discriminator="base_type"), + ] + + model = construct_type( + type_=T, + value={ + "base_type": "base_type_1", + "value": { + "type": "type_2", + }, + }, + ) + assert isinstance(model, Type1) + assert isinstance(model.value, InnerType2)