diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43283d95..80834efa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/knock-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rye run: | @@ -46,7 +46,7 @@ jobs: id-token: write runs-on: ${{ github.repository == 'stainless-sdks/knock-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rye run: | @@ -67,7 +67,7 @@ jobs: github.repository == 'stainless-sdks/knock-python' && !startsWith(github.ref, 'refs/heads/stl/') id: github-oidc - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: core.setOutput('github_token', await core.getIDToken()); @@ -87,7 +87,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/knock-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rye run: | diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 02565d35..3b46aceb 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rye run: | diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index 7c384aa7..0ae2ffdb 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'knocklabs/knock-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check release environment run: | diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0c0c0c35..f3dbfd2a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.25.0" + ".": "1.26.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 86d162ea..2a11252d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 94 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/knock%2Fknock-3e8f3a4664d48b3d546339018b451a356f8e20c223a2d21e7c3824fad4cddc7b.yml -openapi_spec_hash: c2b6637451a63e39c1f1042c6a7cc7f7 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/knock/knock-b42896ed120111c98cea7e588b382fdce0ff587b92a779e1eff1e585c3f77039.yml +openapi_spec_hash: 7b326a46e026aabfb75a66d7dfd3010b config_hash: 625db64572b7ee0ee1dd00546e53fc5f diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3d5eae..ffb6804f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # Changelog +## 1.26.0 (2026-06-29) + +Full Changelog: [v1.25.0...v1.26.0](https://github.com/knocklabs/knock-python/compare/v1.25.0...v1.26.0) + +### Features + +* **api:** api update ([c4fc2df](https://github.com/knocklabs/knock-python/commit/c4fc2df8327f44f10823ba20f361d42e1c98f5fd)) +* **api:** api update ([e81b283](https://github.com/knocklabs/knock-python/commit/e81b283f0a2ece6369dc5b1ba0bd6e6410cb433d)) +* **api:** api update ([63378d1](https://github.com/knocklabs/knock-python/commit/63378d10a51e7f7dceeab05d7dd98015f143852f)) +* **api:** api update ([ca1f597](https://github.com/knocklabs/knock-python/commit/ca1f59724d1643bf5f863d7b030c05ddddc0dd8f)) +* **api:** api update ([ac2b316](https://github.com/knocklabs/knock-python/commit/ac2b316b3b0436305d61e147d438b21189c7603f)) +* **api:** api update ([98cab8e](https://github.com/knocklabs/knock-python/commit/98cab8e55b0c27929c8ed10655aefedb8d56ccca)) +* **api:** api update ([ea6a7b2](https://github.com/knocklabs/knock-python/commit/ea6a7b20b8b6945407270414c377e0d0cacbe549)) +* **api:** api update ([c4e760e](https://github.com/knocklabs/knock-python/commit/c4e760e67d085de8b0e468a2e98775f4ab93bd66)) +* **api:** api update ([43412da](https://github.com/knocklabs/knock-python/commit/43412da962d2521710dd41058bd4c5074d17e3d1)) +* **api:** api update ([edbcb15](https://github.com/knocklabs/knock-python/commit/edbcb1532aa9d87d505c0ebc5980da3d04fbb0dd)) +* **api:** api update ([8f3a9d1](https://github.com/knocklabs/knock-python/commit/8f3a9d18b69e5c5fd84bec640a634c3f7ac4f823)) +* **api:** api update ([75bdef9](https://github.com/knocklabs/knock-python/commit/75bdef9bb327961a6ca2e5f77339b49deaaa0c1d)) +* **api:** api update ([84f04fa](https://github.com/knocklabs/knock-python/commit/84f04fa741708c52e808d0a9d16ecef51e8b9278)) +* **api:** api update ([385770f](https://github.com/knocklabs/knock-python/commit/385770f1fa876f3214c4d81994c13b7b2ba3c757)) +* **api:** api update ([0759719](https://github.com/knocklabs/knock-python/commit/0759719e28f705d6d77d25a1cd34463db0e5e43b)) +* **api:** api update ([f53e71f](https://github.com/knocklabs/knock-python/commit/f53e71fc4c49e6a45666986f809404870325e453)) +* **api:** api update ([da1eebd](https://github.com/knocklabs/knock-python/commit/da1eebdadd17a6e62b69bd0187d8098af18ba02f)) +* **api:** api update ([cc4aff9](https://github.com/knocklabs/knock-python/commit/cc4aff98cc1b0213a1215c7751da5383f17ef533)) +* **api:** api update ([d01a71b](https://github.com/knocklabs/knock-python/commit/d01a71b3d70e2d41c7699ebf46c71c805aa04a36)) +* **api:** api update ([ff11bb4](https://github.com/knocklabs/knock-python/commit/ff11bb4724ba4cbb1d7da0e962c16dfa6202cdc2)) +* **api:** api update ([45246a0](https://github.com/knocklabs/knock-python/commit/45246a08de7846278ecf8c628e983f323135030e)) +* **api:** api update ([f056999](https://github.com/knocklabs/knock-python/commit/f056999c633a20387c7c3ca0d03e6896398bf354)) +* **api:** api update ([d587784](https://github.com/knocklabs/knock-python/commit/d58778468a31d3f085c5b75eeca367d3f69c1694)) +* **api:** api update ([72ae353](https://github.com/knocklabs/knock-python/commit/72ae353d7114cf54164169db4623aeb0964485c1)) +* **api:** api update ([08f1af8](https://github.com/knocklabs/knock-python/commit/08f1af8a783072188da2bf1301fa3fad2b599271)) +* **api:** api update ([6b7bf04](https://github.com/knocklabs/knock-python/commit/6b7bf04ce3367a43fd7190d739ac07ac0e4540db)) +* **api:** api update ([4ade4a7](https://github.com/knocklabs/knock-python/commit/4ade4a73e6121f40677e6e497e7979ef3769ab6d)) +* **api:** api update ([39e6add](https://github.com/knocklabs/knock-python/commit/39e6add4ed39335399c26b1c87968bf68709330f)) +* **api:** api update ([0c514bf](https://github.com/knocklabs/knock-python/commit/0c514bf839ee8134227869b00cf52fb07deeeae6)) +* **api:** api update ([c3f2f4a](https://github.com/knocklabs/knock-python/commit/c3f2f4a2e93139c627d3745ea4eb7f5142f3153b)) +* **internal/types:** support eagerly validating pydantic iterators ([b128ac3](https://github.com/knocklabs/knock-python/commit/b128ac31c39b01c758eabda4003373c513f59138)) +* support setting headers via env ([e22462b](https://github.com/knocklabs/knock-python/commit/e22462bd64af8c7ddaa8e84c76aca28e411d9734)) + + +### Bug Fixes + +* **client:** add missing f-string prefix in file type error message ([04b3154](https://github.com/knocklabs/knock-python/commit/04b3154865a445887eb791b9c94d2c3586eadf3a)) +* **types:** avoid type-checker errors on params with additional properties ([5f69bb9](https://github.com/knocklabs/knock-python/commit/5f69bb911914f0ca3687ce5fe5ebf4a2d0aae2d6)) +* use correct field name format for multipart file arrays ([4d33037](https://github.com/knocklabs/knock-python/commit/4d33037881f97e2b3e99892b31af059ae22c4bb8)) + + +### Chores + +* **internal:** reformat pyproject.toml ([8e64806](https://github.com/knocklabs/knock-python/commit/8e6480650f67a83f923f45485ff2f0d79e719d0b)) + ## 1.25.0 (2026-04-23) Full Changelog: [v1.24.1...v1.25.0](https://github.com/knocklabs/knock-python/compare/v1.24.1...v1.25.0) diff --git a/README.md b/README.md index 169ec8e7..1ab5f9b2 100644 --- a/README.md +++ b/README.md @@ -190,11 +190,15 @@ from knockapi import Knock client = Knock() -page = client.users.list_messages( - user_id="user-123", - inserted_at={}, +response = client.workflows.trigger( + key="key", + recipients=["dr_grant", "dr_sattler", "dr_malcolm"], + settings={ + "sandbox_mode": True, + "skip_delay": True, + }, ) -print(page.items) +print(response.settings) ``` ## Handling errors diff --git a/pyproject.toml b/pyproject.toml index e23661b5..d1819d13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "knockapi" -version = "1.25.0" +version = "1.26.0" description = "The official Python library for the knock API" dynamic = ["readme"] license = "Apache-2.0" @@ -168,7 +168,7 @@ show_error_codes = true # # We also exclude our `tests` as mypy doesn't always infer # types correctly and Pyright will still catch any type errors. -exclude = ['src/knockapi/_files.py', '_dev/.*.py', 'tests/.*'] +exclude = ["src/knockapi/_files.py", "_dev/.*.py", "tests/.*"] strict_equality = true implicit_reexport = true diff --git a/src/knockapi/_client.py b/src/knockapi/_client.py index 6f3e90be..4549136f 100644 --- a/src/knockapi/_client.py +++ b/src/knockapi/_client.py @@ -19,7 +19,11 @@ RequestOptions, not_given, ) -from ._utils import is_given, get_async_library +from ._utils import ( + is_given, + is_mapping_t, + get_async_library, +) from ._compat import cached_property from ._version import __version__ from ._streaming import Stream as Stream, AsyncStream as AsyncStream @@ -113,6 +117,15 @@ def __init__( if base_url is None: base_url = f"https://api.knock.app" + custom_headers_env = os.environ.get("KNOCK_CUSTOM_HEADERS") + if custom_headers_env is not None: + parsed: dict[str, str] = {} + for line in custom_headers_env.split("\n"): + colon = line.find(":") + if colon >= 0: + parsed[line[:colon].strip()] = line[colon + 1 :].strip() + default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + super().__init__( version=__version__, base_url=base_url, @@ -388,6 +401,15 @@ def __init__( if base_url is None: base_url = f"https://api.knock.app" + custom_headers_env = os.environ.get("KNOCK_CUSTOM_HEADERS") + if custom_headers_env is not None: + parsed: dict[str, str] = {} + for line in custom_headers_env.split("\n"): + colon = line.find(":") + if colon >= 0: + parsed[line[:colon].strip()] = line[colon + 1 :].strip() + default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + super().__init__( version=__version__, base_url=base_url, diff --git a/src/knockapi/_files.py b/src/knockapi/_files.py index 0fdce17b..76da9e08 100644 --- a/src/knockapi/_files.py +++ b/src/knockapi/_files.py @@ -99,7 +99,7 @@ async def async_to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles elif is_sequence_t(files): files = [(key, await _async_transform_file(file)) for key, file in files] else: - raise TypeError("Unexpected file type input {type(files)}, expected mapping or sequence") + raise TypeError(f"Unexpected file type input {type(files)}, expected mapping or sequence") return files diff --git a/src/knockapi/_models.py b/src/knockapi/_models.py index 29070e05..8c5ab260 100644 --- a/src/knockapi/_models.py +++ b/src/knockapi/_models.py @@ -25,7 +25,9 @@ ClassVar, Protocol, Required, + Annotated, ParamSpec, + TypeAlias, TypedDict, TypeGuard, final, @@ -79,7 +81,15 @@ from ._constants import RAW_RESPONSE_HEADER if TYPE_CHECKING: + from pydantic import GetCoreSchemaHandler, ValidatorFunctionWrapHandler + from pydantic_core import CoreSchema, core_schema from pydantic_core.core_schema import ModelField, ModelSchema, LiteralSchema, ModelFieldsSchema +else: + try: + from pydantic_core import CoreSchema, core_schema + except ImportError: + CoreSchema = None + core_schema = None __all__ = ["BaseModel", "GenericModel"] @@ -396,6 +406,76 @@ def model_dump_json( ) +class _EagerIterable(list[_T], Generic[_T]): + """ + Accepts any Iterable[T] input (including generators), consumes it + eagerly, and validates all items upfront. + + Validation preserves the original container type where possible + (e.g. a set[T] stays a set[T]). Serialization (model_dump / JSON) + always emits a list — round-tripping through model_dump() will not + restore the original container type. + """ + + @classmethod + def __get_pydantic_core_schema__( + cls, + source_type: Any, + handler: GetCoreSchemaHandler, + ) -> CoreSchema: + (item_type,) = get_args(source_type) or (Any,) + item_schema: CoreSchema = handler.generate_schema(item_type) + list_of_items_schema: CoreSchema = core_schema.list_schema(item_schema) + + return core_schema.no_info_wrap_validator_function( + cls._validate, + list_of_items_schema, + serialization=core_schema.plain_serializer_function_ser_schema( + cls._serialize, + info_arg=False, + ), + ) + + @staticmethod + def _validate(v: Iterable[_T], handler: "ValidatorFunctionWrapHandler") -> Any: + original_type: type[Any] = type(v) + + # Normalize to list so list_schema can validate each item + if isinstance(v, list): + items: list[_T] = v + else: + try: + items = list(v) + except TypeError as e: + raise TypeError("Value is not iterable") from e + + # Validate items against the inner schema + validated: list[_T] = handler(items) + + # Reconstruct original container type + if original_type is list: + return validated + # str(list) produces the list's repr, not a string built from items, + # so skip reconstruction for str and its subclasses. + if issubclass(original_type, str): + return validated + try: + return original_type(validated) + except (TypeError, ValueError): + # If the type cannot be reconstructed, just return the validated list + return validated + + @staticmethod + def _serialize(v: Iterable[_T]) -> list[_T]: + """Always serialize as a list so Pydantic's JSON encoder is happy.""" + if isinstance(v, list): + return v + return list(v) + + +EagerIterable: TypeAlias = Annotated[Iterable[_T], _EagerIterable] + + def _construct_field(value: object, field: FieldInfo, key: str) -> object: if value is None: return field_get_default(field) diff --git a/src/knockapi/_qs.py b/src/knockapi/_qs.py index de8c99bc..4127c19c 100644 --- a/src/knockapi/_qs.py +++ b/src/knockapi/_qs.py @@ -2,17 +2,13 @@ from typing import Any, List, Tuple, Union, Mapping, TypeVar from urllib.parse import parse_qs, urlencode -from typing_extensions import Literal, get_args +from typing_extensions import get_args -from ._types import NotGiven, not_given +from ._types import NotGiven, ArrayFormat, NestedFormat, not_given from ._utils import flatten _T = TypeVar("_T") - -ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] -NestedFormat = Literal["dots", "brackets"] - PrimitiveData = Union[str, int, float, bool, None] # this should be Data = Union[PrimitiveData, "List[Data]", "Tuple[Data]", "Mapping[str, Data]"] # https://github.com/microsoft/pyright/issues/3555 diff --git a/src/knockapi/_types.py b/src/knockapi/_types.py index 8059ac49..6e24b331 100644 --- a/src/knockapi/_types.py +++ b/src/knockapi/_types.py @@ -47,6 +47,9 @@ ModelT = TypeVar("ModelT", bound=pydantic.BaseModel) _T = TypeVar("_T") +ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] +NestedFormat = Literal["dots", "brackets"] + # Approximates httpx internal ProxiesTypes and RequestFiles types # while adding support for `PathLike` instances diff --git a/src/knockapi/_utils/_utils.py b/src/knockapi/_utils/_utils.py index 771859f5..199cd231 100644 --- a/src/knockapi/_utils/_utils.py +++ b/src/knockapi/_utils/_utils.py @@ -17,11 +17,11 @@ ) from pathlib import Path from datetime import date, datetime -from typing_extensions import TypeGuard +from typing_extensions import TypeGuard, get_args import sniffio -from .._types import Omit, NotGiven, FileTypes, HeadersLike +from .._types import Omit, NotGiven, FileTypes, ArrayFormat, HeadersLike _T = TypeVar("_T") _TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) @@ -40,25 +40,45 @@ def extract_files( query: Mapping[str, object], *, paths: Sequence[Sequence[str]], + array_format: ArrayFormat = "brackets", ) -> list[tuple[str, FileTypes]]: """Recursively extract files from the given dictionary based on specified paths. A path may look like this ['foo', 'files', '', 'data']. + ``array_format`` controls how ```` segments contribute to the emitted + field name. Supported values: ``"brackets"`` (``foo[]``), ``"repeat"`` and + ``"comma"`` (``foo``), ``"indices"`` (``foo[0]``, ``foo[1]``). + Note: this mutates the given dictionary. """ files: list[tuple[str, FileTypes]] = [] for path in paths: - files.extend(_extract_items(query, path, index=0, flattened_key=None)) + files.extend(_extract_items(query, path, index=0, flattened_key=None, array_format=array_format)) return files +def _array_suffix(array_format: ArrayFormat, array_index: int) -> str: + if array_format == "brackets": + return "[]" + if array_format == "indices": + return f"[{array_index}]" + if array_format == "repeat" or array_format == "comma": + # Both repeat the bare field name for each file part; there is no + # meaningful way to comma-join binary parts. + return "" + raise NotImplementedError( + f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}" + ) + + def _extract_items( obj: object, path: Sequence[str], *, index: int, flattened_key: str | None, + array_format: ArrayFormat, ) -> list[tuple[str, FileTypes]]: try: key = path[index] @@ -75,9 +95,11 @@ def _extract_items( if is_list(obj): files: list[tuple[str, FileTypes]] = [] - for entry in obj: - assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "") - files.append((flattened_key + "[]", cast(FileTypes, entry))) + for array_index, entry in enumerate(obj): + suffix = _array_suffix(array_format, array_index) + emitted_key = (flattened_key + suffix) if flattened_key else suffix + assert_is_file_content(entry, key=emitted_key) + files.append((emitted_key, cast(FileTypes, entry))) return files assert_is_file_content(obj, key=flattened_key) @@ -106,6 +128,7 @@ def _extract_items( path, index=index, flattened_key=flattened_key, + array_format=array_format, ) elif is_list(obj): if key != "": @@ -117,9 +140,12 @@ def _extract_items( item, path, index=index, - flattened_key=flattened_key + "[]" if flattened_key is not None else "[]", + flattened_key=( + (flattened_key if flattened_key is not None else "") + _array_suffix(array_format, array_index) + ), + array_format=array_format, ) - for item in obj + for array_index, item in enumerate(obj) ] ) diff --git a/src/knockapi/_version.py b/src/knockapi/_version.py index 083c80ae..e1ed9720 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.25.0" # x-release-please-version +__version__ = "1.26.0" # x-release-please-version diff --git a/src/knockapi/resources/users/bulk.py b/src/knockapi/resources/users/bulk.py index 48e4d621..21212aed 100644 --- a/src/knockapi/resources/users/bulk.py +++ b/src/knockapi/resources/users/bulk.py @@ -20,7 +20,6 @@ from ..._base_client import make_request_options from ...types.bulk_operation import BulkOperation from ...types.inline_identify_user_request_param import InlineIdentifyUserRequestParam -from ...types.recipients.preference_set_request_param import PreferenceSetRequestParam __all__ = ["BulkResource", "AsyncBulkResource"] @@ -137,7 +136,7 @@ def identify( def set_preferences( self, *, - preferences: PreferenceSetRequestParam, + preferences: bulk_set_preferences_params.Preferences, user_ids: SequenceNotStr[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. @@ -156,7 +155,8 @@ def set_preferences( the preferences sent. Args: - preferences: A request to set a preference set for a recipient. + preferences: A preference set to apply in a bulk operation. Always replaces existing + preferences for the specified set. user_ids: A list of user IDs. @@ -302,7 +302,7 @@ async def identify( async def set_preferences( self, *, - preferences: PreferenceSetRequestParam, + preferences: bulk_set_preferences_params.Preferences, user_ids: SequenceNotStr[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. @@ -321,7 +321,8 @@ async def set_preferences( the preferences sent. Args: - preferences: A request to set a preference set for a recipient. + preferences: A preference set to apply in a bulk operation. Always replaces existing + preferences for the specified set. user_ids: A list of user IDs. diff --git a/src/knockapi/resources/workflows.py b/src/knockapi/resources/workflows.py index 6080eb85..232e6c9c 100644 --- a/src/knockapi/resources/workflows.py +++ b/src/knockapi/resources/workflows.py @@ -70,11 +70,11 @@ def cancel( pair. Can optionally be provided one or more recipients to scope the request to. Args: - cancellation_key: An optional key that is used to reference a specific workflow trigger request - when issuing a [workflow cancellation](/send-notifications/canceling-workflows) - request. Must be provided while triggering a workflow in order to enable - subsequent cancellation. Should be unique across trigger requests to avoid - unintentional cancellations. + cancellation_key: A key that is used to reference a specific workflow trigger request when issuing + a [workflow cancellation](/send-notifications/canceling-workflows) request. Must + be provided while triggering a workflow in order to enable subsequent + cancellation. Should be unique across trigger requests to avoid unintentional + cancellations. recipients: A list of recipients to cancel the notification for. If omitted, cancels for all recipients associated with the cancellation key. @@ -119,6 +119,7 @@ def trigger( actor: Optional[RecipientRequestParam] | Omit = omit, cancellation_key: Optional[str] | Omit = omit, data: Optional[Dict[str, object]] | Omit = omit, + settings: Optional[workflow_trigger_params.Settings] | Omit = omit, tenant: Optional[InlineTenantRequestParam] | 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. @@ -143,17 +144,19 @@ def trigger( (string), an inline user request (object), or an inline object request, which is determined by the presence of a `collection` property. - cancellation_key: An optional key that is used to reference a specific workflow trigger request - when issuing a [workflow cancellation](/send-notifications/canceling-workflows) - request. Must be provided while triggering a workflow in order to enable - subsequent cancellation. Should be unique across trigger requests to avoid - unintentional cancellations. + cancellation_key: A key that is used to reference a specific workflow trigger request when issuing + a [workflow cancellation](/send-notifications/canceling-workflows) request. Must + be provided while triggering a workflow in order to enable 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 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. + settings: Optional settings that control how this workflow trigger is executed. + tenant: An request to set a tenant inline. extra_headers: Send extra headers @@ -176,6 +179,7 @@ def trigger( "actor": actor, "cancellation_key": cancellation_key, "data": data, + "settings": settings, "tenant": tenant, }, workflow_trigger_params.WorkflowTriggerParams, @@ -235,11 +239,11 @@ async def cancel( pair. Can optionally be provided one or more recipients to scope the request to. Args: - cancellation_key: An optional key that is used to reference a specific workflow trigger request - when issuing a [workflow cancellation](/send-notifications/canceling-workflows) - request. Must be provided while triggering a workflow in order to enable - subsequent cancellation. Should be unique across trigger requests to avoid - unintentional cancellations. + cancellation_key: A key that is used to reference a specific workflow trigger request when issuing + a [workflow cancellation](/send-notifications/canceling-workflows) request. Must + be provided while triggering a workflow in order to enable subsequent + cancellation. Should be unique across trigger requests to avoid unintentional + cancellations. recipients: A list of recipients to cancel the notification for. If omitted, cancels for all recipients associated with the cancellation key. @@ -284,6 +288,7 @@ async def trigger( actor: Optional[RecipientRequestParam] | Omit = omit, cancellation_key: Optional[str] | Omit = omit, data: Optional[Dict[str, object]] | Omit = omit, + settings: Optional[workflow_trigger_params.Settings] | Omit = omit, tenant: Optional[InlineTenantRequestParam] | 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. @@ -308,17 +313,19 @@ async def trigger( (string), an inline user request (object), or an inline object request, which is determined by the presence of a `collection` property. - cancellation_key: An optional key that is used to reference a specific workflow trigger request - when issuing a [workflow cancellation](/send-notifications/canceling-workflows) - request. Must be provided while triggering a workflow in order to enable - subsequent cancellation. Should be unique across trigger requests to avoid - unintentional cancellations. + cancellation_key: A key that is used to reference a specific workflow trigger request when issuing + a [workflow cancellation](/send-notifications/canceling-workflows) request. Must + be provided while triggering a workflow in order to enable 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 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. + settings: Optional settings that control how this workflow trigger is executed. + tenant: An request to set a tenant inline. extra_headers: Send extra headers @@ -341,6 +348,7 @@ async def trigger( "actor": actor, "cancellation_key": cancellation_key, "data": data, + "settings": settings, "tenant": tenant, }, workflow_trigger_params.WorkflowTriggerParams, diff --git a/src/knockapi/types/inline_identify_user_request_param.py b/src/knockapi/types/inline_identify_user_request_param.py index 07c5df57..22711bb4 100644 --- a/src/knockapi/types/inline_identify_user_request_param.py +++ b/src/knockapi/types/inline_identify_user_request_param.py @@ -13,7 +13,11 @@ __all__ = ["InlineIdentifyUserRequestParam"] -class InlineIdentifyUserRequestParam(TypedDict, total=False, extra_items=object): # type: ignore[call-arg] +class InlineIdentifyUserRequestParam( # type: ignore[call-arg] + TypedDict, + total=False, + extra_items=object, # pyright: ignore[reportGeneralTypeIssues] +): """A set of parameters to inline-identify a user with. Inline identifying the user will ensure that the user is available before the request is executed in Knock. It will perform an upsert for the user you're supplying, replacing any properties specified. diff --git a/src/knockapi/types/inline_object_request_param.py b/src/knockapi/types/inline_object_request_param.py index acedae44..552a6c6f 100644 --- a/src/knockapi/types/inline_object_request_param.py +++ b/src/knockapi/types/inline_object_request_param.py @@ -13,7 +13,11 @@ __all__ = ["InlineObjectRequestParam"] -class InlineObjectRequestParam(TypedDict, total=False, extra_items=object): # type: ignore[call-arg] +class InlineObjectRequestParam( # type: ignore[call-arg] + TypedDict, + total=False, + extra_items=object, # pyright: ignore[reportGeneralTypeIssues] +): """A custom [Object](/concepts/objects) entity which belongs to a collection.""" id: Required[str] diff --git a/src/knockapi/types/message.py b/src/knockapi/types/message.py index b368ffd6..a5fb7bda 100644 --- a/src/knockapi/types/message.py +++ b/src/knockapi/types/message.py @@ -9,7 +9,7 @@ from .._models import BaseModel from .recipient_reference import RecipientReference -__all__ = ["Message", "Source", "Channel"] +__all__ = ["Message", "Source", "Channel", "RecipientSnapshot"] class Source(BaseModel): @@ -71,6 +71,19 @@ class Channel(BaseModel): """The human-readable name of the channel.""" +class RecipientSnapshot(BaseModel): + """Recipient contact information captured at email send time. + + Null for non-email channels. + """ + + email: Optional[str] = None + """The email address the message was delivered to""" + + name: Optional[str] = None + """The recipient name at send time""" + + class Message(BaseModel): """ Represents a single message that was generated by a workflow for a given channel. @@ -144,6 +157,12 @@ class Message(BaseModel): read_at: Optional[datetime] = None """Timestamp when the message was read.""" + recipient_snapshot: Optional[RecipientSnapshot] = None + """Recipient contact information captured at email send time. + + Null for non-email channels. + """ + scheduled_at: Optional[datetime] = None """Timestamp when the message was scheduled to be sent.""" diff --git a/src/knockapi/types/message_event.py b/src/knockapi/types/message_event.py index fe1a72c1..17faa7ee 100644 --- a/src/knockapi/types/message_event.py +++ b/src/knockapi/types/message_event.py @@ -34,22 +34,22 @@ class MessageEvent(BaseModel): """ type: Literal[ - "message.archived", - "message.bounced", + "message.read", + "message.sent", + "message.seen", "message.created", + "message.queued", "message.delivered", + "message.bounced", + "message.undelivered", + "message.not_sent", "message.delivery_attempted", - "message.interacted", + "message.archived", "message.link_clicked", - "message.not_sent", - "message.queued", - "message.read", - "message.seen", - "message.sent", - "message.unarchived", - "message.undelivered", - "message.unread", + "message.interacted", "message.unseen", + "message.unread", + "message.unarchived", ] """The type of event that occurred.""" diff --git a/src/knockapi/types/objects/bulk_set_params.py b/src/knockapi/types/objects/bulk_set_params.py index dda82045..040eaaa0 100644 --- a/src/knockapi/types/objects/bulk_set_params.py +++ b/src/knockapi/types/objects/bulk_set_params.py @@ -18,7 +18,11 @@ class BulkSetParams(TypedDict, total=False): """A list of objects.""" -class Object(TypedDict, total=False, extra_items=object): # type: ignore[call-arg] +class Object( # type: ignore[call-arg] + TypedDict, + total=False, + extra_items=object, # pyright: ignore[reportGeneralTypeIssues] +): """A custom [Object](/concepts/objects) entity which belongs to a collection.""" id: Required[str] diff --git a/src/knockapi/types/providers/ms_team_check_auth_response.py b/src/knockapi/types/providers/ms_team_check_auth_response.py index c1d5e904..14d6a875 100644 --- a/src/knockapi/types/providers/ms_team_check_auth_response.py +++ b/src/knockapi/types/providers/ms_team_check_auth_response.py @@ -13,6 +13,9 @@ class Connection(BaseModel): ok: bool """Whether the Microsoft Teams connection is valid.""" + ms_teams_tenant_id: Optional[str] = None + """The Microsoft Teams tenant ID for the connected tenant.""" + reason: Optional[str] = None """The reason for the Microsoft Teams connection if it is not valid.""" diff --git a/src/knockapi/types/recipients/slack_channel_data.py b/src/knockapi/types/recipients/slack_channel_data.py index 261631d5..f241c7a5 100644 --- a/src/knockapi/types/recipients/slack_channel_data.py +++ b/src/knockapi/types/recipients/slack_channel_data.py @@ -10,6 +10,7 @@ "Connection", "ConnectionSlackTokenConnection", "ConnectionSlackIncomingWebhookConnection", + "ConnectionSlackIncomingWebhookConnectionIncomingWebhook", "Token", ] @@ -27,13 +28,20 @@ class ConnectionSlackTokenConnection(BaseModel): """A Slack user ID from the Slack provider.""" -class ConnectionSlackIncomingWebhookConnection(BaseModel): +class ConnectionSlackIncomingWebhookConnectionIncomingWebhook(BaseModel): """A Slack connection incoming webhook.""" url: str """The URL of the incoming webhook for a Slack connection.""" +class ConnectionSlackIncomingWebhookConnection(BaseModel): + """A Slack connection incoming webhook.""" + + incoming_webhook: ConnectionSlackIncomingWebhookConnectionIncomingWebhook + """A Slack connection incoming webhook.""" + + Connection: TypeAlias = Union[ConnectionSlackTokenConnection, ConnectionSlackIncomingWebhookConnection] diff --git a/src/knockapi/types/recipients/slack_channel_data_param.py b/src/knockapi/types/recipients/slack_channel_data_param.py index 0100c10f..c2ef29c1 100644 --- a/src/knockapi/types/recipients/slack_channel_data_param.py +++ b/src/knockapi/types/recipients/slack_channel_data_param.py @@ -10,6 +10,7 @@ "Connection", "ConnectionSlackTokenConnection", "ConnectionSlackIncomingWebhookConnection", + "ConnectionSlackIncomingWebhookConnectionIncomingWebhook", "Token", ] @@ -27,13 +28,20 @@ class ConnectionSlackTokenConnection(TypedDict, total=False): """A Slack user ID from the Slack provider.""" -class ConnectionSlackIncomingWebhookConnection(TypedDict, total=False): +class ConnectionSlackIncomingWebhookConnectionIncomingWebhook(TypedDict, total=False): """A Slack connection incoming webhook.""" url: Required[str] """The URL of the incoming webhook for a Slack connection.""" +class ConnectionSlackIncomingWebhookConnection(TypedDict, total=False): + """A Slack connection incoming webhook.""" + + incoming_webhook: Required[ConnectionSlackIncomingWebhookConnectionIncomingWebhook] + """A Slack connection incoming webhook.""" + + Connection: TypeAlias = Union[ConnectionSlackTokenConnection, ConnectionSlackIncomingWebhookConnection] diff --git a/src/knockapi/types/shared/condition.py b/src/knockapi/types/shared/condition.py index 7a3879b2..a2552559 100644 --- a/src/knockapi/types/shared/condition.py +++ b/src/knockapi/types/shared/condition.py @@ -28,6 +28,7 @@ class Condition(BaseModel): "exists", "not_exists", "contains_all", + "not_contains_all", "is_timestamp", "is_not_timestamp", "is_timestamp_on_or_after", diff --git a/src/knockapi/types/shared_params/condition.py b/src/knockapi/types/shared_params/condition.py index 4da8f407..5adfddf2 100644 --- a/src/knockapi/types/shared_params/condition.py +++ b/src/knockapi/types/shared_params/condition.py @@ -29,6 +29,7 @@ class Condition(TypedDict, total=False): "exists", "not_exists", "contains_all", + "not_contains_all", "is_timestamp", "is_not_timestamp", "is_timestamp_on_or_after", diff --git a/src/knockapi/types/tenant_request_param.py b/src/knockapi/types/tenant_request_param.py index 58d02b7e..3a097b2b 100644 --- a/src/knockapi/types/tenant_request_param.py +++ b/src/knockapi/types/tenant_request_param.py @@ -44,7 +44,11 @@ class Settings(TypedDict, total=False): """A request to set a preference set for a recipient.""" -class TenantRequestParam(TypedDict, total=False, extra_items=object): # type: ignore[call-arg] +class TenantRequestParam( # type: ignore[call-arg] + TypedDict, + total=False, + extra_items=object, # pyright: ignore[reportGeneralTypeIssues] +): """A tenant to be set in the system. You can supply any additional properties on the tenant object. diff --git a/src/knockapi/types/users/bulk_set_preferences_params.py b/src/knockapi/types/users/bulk_set_preferences_params.py index a954ede4..c5831208 100644 --- a/src/knockapi/types/users/bulk_set_preferences_params.py +++ b/src/knockapi/types/users/bulk_set_preferences_params.py @@ -2,17 +2,114 @@ from __future__ import annotations -from typing_extensions import Required, TypedDict +from typing import Dict, Union, Iterable, Optional +from typing_extensions import Required, TypeAlias, TypedDict from ..._types import SequenceNotStr -from ..recipients.preference_set_request_param import PreferenceSetRequestParam +from ..shared_params.condition import Condition +from ..recipients.preference_set_channel_types_param import PreferenceSetChannelTypesParam +from ..recipients.preference_set_channel_setting_param import PreferenceSetChannelSettingParam -__all__ = ["BulkSetPreferencesParams"] +__all__ = [ + "BulkSetPreferencesParams", + "Preferences", + "PreferencesCategories", + "PreferencesCategoriesPreferenceSetWorkflowCategorySettingObject", + "PreferencesCategoriesPreferenceSetWorkflowCategorySettingObjectChannels", + "PreferencesChannels", + "PreferencesWorkflows", + "PreferencesWorkflowsPreferenceSetWorkflowCategorySettingObject", + "PreferencesWorkflowsPreferenceSetWorkflowCategorySettingObjectChannels", +] class BulkSetPreferencesParams(TypedDict, total=False): - preferences: Required[PreferenceSetRequestParam] - """A request to set a preference set for a recipient.""" + preferences: Required[Preferences] + """A preference set to apply in a bulk operation. + + Always replaces existing preferences for the specified set. + """ user_ids: Required[SequenceNotStr[str]] """A list of user IDs.""" + + +PreferencesCategoriesPreferenceSetWorkflowCategorySettingObjectChannels: TypeAlias = Union[ + bool, PreferenceSetChannelSettingParam +] + + +class PreferencesCategoriesPreferenceSetWorkflowCategorySettingObject(TypedDict, total=False): + """ + The settings object for a workflow or category, where you can specify channel types or conditions. + """ + + channel_types: Optional[PreferenceSetChannelTypesParam] + """Channel type preferences.""" + + channels: Optional[Dict[str, PreferencesCategoriesPreferenceSetWorkflowCategorySettingObjectChannels]] + """Channel preferences.""" + + conditions: Optional[Iterable[Condition]] + """A list of conditions to apply to a channel type.""" + + +PreferencesCategories: TypeAlias = Union[bool, PreferencesCategoriesPreferenceSetWorkflowCategorySettingObject] + +PreferencesChannels: TypeAlias = Union[bool, PreferenceSetChannelSettingParam] + +PreferencesWorkflowsPreferenceSetWorkflowCategorySettingObjectChannels: TypeAlias = Union[ + bool, PreferenceSetChannelSettingParam +] + + +class PreferencesWorkflowsPreferenceSetWorkflowCategorySettingObject(TypedDict, total=False): + """ + The settings object for a workflow or category, where you can specify channel types or conditions. + """ + + channel_types: Optional[PreferenceSetChannelTypesParam] + """Channel type preferences.""" + + channels: Optional[Dict[str, PreferencesWorkflowsPreferenceSetWorkflowCategorySettingObjectChannels]] + """Channel preferences.""" + + conditions: Optional[Iterable[Condition]] + """A list of conditions to apply to a channel type.""" + + +PreferencesWorkflows: TypeAlias = Union[bool, PreferencesWorkflowsPreferenceSetWorkflowCategorySettingObject] + + +class Preferences(TypedDict, total=False): + """A preference set to apply in a bulk operation. + + Always replaces existing preferences for the specified set. + """ + + id: str + """Identifier for the preference set to update. Can be `default` or a tenant ID.""" + + categories: Optional[Dict[str, PreferencesCategories]] + """ + An object where the key is the category and the values are the preference + settings for that category. + """ + + channel_types: Optional[PreferenceSetChannelTypesParam] + """Channel type preferences.""" + + channels: Optional[Dict[str, PreferencesChannels]] + """Channel preferences.""" + + commercial_subscribed: Optional[bool] + """Whether the recipient is subscribed to commercial communications. + + When false, the recipient will not receive commercial workflow notifications. + """ + + workflows: Optional[Dict[str, PreferencesWorkflows]] + """ + An object where the key is the workflow key and the values are the preference + settings for that workflow. + """ diff --git a/src/knockapi/types/workflow_cancel_params.py b/src/knockapi/types/workflow_cancel_params.py index ad674e6d..13a260a1 100644 --- a/src/knockapi/types/workflow_cancel_params.py +++ b/src/knockapi/types/workflow_cancel_params.py @@ -14,11 +14,11 @@ class WorkflowCancelParams(TypedDict, total=False): cancellation_key: Required[str] """ - An optional key that is used to reference a specific workflow trigger request - when issuing a [workflow cancellation](/send-notifications/canceling-workflows) - request. Must be provided while triggering a workflow in order to enable - subsequent cancellation. Should be unique across trigger requests to avoid - unintentional cancellations. + A key that is used to reference a specific workflow trigger request when issuing + a [workflow cancellation](/send-notifications/canceling-workflows) request. Must + be provided while triggering a workflow in order to enable subsequent + cancellation. Should be unique across trigger requests to avoid unintentional + cancellations. """ recipients: Optional[SequenceNotStr[RecipientReferenceParam]] diff --git a/src/knockapi/types/workflow_recipient_run.py b/src/knockapi/types/workflow_recipient_run.py index 884b6846..4217f645 100644 --- a/src/knockapi/types/workflow_recipient_run.py +++ b/src/knockapi/types/workflow_recipient_run.py @@ -7,7 +7,6 @@ from pydantic import Field as FieldInfo from .._models import BaseModel -from .recipient import Recipient from .recipient_reference import RecipientReference __all__ = ["WorkflowRecipientRun", "TriggerSource"] @@ -74,8 +73,11 @@ class WorkflowRecipientRun(BaseModel): single trigger. """ - actor: Optional[Recipient] = None - """A recipient of a notification, which is either a user or an object.""" + actor: Optional[RecipientReference] = None + """ + A reference to a recipient, either a user identifier (string) or an object + reference (ID, collection). + """ error_count: Optional[int] = None """The number of errors encountered during the workflow recipient run.""" diff --git a/src/knockapi/types/workflow_trigger_params.py b/src/knockapi/types/workflow_trigger_params.py index 604f3199..373dec0f 100644 --- a/src/knockapi/types/workflow_trigger_params.py +++ b/src/knockapi/types/workflow_trigger_params.py @@ -9,7 +9,7 @@ from .recipient_request_param import RecipientRequestParam from .inline_tenant_request_param import InlineTenantRequestParam -__all__ = ["WorkflowTriggerParams"] +__all__ = ["WorkflowTriggerParams", "Settings"] class WorkflowTriggerParams(TypedDict, total=False): @@ -30,11 +30,11 @@ class WorkflowTriggerParams(TypedDict, total=False): cancellation_key: Optional[str] """ - An optional key that is used to reference a specific workflow trigger request - when issuing a [workflow cancellation](/send-notifications/canceling-workflows) - request. Must be provided while triggering a workflow in order to enable - subsequent cancellation. Should be unique across trigger requests to avoid - unintentional cancellations. + A key that is used to reference a specific workflow trigger request when issuing + a [workflow cancellation](/send-notifications/canceling-workflows) request. Must + be provided while triggering a workflow in order to enable subsequent + cancellation. Should be unique across trigger requests to avoid unintentional + cancellations. """ data: Optional[Dict[str, object]] @@ -45,5 +45,26 @@ class WorkflowTriggerParams(TypedDict, total=False): [truncated](/developer-tools/api-logs#log-truncation) in your logs. """ + settings: Optional[Settings] + """Optional settings that control how this workflow trigger is executed.""" + tenant: Optional[InlineTenantRequestParam] """An request to set a tenant inline.""" + + +class Settings(TypedDict, total=False): + """Optional settings that control how this workflow trigger is executed.""" + + sandbox_mode: Optional[bool] + """ + When set to true, overrides the sandbox mode for all channels in this workflow + run, messages are not delivered to the underlying providers. If false or not + set, the workflow delivers messages normally. + """ + + skip_delay: Optional[bool] + """When set to true, skips all delay steps in the workflow for this trigger + request. + + If false or not set, delay steps execute normally. + """ diff --git a/tests/api_resources/test_workflows.py b/tests/api_resources/test_workflows.py index 76532392..7bd00fa5 100644 --- a/tests/api_resources/test_workflows.py +++ b/tests/api_resources/test_workflows.py @@ -93,6 +93,10 @@ def test_method_trigger_with_all_params(self, client: Knock) -> None: "severity": "bar", "system_status": "bar", }, + settings={ + "sandbox_mode": True, + "skip_delay": True, + }, tenant="ingen_isla_nublar", ) assert_matches_type(WorkflowTriggerResponse, workflow, path=["response"]) @@ -211,6 +215,10 @@ async def test_method_trigger_with_all_params(self, async_client: AsyncKnock) -> "severity": "bar", "system_status": "bar", }, + settings={ + "sandbox_mode": True, + "skip_delay": True, + }, tenant="ingen_isla_nublar", ) assert_matches_type(WorkflowTriggerResponse, workflow, path=["response"]) diff --git a/tests/api_resources/users/test_bulk.py b/tests/api_resources/users/test_bulk.py index 537d6c80..28af4f8e 100644 --- a/tests/api_resources/users/test_bulk.py +++ b/tests/api_resources/users/test_bulk.py @@ -91,7 +91,7 @@ def test_method_set_preferences(self, client: Knock) -> None: def test_method_set_preferences_with_all_params(self, client: Knock) -> None: bulk = client.users.bulk.set_preferences( preferences={ - "_persistence_strategy": "merge", + "id": "default", "categories": { "marketing": False, "transactional": { @@ -385,7 +385,7 @@ async def test_method_set_preferences(self, async_client: AsyncKnock) -> None: async def test_method_set_preferences_with_all_params(self, async_client: AsyncKnock) -> None: bulk = await async_client.users.bulk.set_preferences( preferences={ - "_persistence_strategy": "merge", + "id": "default", "categories": { "marketing": False, "transactional": { diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py index dc13e213..eeb7608f 100644 --- a/tests/test_extract_files.py +++ b/tests/test_extract_files.py @@ -4,7 +4,7 @@ import pytest -from knockapi._types import FileTypes +from knockapi._types import FileTypes, ArrayFormat from knockapi._utils import extract_files @@ -37,10 +37,7 @@ def test_multiple_files() -> None: def test_top_level_file_array() -> None: query = {"files": [b"file one", b"file two"], "title": "hello"} - assert extract_files(query, paths=[["files", ""]]) == [ - ("files[]", b"file one"), - ("files[]", b"file two"), - ] + assert extract_files(query, paths=[["files", ""]]) == [("files[]", b"file one"), ("files[]", b"file two")] assert query == {"title": "hello"} @@ -71,3 +68,24 @@ def test_ignores_incorrect_paths( expected: list[tuple[str, FileTypes]], ) -> None: assert extract_files(query, paths=paths) == expected + + +@pytest.mark.parametrize( + "array_format,expected_top_level,expected_nested", + [ + ("brackets", [("files[]", b"a"), ("files[]", b"b")], [("items[][file]", b"a"), ("items[][file]", b"b")]), + ("repeat", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]), + ("comma", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]), + ("indices", [("files[0]", b"a"), ("files[1]", b"b")], [("items[0][file]", b"a"), ("items[1][file]", b"b")]), + ], +) +def test_array_format_controls_file_field_names( + array_format: ArrayFormat, + expected_top_level: list[tuple[str, FileTypes]], + expected_nested: list[tuple[str, FileTypes]], +) -> None: + top_level = {"files": [b"a", b"b"]} + assert extract_files(top_level, paths=[["files", ""]], array_format=array_format) == expected_top_level + + nested = {"items": [{"file": b"a"}, {"file": b"b"}]} + assert extract_files(nested, paths=[["items", "", "file"]], array_format=array_format) == expected_nested diff --git a/tests/test_files.py b/tests/test_files.py index 641bf93e..83cca7b7 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -131,7 +131,7 @@ def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None: copied = deepcopy_with_paths(original, [["items", "", "file"]]) extracted = extract_files(copied, paths=[["items", "", "file"]]) - assert extracted == [("items[][file]", file1), ("items[][file]", file2)] + assert [entry for _, entry in extracted] == [file1, file2] assert original == { "items": [ {"file": file1, "extra": 1}, diff --git a/tests/test_models.py b/tests/test_models.py index 36593c94..d3ced95f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,7 +1,8 @@ import json -from typing import TYPE_CHECKING, Any, Dict, List, Union, Optional, cast +from typing import TYPE_CHECKING, Any, Dict, List, Union, Iterable, Optional, cast from datetime import datetime, timezone -from typing_extensions import Literal, Annotated, TypeAliasType +from collections import deque +from typing_extensions import Literal, Annotated, TypedDict, TypeAliasType import pytest import pydantic @@ -9,7 +10,7 @@ from knockapi._utils import PropertyInfo from knockapi._compat import PYDANTIC_V1, parse_obj, model_dump, model_json -from knockapi._models import DISCRIMINATOR_CACHE, BaseModel, construct_type +from knockapi._models import DISCRIMINATOR_CACHE, BaseModel, EagerIterable, construct_type class BasicModel(BaseModel): @@ -961,3 +962,56 @@ def __getattr__(self, attr: str) -> Item: ... assert model.a.prop == 1 assert isinstance(model.a, Item) assert model.other == "foo" + + +# NOTE: Workaround for Pydantic Iterable behavior. +# Iterable fields are replaced with a ValidatorIterator and may be consumed +# during serialization, which can cause subsequent dumps to return empty data. +# See: https://github.com/pydantic/pydantic/issues/9541 +@pytest.mark.parametrize( + "data, expected_validated", + [ + ([1, 2, 3], [1, 2, 3]), + ((1, 2, 3), (1, 2, 3)), + (set([1, 2, 3]), set([1, 2, 3])), + (iter([1, 2, 3]), [1, 2, 3]), + ([], []), + ((x for x in [1, 2, 3]), [1, 2, 3]), + (map(lambda x: x, [1, 2, 3]), [1, 2, 3]), + (frozenset([1, 2, 3]), frozenset([1, 2, 3])), + (deque([1, 2, 3]), deque([1, 2, 3])), + ], + ids=["list", "tuple", "set", "iterator", "empty", "generator", "map", "frozenset", "deque"], +) +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2") +def test_iterable_construction(data: Iterable[int], expected_validated: Iterable[int]) -> None: + class TypeWithIterable(TypedDict): + items: EagerIterable[int] + + class Model(BaseModel): + data: TypeWithIterable + + m = Model.model_validate({"data": {"items": data}}) + assert m.data["items"] == expected_validated + + # Verify repeated dumps don't lose data (the original bug) + assert m.model_dump()["data"]["items"] == list(expected_validated) + assert m.model_dump()["data"]["items"] == list(expected_validated) + + +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2") +def test_iterable_construction_str_falls_back_to_list() -> None: + # str is iterable (over chars), but str(list_of_chars) produces the list's repr + # rather than reconstructing a string from items. We special-case str to fall + # back to list instead of attempting reconstruction. + class TypeWithIterable(TypedDict): + items: EagerIterable[str] + + class Model(BaseModel): + data: TypeWithIterable + + m = Model.model_validate({"data": {"items": "hello"}}) + + # falls back to list of chars rather than calling str(["h", "e", "l", "l", "o"]) + assert m.data["items"] == ["h", "e", "l", "l", "o"] + assert m.model_dump()["data"]["items"] == ["h", "e", "l", "l", "o"]