diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36a46e6..d1853c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/prelude-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | @@ -44,7 +44,7 @@ jobs: id-token: write runs-on: ${{ github.repository == 'stainless-sdks/prelude-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | @@ -61,14 +61,18 @@ jobs: run: rye build - name: Get GitHub OIDC Token - if: github.repository == 'stainless-sdks/prelude-python' + if: |- + github.repository == 'stainless-sdks/prelude-python' && + !startsWith(github.ref, 'refs/heads/stl/') id: github-oidc - uses: actions/github-script@v6 + uses: actions/github-script@v8 with: script: core.setOutput('github_token', await core.getIDToken()); - name: Upload tarball - if: github.repository == 'stainless-sdks/prelude-python' + if: |- + github.repository == 'stainless-sdks/prelude-python' && + !startsWith(github.ref, 'refs/heads/stl/') env: URL: https://pkg.stainless.com/s AUTH: ${{ steps.github-oidc.outputs.github_token }} @@ -81,7 +85,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/prelude-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 1ccb8d9..44394c8 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@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index 82d8ed1..e23c857 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'prelude-so/python-sdk' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check release environment run: | diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 091cfb1..f7014c3 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.10.0" + ".": "0.11.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 3559e65..d3333fd 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 19 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/prelude%2Fprelude-fe14bed3c1b6245444e1a53e7b0b172301c207ef9500dc68f4d8e58a7bfec436.yml -openapi_spec_hash: 4acdc9dd487011b2391f6fe02812a6bd -config_hash: 55380048fe5cf686421acf7eeaae21be +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/prelude%2Fprelude-c9124c257dd54dd0728cb57306b9082007439bfefac11642542605edb3e7606d.yml +openapi_spec_hash: 61dc64cc814d10975a8825ec88fd9c1c +config_hash: 107ae5754168e80c4ad2cd779a75bc36 diff --git a/CHANGELOG.md b/CHANGELOG.md index 66e11a1..8d8b0b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## 0.11.0 (2026-03-07) + +Full Changelog: [v0.10.0...v0.11.0](https://github.com/prelude-so/python-sdk/compare/v0.10.0...v0.11.0) + +### Features + +* **api:** api update ([859b8f0](https://github.com/prelude-so/python-sdk/commit/859b8f0cccf997852bc2d1911b1699da74741551)) +* **api:** api update ([4c5c4e2](https://github.com/prelude-so/python-sdk/commit/4c5c4e253a4f490b338d5536201a0ae3d5bc64a7)) +* **api:** api update ([24288cd](https://github.com/prelude-so/python-sdk/commit/24288cdaca292916fbb906003adcbb80f8315343)) +* **api:** api update ([bc95591](https://github.com/prelude-so/python-sdk/commit/bc95591afd9e4e6e09a4c8f83a5730ad3a5ed051)) +* **api:** api update ([c00e5f5](https://github.com/prelude-so/python-sdk/commit/c00e5f5f604a94604140731637d7fb975376188e)) +* **api:** api update ([f7afece](https://github.com/prelude-so/python-sdk/commit/f7afece5c9ea5bb3f3420a8a1f543da1e136bcff)) +* **api:** api update ([94f4fa9](https://github.com/prelude-so/python-sdk/commit/94f4fa9904573e950ae6d01d289cce34e9e70370)) +* **api:** api update ([6ab992c](https://github.com/prelude-so/python-sdk/commit/6ab992cc4da735868c0aed05cd737ed33f2c8d68)) +* **client:** add custom JSON encoder for extended type support ([7b89b88](https://github.com/prelude-so/python-sdk/commit/7b89b884402982ffc74e27f13bfccdff24b8ff75)) +* **client:** add support for binary request streaming ([5cc25b3](https://github.com/prelude-so/python-sdk/commit/5cc25b3416e9fe458d27fb26a0f8ebca1c36312a)) + + +### Bug Fixes + +* **types:** allow pyright to infer TypedDict types within SequenceNotStr ([1197a12](https://github.com/prelude-so/python-sdk/commit/1197a1218644530a19719ab9866385a3155581d5)) +* use async_to_httpx_files in patch method ([2a4c51a](https://github.com/prelude-so/python-sdk/commit/2a4c51aa40b31c9709fd482771a5eb1defd60786)) + + +### Chores + +* add missing docstrings ([6fc2141](https://github.com/prelude-so/python-sdk/commit/6fc2141b36efba6d357336a36c1305864ffec8cb)) +* **ci:** skip uploading artifacts on stainless-internal branches ([c6b3603](https://github.com/prelude-so/python-sdk/commit/c6b360343d0e2115be558ae6b17017235428206d)) +* **ci:** upgrade `actions/github-script` ([37e674a](https://github.com/prelude-so/python-sdk/commit/37e674a17a76e2a0bb3a2f9b29439ea233830109)) +* **docs:** add missing descriptions ([1a73fd8](https://github.com/prelude-so/python-sdk/commit/1a73fd80ad7b60fda2da8ad9746aede6b7d7bf1c)) +* format all `api.md` files ([cafa350](https://github.com/prelude-so/python-sdk/commit/cafa350bb5651faa97e3d5b0ba520a24805e57ae)) +* **internal:** add `--fix` argument to lint script ([aaa77d7](https://github.com/prelude-so/python-sdk/commit/aaa77d73edce4cc03a4cd203b90a7c6f9e145bbd)) +* **internal:** add missing files argument to base client ([84ade2c](https://github.com/prelude-so/python-sdk/commit/84ade2c96740cc217646929248a0b4e60c2434f0)) +* **internal:** add request options to SSE classes ([1d2f5ba](https://github.com/prelude-so/python-sdk/commit/1d2f5baf54ac0ed2e95c94c0c8a5ad97456d8db7)) +* **internal:** bump dependencies ([cd147cc](https://github.com/prelude-so/python-sdk/commit/cd147cc674b99d1f42840dc8be4cbc50abe458cb)) +* **internal:** codegen related update ([72f7709](https://github.com/prelude-so/python-sdk/commit/72f7709ffeae6b6b4ad835f0861136d713aa3ba5)) +* **internal:** fix lint error on Python 3.14 ([6fa1e9a](https://github.com/prelude-so/python-sdk/commit/6fa1e9a65dd600399a33c4184b159693dc5f6fd2)) +* **internal:** make `test_proxy_environment_variables` more resilient ([1ca0a6e](https://github.com/prelude-so/python-sdk/commit/1ca0a6ec1b01fc52bf7c02225a4a63873da0a75b)) +* **internal:** make `test_proxy_environment_variables` more resilient to env ([0e5b065](https://github.com/prelude-so/python-sdk/commit/0e5b065a92f740de2a59ad94777ca4890b0aba13)) +* **internal:** update `actions/checkout` version ([34bf2ba](https://github.com/prelude-so/python-sdk/commit/34bf2ba6a9e133d5287719459da01be9289b115d)) +* speedup initial import ([5300f18](https://github.com/prelude-so/python-sdk/commit/5300f18a10c0db7b222c1bb3e487402aab9d318e)) +* **test:** do not count install time for mock server timeout ([73aaccf](https://github.com/prelude-so/python-sdk/commit/73aaccf6b2dde8d80b4ee5d090a9057f847b4bac)) +* update mock server docs ([85a5da0](https://github.com/prelude-so/python-sdk/commit/85a5da01c2f04110317bd8a8c5a4cec4bd7be831)) + ## 0.10.0 (2025-12-05) Full Changelog: [v0.9.0...v0.10.0](https://github.com/prelude-so/python-sdk/compare/v0.9.0...v0.10.0) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d42531..d7fbe4e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,8 +88,7 @@ $ pip install ./path-to-wheel-file.whl Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. ```sh -# you will need npm installed -$ npx prism mock path/to/your/openapi.yml +$ ./scripts/mock ``` ```sh diff --git a/LICENSE b/LICENSE index 69f0a67..ed78459 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2025 Prelude + Copyright 2026 Prelude Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/pyproject.toml b/pyproject.toml index 337da74..e0a3060 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "prelude-python-sdk" -version = "0.10.0" +version = "0.11.0" description = "The official Python library for the Prelude API" dynamic = ["readme"] license = "Apache-2.0" @@ -69,7 +69,7 @@ format = { chain = [ # run formatting again to fix any inconsistencies when imports are stripped "format:ruff", ]} -"format:docs" = "python scripts/utils/ruffen-docs.py README.md api.md" +"format:docs" = "bash -c 'python scripts/utils/ruffen-docs.py README.md $(find . -type f -name api.md)'" "format:ruff" = "ruff format" "lint" = { chain = [ diff --git a/requirements-dev.lock b/requirements-dev.lock index 10e306b..c6bb79b 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -12,14 +12,14 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.13.2 +aiohttp==3.13.3 # via httpx-aiohttp # via prelude-python-sdk aiosignal==1.4.0 # via aiohttp annotated-types==0.7.0 # via pydantic -anyio==4.12.0 +anyio==4.12.1 # via httpx # via prelude-python-sdk argcomplete==3.6.3 @@ -31,7 +31,7 @@ attrs==25.4.0 # via nox backports-asyncio-runner==1.2.0 # via pytest-asyncio -certifi==2025.11.12 +certifi==2026.1.4 # via httpcore # via httpx colorlog==6.10.1 @@ -61,7 +61,7 @@ httpx==0.28.1 # via httpx-aiohttp # via prelude-python-sdk # via respx -httpx-aiohttp==0.1.9 +httpx-aiohttp==0.1.12 # via prelude-python-sdk humanize==4.13.0 # via nox @@ -69,7 +69,7 @@ idna==3.11 # via anyio # via httpx # via yarl -importlib-metadata==8.7.0 +importlib-metadata==8.7.1 iniconfig==2.1.0 # via pytest markdown-it-py==3.0.0 @@ -82,14 +82,14 @@ multidict==6.7.0 mypy==1.17.0 mypy-extensions==1.1.0 # via mypy -nodeenv==1.9.1 +nodeenv==1.10.0 # via pyright nox==2025.11.12 packaging==25.0 # via dependency-groups # via nox # via pytest -pathspec==0.12.1 +pathspec==1.0.3 # via mypy platformdirs==4.4.0 # via virtualenv @@ -115,13 +115,13 @@ python-dateutil==2.9.0.post0 # via time-machine respx==0.22.0 rich==14.2.0 -ruff==0.14.7 +ruff==0.14.13 six==1.17.0 # via python-dateutil sniffio==1.3.1 # via prelude-python-sdk time-machine==2.19.0 -tomli==2.3.0 +tomli==2.4.0 # via dependency-groups # via mypy # via nox @@ -141,7 +141,7 @@ typing-extensions==4.15.0 # via virtualenv typing-inspection==0.4.2 # via pydantic -virtualenv==20.35.4 +virtualenv==20.36.1 # via nox yarl==1.22.0 # via aiohttp diff --git a/requirements.lock b/requirements.lock index 39e3f94..6bc76f8 100644 --- a/requirements.lock +++ b/requirements.lock @@ -12,21 +12,21 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.13.2 +aiohttp==3.13.3 # via httpx-aiohttp # via prelude-python-sdk aiosignal==1.4.0 # via aiohttp annotated-types==0.7.0 # via pydantic -anyio==4.12.0 +anyio==4.12.1 # via httpx # via prelude-python-sdk async-timeout==5.0.1 # via aiohttp attrs==25.4.0 # via aiohttp -certifi==2025.11.12 +certifi==2026.1.4 # via httpcore # via httpx distro==1.9.0 @@ -43,7 +43,7 @@ httpcore==1.0.9 httpx==0.28.1 # via httpx-aiohttp # via prelude-python-sdk -httpx-aiohttp==0.1.9 +httpx-aiohttp==0.1.12 # via prelude-python-sdk idna==3.11 # via anyio diff --git a/scripts/lint b/scripts/lint index ffa4a0f..ab49711 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,8 +4,13 @@ set -e cd "$(dirname "$0")/.." -echo "==> Running lints" -rye run lint +if [ "$1" = "--fix" ]; then + echo "==> Running lints with --fix" + rye run fix:ruff +else + echo "==> Running lints" + rye run lint +fi echo "==> Making sure it imports" rye run python -c 'import prelude_python_sdk' diff --git a/scripts/mock b/scripts/mock index 0b28f6e..bcf3b39 100755 --- a/scripts/mock +++ b/scripts/mock @@ -21,11 +21,22 @@ echo "==> Starting mock server with URL ${URL}" # Run prism mock on the given spec if [ "$1" == "--daemon" ]; then + # Pre-install the package so the download doesn't eat into the startup timeout + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism --version + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & - # Wait for server to come online + # Wait for server to come online (max 30s) echo -n "Waiting for server" + attempts=0 while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 300 ]; then + echo + echo "Timed out waiting for Prism server to start" + cat .prism.log + exit 1 + fi echo -n "." sleep 0.1 done diff --git a/src/prelude_python_sdk/_base_client.py b/src/prelude_python_sdk/_base_client.py index f3d952b..c6309e4 100644 --- a/src/prelude_python_sdk/_base_client.py +++ b/src/prelude_python_sdk/_base_client.py @@ -9,6 +9,7 @@ import inspect import logging import platform +import warnings import email.utils from types import TracebackType from random import random @@ -51,9 +52,11 @@ ResponseT, AnyMapping, PostParser, + BinaryTypes, RequestFiles, HttpxSendArgs, RequestOptions, + AsyncBinaryTypes, HttpxRequestFiles, ModelBuilderProtocol, not_given, @@ -83,6 +86,7 @@ APIConnectionError, APIResponseValidationError, ) +from ._utils._json import openapi_dumps log: logging.Logger = logging.getLogger(__name__) @@ -477,8 +481,19 @@ def _build_request( retries_taken: int = 0, ) -> httpx.Request: if log.isEnabledFor(logging.DEBUG): - log.debug("Request options: %s", model_dump(options, exclude_unset=True)) - + log.debug( + "Request options: %s", + model_dump( + options, + exclude_unset=True, + # Pydantic v1 can't dump every type we support in content, so we exclude it for now. + exclude={ + "content", + } + if PYDANTIC_V1 + else {}, + ), + ) kwargs: dict[str, Any] = {} json_data = options.json_data @@ -532,10 +547,18 @@ def _build_request( is_body_allowed = options.method.lower() != "get" if is_body_allowed: - if isinstance(json_data, bytes): + if options.content is not None and json_data is not None: + raise TypeError("Passing both `content` and `json_data` is not supported") + if options.content is not None and files is not None: + raise TypeError("Passing both `content` and `files` is not supported") + if options.content is not None: + kwargs["content"] = options.content + elif isinstance(json_data, bytes): kwargs["content"] = json_data - else: - kwargs["json"] = json_data if is_given(json_data) else None + elif not files: + # Don't set content when JSON is sent as multipart/form-data, + # since httpx's content param overrides other body arguments + kwargs["content"] = openapi_dumps(json_data) if is_given(json_data) and json_data is not None else None kwargs["files"] = files else: headers.pop("Content-Type", None) @@ -1194,6 +1217,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[False] = False, @@ -1206,6 +1230,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[True], @@ -1219,6 +1244,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool, @@ -1231,13 +1257,25 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool = False, stream_cls: type[_StreamT] | None = None, ) -> ResponseT | _StreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=to_httpx_files(files), **options + method="post", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) @@ -1247,9 +1285,24 @@ def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="patch", url=path, json_data=body, content=content, files=to_httpx_files(files), **options + ) return self.request(cast_to, opts) def put( @@ -1258,11 +1311,23 @@ def put( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=to_httpx_files(files), **options + method="put", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return self.request(cast_to, opts) @@ -1272,9 +1337,19 @@ def delete( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) return self.request(cast_to, opts) def get_api_list( @@ -1714,6 +1789,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[False] = False, @@ -1726,6 +1802,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[True], @@ -1739,6 +1816,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool, @@ -1751,13 +1829,25 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool = False, stream_cls: type[_AsyncStreamT] | None = None, ) -> ResponseT | _AsyncStreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="post", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) @@ -1767,9 +1857,29 @@ async def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="patch", + url=path, + json_data=body, + content=content, + files=await async_to_httpx_files(files), + **options, + ) return await self.request(cast_to, opts) async def put( @@ -1778,11 +1888,23 @@ async def put( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="put", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts) @@ -1792,9 +1914,19 @@ async def delete( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) return await self.request(cast_to, opts) def get_api_list( diff --git a/src/prelude_python_sdk/_client.py b/src/prelude_python_sdk/_client.py index 95c5c63..17e918c 100644 --- a/src/prelude_python_sdk/_client.py +++ b/src/prelude_python_sdk/_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import Any, Mapping +from typing import TYPE_CHECKING, Any, Mapping from typing_extensions import Self, override import httpx @@ -20,8 +20,8 @@ not_given, ) from ._utils import is_given, get_async_library +from ._compat import cached_property from ._version import __version__ -from .resources import watch, lookup, notify, verification, transactional, verification_management from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import PreludeError, APIStatusError from ._base_client import ( @@ -30,19 +30,19 @@ AsyncAPIClient, ) +if TYPE_CHECKING: + from .resources import watch, lookup, notify, verification, transactional, verification_management + from .resources.watch import WatchResource, AsyncWatchResource + from .resources.lookup import LookupResource, AsyncLookupResource + from .resources.notify import NotifyResource, AsyncNotifyResource + from .resources.verification import VerificationResource, AsyncVerificationResource + from .resources.transactional import TransactionalResource, AsyncTransactionalResource + from .resources.verification_management import VerificationManagementResource, AsyncVerificationManagementResource + __all__ = ["Timeout", "Transport", "ProxiesTypes", "RequestOptions", "Prelude", "AsyncPrelude", "Client", "AsyncClient"] class Prelude(SyncAPIClient): - lookup: lookup.LookupResource - notify: notify.NotifyResource - transactional: transactional.TransactionalResource - verification: verification.VerificationResource - verification_management: verification_management.VerificationManagementResource - watch: watch.WatchResource - with_raw_response: PreludeWithRawResponse - with_streaming_response: PreludeWithStreamedResponse - # client options api_token: str @@ -97,14 +97,57 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.lookup = lookup.LookupResource(self) - self.notify = notify.NotifyResource(self) - self.transactional = transactional.TransactionalResource(self) - self.verification = verification.VerificationResource(self) - self.verification_management = verification_management.VerificationManagementResource(self) - self.watch = watch.WatchResource(self) - self.with_raw_response = PreludeWithRawResponse(self) - self.with_streaming_response = PreludeWithStreamedResponse(self) + @cached_property + def lookup(self) -> LookupResource: + """ + Retrieve detailed information about a phone number including carrier data, line type, and portability status. + """ + from .resources.lookup import LookupResource + + return LookupResource(self) + + @cached_property + def notify(self) -> NotifyResource: + """Send transactional and marketing messages with compliance enforcement.""" + from .resources.notify import NotifyResource + + return NotifyResource(self) + + @cached_property + def transactional(self) -> TransactionalResource: + """Send transactional messages (deprecated - use Notify API instead).""" + from .resources.transactional import TransactionalResource + + return TransactionalResource(self) + + @cached_property + def verification(self) -> VerificationResource: + """Verify phone numbers.""" + from .resources.verification import VerificationResource + + return VerificationResource(self) + + @cached_property + def verification_management(self) -> VerificationManagementResource: + """Verify phone numbers.""" + from .resources.verification_management import VerificationManagementResource + + return VerificationManagementResource(self) + + @cached_property + def watch(self) -> WatchResource: + """Evaluate email addresses and phone numbers for trustworthiness.""" + from .resources.watch import WatchResource + + return WatchResource(self) + + @cached_property + def with_raw_response(self) -> PreludeWithRawResponse: + return PreludeWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> PreludeWithStreamedResponse: + return PreludeWithStreamedResponse(self) @property @override @@ -212,15 +255,6 @@ def _make_status_error( class AsyncPrelude(AsyncAPIClient): - lookup: lookup.AsyncLookupResource - notify: notify.AsyncNotifyResource - transactional: transactional.AsyncTransactionalResource - verification: verification.AsyncVerificationResource - verification_management: verification_management.AsyncVerificationManagementResource - watch: watch.AsyncWatchResource - with_raw_response: AsyncPreludeWithRawResponse - with_streaming_response: AsyncPreludeWithStreamedResponse - # client options api_token: str @@ -275,14 +309,57 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.lookup = lookup.AsyncLookupResource(self) - self.notify = notify.AsyncNotifyResource(self) - self.transactional = transactional.AsyncTransactionalResource(self) - self.verification = verification.AsyncVerificationResource(self) - self.verification_management = verification_management.AsyncVerificationManagementResource(self) - self.watch = watch.AsyncWatchResource(self) - self.with_raw_response = AsyncPreludeWithRawResponse(self) - self.with_streaming_response = AsyncPreludeWithStreamedResponse(self) + @cached_property + def lookup(self) -> AsyncLookupResource: + """ + Retrieve detailed information about a phone number including carrier data, line type, and portability status. + """ + from .resources.lookup import AsyncLookupResource + + return AsyncLookupResource(self) + + @cached_property + def notify(self) -> AsyncNotifyResource: + """Send transactional and marketing messages with compliance enforcement.""" + from .resources.notify import AsyncNotifyResource + + return AsyncNotifyResource(self) + + @cached_property + def transactional(self) -> AsyncTransactionalResource: + """Send transactional messages (deprecated - use Notify API instead).""" + from .resources.transactional import AsyncTransactionalResource + + return AsyncTransactionalResource(self) + + @cached_property + def verification(self) -> AsyncVerificationResource: + """Verify phone numbers.""" + from .resources.verification import AsyncVerificationResource + + return AsyncVerificationResource(self) + + @cached_property + def verification_management(self) -> AsyncVerificationManagementResource: + """Verify phone numbers.""" + from .resources.verification_management import AsyncVerificationManagementResource + + return AsyncVerificationManagementResource(self) + + @cached_property + def watch(self) -> AsyncWatchResource: + """Evaluate email addresses and phone numbers for trustworthiness.""" + from .resources.watch import AsyncWatchResource + + return AsyncWatchResource(self) + + @cached_property + def with_raw_response(self) -> AsyncPreludeWithRawResponse: + return AsyncPreludeWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncPreludeWithStreamedResponse: + return AsyncPreludeWithStreamedResponse(self) @property @override @@ -390,51 +467,209 @@ def _make_status_error( class PreludeWithRawResponse: + _client: Prelude + def __init__(self, client: Prelude) -> None: - self.lookup = lookup.LookupResourceWithRawResponse(client.lookup) - self.notify = notify.NotifyResourceWithRawResponse(client.notify) - self.transactional = transactional.TransactionalResourceWithRawResponse(client.transactional) - self.verification = verification.VerificationResourceWithRawResponse(client.verification) - self.verification_management = verification_management.VerificationManagementResourceWithRawResponse( - client.verification_management - ) - self.watch = watch.WatchResourceWithRawResponse(client.watch) + self._client = client + + @cached_property + def lookup(self) -> lookup.LookupResourceWithRawResponse: + """ + Retrieve detailed information about a phone number including carrier data, line type, and portability status. + """ + from .resources.lookup import LookupResourceWithRawResponse + + return LookupResourceWithRawResponse(self._client.lookup) + + @cached_property + def notify(self) -> notify.NotifyResourceWithRawResponse: + """Send transactional and marketing messages with compliance enforcement.""" + from .resources.notify import NotifyResourceWithRawResponse + + return NotifyResourceWithRawResponse(self._client.notify) + + @cached_property + def transactional(self) -> transactional.TransactionalResourceWithRawResponse: + """Send transactional messages (deprecated - use Notify API instead).""" + from .resources.transactional import TransactionalResourceWithRawResponse + + return TransactionalResourceWithRawResponse(self._client.transactional) + + @cached_property + def verification(self) -> verification.VerificationResourceWithRawResponse: + """Verify phone numbers.""" + from .resources.verification import VerificationResourceWithRawResponse + + return VerificationResourceWithRawResponse(self._client.verification) + + @cached_property + def verification_management(self) -> verification_management.VerificationManagementResourceWithRawResponse: + """Verify phone numbers.""" + from .resources.verification_management import VerificationManagementResourceWithRawResponse + + return VerificationManagementResourceWithRawResponse(self._client.verification_management) + + @cached_property + def watch(self) -> watch.WatchResourceWithRawResponse: + """Evaluate email addresses and phone numbers for trustworthiness.""" + from .resources.watch import WatchResourceWithRawResponse + + return WatchResourceWithRawResponse(self._client.watch) class AsyncPreludeWithRawResponse: + _client: AsyncPrelude + def __init__(self, client: AsyncPrelude) -> None: - self.lookup = lookup.AsyncLookupResourceWithRawResponse(client.lookup) - self.notify = notify.AsyncNotifyResourceWithRawResponse(client.notify) - self.transactional = transactional.AsyncTransactionalResourceWithRawResponse(client.transactional) - self.verification = verification.AsyncVerificationResourceWithRawResponse(client.verification) - self.verification_management = verification_management.AsyncVerificationManagementResourceWithRawResponse( - client.verification_management - ) - self.watch = watch.AsyncWatchResourceWithRawResponse(client.watch) + self._client = client + + @cached_property + def lookup(self) -> lookup.AsyncLookupResourceWithRawResponse: + """ + Retrieve detailed information about a phone number including carrier data, line type, and portability status. + """ + from .resources.lookup import AsyncLookupResourceWithRawResponse + + return AsyncLookupResourceWithRawResponse(self._client.lookup) + + @cached_property + def notify(self) -> notify.AsyncNotifyResourceWithRawResponse: + """Send transactional and marketing messages with compliance enforcement.""" + from .resources.notify import AsyncNotifyResourceWithRawResponse + + return AsyncNotifyResourceWithRawResponse(self._client.notify) + + @cached_property + def transactional(self) -> transactional.AsyncTransactionalResourceWithRawResponse: + """Send transactional messages (deprecated - use Notify API instead).""" + from .resources.transactional import AsyncTransactionalResourceWithRawResponse + + return AsyncTransactionalResourceWithRawResponse(self._client.transactional) + + @cached_property + def verification(self) -> verification.AsyncVerificationResourceWithRawResponse: + """Verify phone numbers.""" + from .resources.verification import AsyncVerificationResourceWithRawResponse + + return AsyncVerificationResourceWithRawResponse(self._client.verification) + + @cached_property + def verification_management(self) -> verification_management.AsyncVerificationManagementResourceWithRawResponse: + """Verify phone numbers.""" + from .resources.verification_management import AsyncVerificationManagementResourceWithRawResponse + + return AsyncVerificationManagementResourceWithRawResponse(self._client.verification_management) + + @cached_property + def watch(self) -> watch.AsyncWatchResourceWithRawResponse: + """Evaluate email addresses and phone numbers for trustworthiness.""" + from .resources.watch import AsyncWatchResourceWithRawResponse + + return AsyncWatchResourceWithRawResponse(self._client.watch) class PreludeWithStreamedResponse: + _client: Prelude + def __init__(self, client: Prelude) -> None: - self.lookup = lookup.LookupResourceWithStreamingResponse(client.lookup) - self.notify = notify.NotifyResourceWithStreamingResponse(client.notify) - self.transactional = transactional.TransactionalResourceWithStreamingResponse(client.transactional) - self.verification = verification.VerificationResourceWithStreamingResponse(client.verification) - self.verification_management = verification_management.VerificationManagementResourceWithStreamingResponse( - client.verification_management - ) - self.watch = watch.WatchResourceWithStreamingResponse(client.watch) + self._client = client + + @cached_property + def lookup(self) -> lookup.LookupResourceWithStreamingResponse: + """ + Retrieve detailed information about a phone number including carrier data, line type, and portability status. + """ + from .resources.lookup import LookupResourceWithStreamingResponse + + return LookupResourceWithStreamingResponse(self._client.lookup) + + @cached_property + def notify(self) -> notify.NotifyResourceWithStreamingResponse: + """Send transactional and marketing messages with compliance enforcement.""" + from .resources.notify import NotifyResourceWithStreamingResponse + + return NotifyResourceWithStreamingResponse(self._client.notify) + + @cached_property + def transactional(self) -> transactional.TransactionalResourceWithStreamingResponse: + """Send transactional messages (deprecated - use Notify API instead).""" + from .resources.transactional import TransactionalResourceWithStreamingResponse + + return TransactionalResourceWithStreamingResponse(self._client.transactional) + + @cached_property + def verification(self) -> verification.VerificationResourceWithStreamingResponse: + """Verify phone numbers.""" + from .resources.verification import VerificationResourceWithStreamingResponse + + return VerificationResourceWithStreamingResponse(self._client.verification) + + @cached_property + def verification_management(self) -> verification_management.VerificationManagementResourceWithStreamingResponse: + """Verify phone numbers.""" + from .resources.verification_management import VerificationManagementResourceWithStreamingResponse + + return VerificationManagementResourceWithStreamingResponse(self._client.verification_management) + + @cached_property + def watch(self) -> watch.WatchResourceWithStreamingResponse: + """Evaluate email addresses and phone numbers for trustworthiness.""" + from .resources.watch import WatchResourceWithStreamingResponse + + return WatchResourceWithStreamingResponse(self._client.watch) class AsyncPreludeWithStreamedResponse: + _client: AsyncPrelude + def __init__(self, client: AsyncPrelude) -> None: - self.lookup = lookup.AsyncLookupResourceWithStreamingResponse(client.lookup) - self.notify = notify.AsyncNotifyResourceWithStreamingResponse(client.notify) - self.transactional = transactional.AsyncTransactionalResourceWithStreamingResponse(client.transactional) - self.verification = verification.AsyncVerificationResourceWithStreamingResponse(client.verification) - self.verification_management = verification_management.AsyncVerificationManagementResourceWithStreamingResponse( - client.verification_management - ) - self.watch = watch.AsyncWatchResourceWithStreamingResponse(client.watch) + self._client = client + + @cached_property + def lookup(self) -> lookup.AsyncLookupResourceWithStreamingResponse: + """ + Retrieve detailed information about a phone number including carrier data, line type, and portability status. + """ + from .resources.lookup import AsyncLookupResourceWithStreamingResponse + + return AsyncLookupResourceWithStreamingResponse(self._client.lookup) + + @cached_property + def notify(self) -> notify.AsyncNotifyResourceWithStreamingResponse: + """Send transactional and marketing messages with compliance enforcement.""" + from .resources.notify import AsyncNotifyResourceWithStreamingResponse + + return AsyncNotifyResourceWithStreamingResponse(self._client.notify) + + @cached_property + def transactional(self) -> transactional.AsyncTransactionalResourceWithStreamingResponse: + """Send transactional messages (deprecated - use Notify API instead).""" + from .resources.transactional import AsyncTransactionalResourceWithStreamingResponse + + return AsyncTransactionalResourceWithStreamingResponse(self._client.transactional) + + @cached_property + def verification(self) -> verification.AsyncVerificationResourceWithStreamingResponse: + """Verify phone numbers.""" + from .resources.verification import AsyncVerificationResourceWithStreamingResponse + + return AsyncVerificationResourceWithStreamingResponse(self._client.verification) + + @cached_property + def verification_management( + self, + ) -> verification_management.AsyncVerificationManagementResourceWithStreamingResponse: + """Verify phone numbers.""" + from .resources.verification_management import AsyncVerificationManagementResourceWithStreamingResponse + + return AsyncVerificationManagementResourceWithStreamingResponse(self._client.verification_management) + + @cached_property + def watch(self) -> watch.AsyncWatchResourceWithStreamingResponse: + """Evaluate email addresses and phone numbers for trustworthiness.""" + from .resources.watch import AsyncWatchResourceWithStreamingResponse + + return AsyncWatchResourceWithStreamingResponse(self._client.watch) Client = Prelude diff --git a/src/prelude_python_sdk/_compat.py b/src/prelude_python_sdk/_compat.py index bdef67f..786ff42 100644 --- a/src/prelude_python_sdk/_compat.py +++ b/src/prelude_python_sdk/_compat.py @@ -139,6 +139,7 @@ def model_dump( exclude_defaults: bool = False, warnings: bool = True, mode: Literal["json", "python"] = "python", + by_alias: bool | None = None, ) -> dict[str, Any]: if (not PYDANTIC_V1) or hasattr(model, "model_dump"): return model.model_dump( @@ -148,13 +149,12 @@ def model_dump( exclude_defaults=exclude_defaults, # warnings are not supported in Pydantic v1 warnings=True if PYDANTIC_V1 else warnings, + by_alias=by_alias, ) return cast( "dict[str, Any]", model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast] - exclude=exclude, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, + exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, by_alias=bool(by_alias) ), ) diff --git a/src/prelude_python_sdk/_models.py b/src/prelude_python_sdk/_models.py index ca9500b..29070e0 100644 --- a/src/prelude_python_sdk/_models.py +++ b/src/prelude_python_sdk/_models.py @@ -3,7 +3,20 @@ import os import inspect import weakref -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast +from typing import ( + IO, + TYPE_CHECKING, + Any, + Type, + Union, + Generic, + TypeVar, + Callable, + Iterable, + Optional, + AsyncIterable, + cast, +) from datetime import date, datetime from typing_extensions import ( List, @@ -787,6 +800,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): timeout: float | Timeout | None files: HttpxRequestFiles | None idempotency_key: str + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] json_data: Body extra_json: AnyMapping follow_redirects: bool @@ -805,6 +819,7 @@ class FinalRequestOptions(pydantic.BaseModel): post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() follow_redirects: Union[bool, None] = None + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None # It should be noted that we cannot use `json` here as that would override # a BaseModel method in an incompatible fashion. json_data: Union[Body, None] = None diff --git a/src/prelude_python_sdk/_response.py b/src/prelude_python_sdk/_response.py index e6e260d..f8c8d96 100644 --- a/src/prelude_python_sdk/_response.py +++ b/src/prelude_python_sdk/_response.py @@ -152,6 +152,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: ), response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) @@ -162,6 +163,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: cast_to=extract_stream_chunk_type(self._stream_cls), response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) @@ -175,6 +177,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: cast_to=cast_to, response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) diff --git a/src/prelude_python_sdk/_streaming.py b/src/prelude_python_sdk/_streaming.py index 13f497a..0be8b8c 100644 --- a/src/prelude_python_sdk/_streaming.py +++ b/src/prelude_python_sdk/_streaming.py @@ -4,7 +4,7 @@ import json import inspect from types import TracebackType -from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, AsyncIterator, cast +from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, Optional, AsyncIterator, cast from typing_extensions import Self, Protocol, TypeGuard, override, get_origin, runtime_checkable import httpx @@ -13,6 +13,7 @@ if TYPE_CHECKING: from ._client import Prelude, AsyncPrelude + from ._models import FinalRequestOptions _T = TypeVar("_T") @@ -22,7 +23,7 @@ class Stream(Generic[_T]): """Provides the core interface to iterate over a synchronous stream response.""" response: httpx.Response - + _options: Optional[FinalRequestOptions] = None _decoder: SSEBytesDecoder def __init__( @@ -31,10 +32,12 @@ def __init__( cast_to: type[_T], response: httpx.Response, client: Prelude, + options: Optional[FinalRequestOptions] = None, ) -> None: self.response = response self._cast_to = cast_to self._client = client + self._options = options self._decoder = client._make_sse_decoder() self._iterator = self.__stream__() @@ -85,7 +88,7 @@ class AsyncStream(Generic[_T]): """Provides the core interface to iterate over an asynchronous stream response.""" response: httpx.Response - + _options: Optional[FinalRequestOptions] = None _decoder: SSEDecoder | SSEBytesDecoder def __init__( @@ -94,10 +97,12 @@ def __init__( cast_to: type[_T], response: httpx.Response, client: AsyncPrelude, + options: Optional[FinalRequestOptions] = None, ) -> None: self.response = response self._cast_to = cast_to self._client = client + self._options = options self._decoder = client._make_sse_decoder() self._iterator = self.__stream__() diff --git a/src/prelude_python_sdk/_types.py b/src/prelude_python_sdk/_types.py index e7c46a0..b58f49d 100644 --- a/src/prelude_python_sdk/_types.py +++ b/src/prelude_python_sdk/_types.py @@ -13,9 +13,11 @@ Mapping, TypeVar, Callable, + Iterable, Iterator, Optional, Sequence, + AsyncIterable, ) from typing_extensions import ( Set, @@ -56,6 +58,13 @@ else: Base64FileInput = Union[IO[bytes], PathLike] FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8. + + +# Used for sending raw binary data / streaming data in request bodies +# e.g. for file uploads without multipart encoding +BinaryTypes = Union[bytes, bytearray, IO[bytes], Iterable[bytes]] +AsyncBinaryTypes = Union[bytes, bytearray, IO[bytes], AsyncIterable[bytes]] + FileTypes = Union[ # file (or bytes) FileContent, @@ -243,6 +252,9 @@ class HttpxSendArgs(TypedDict, total=False): if TYPE_CHECKING: # This works because str.__contains__ does not accept object (either in typeshed or at runtime) # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285 + # + # Note: index() and count() methods are intentionally omitted to allow pyright to properly + # infer TypedDict types when dict literals are used in lists assigned to SequenceNotStr. class SequenceNotStr(Protocol[_T_co]): @overload def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... @@ -251,8 +263,6 @@ def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... def __contains__(self, value: object, /) -> bool: ... def __len__(self) -> int: ... def __iter__(self) -> Iterator[_T_co]: ... - def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ... - def count(self, value: Any, /) -> int: ... def __reversed__(self) -> Iterator[_T_co]: ... else: # just point this to a normal `Sequence` at runtime to avoid having to special case diff --git a/src/prelude_python_sdk/_utils/_compat.py b/src/prelude_python_sdk/_utils/_compat.py index dd70323..2c70b29 100644 --- a/src/prelude_python_sdk/_utils/_compat.py +++ b/src/prelude_python_sdk/_utils/_compat.py @@ -26,7 +26,7 @@ def is_union(tp: Optional[Type[Any]]) -> bool: else: import types - return tp is Union or tp is types.UnionType + return tp is Union or tp is types.UnionType # type: ignore[comparison-overlap] def is_typeddict(tp: Type[Any]) -> bool: diff --git a/src/prelude_python_sdk/_utils/_json.py b/src/prelude_python_sdk/_utils/_json.py new file mode 100644 index 0000000..6058421 --- /dev/null +++ b/src/prelude_python_sdk/_utils/_json.py @@ -0,0 +1,35 @@ +import json +from typing import Any +from datetime import datetime +from typing_extensions import override + +import pydantic + +from .._compat import model_dump + + +def openapi_dumps(obj: Any) -> bytes: + """ + Serialize an object to UTF-8 encoded JSON bytes. + + Extends the standard json.dumps with support for additional types + commonly used in the SDK, such as `datetime`, `pydantic.BaseModel`, etc. + """ + return json.dumps( + obj, + cls=_CustomEncoder, + # Uses the same defaults as httpx's JSON serialization + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ).encode() + + +class _CustomEncoder(json.JSONEncoder): + @override + def default(self, o: Any) -> Any: + if isinstance(o, datetime): + return o.isoformat() + if isinstance(o, pydantic.BaseModel): + return model_dump(o, exclude_unset=True, mode="json", by_alias=True) + return super().default(o) diff --git a/src/prelude_python_sdk/_version.py b/src/prelude_python_sdk/_version.py index 6d65db6..3f45685 100644 --- a/src/prelude_python_sdk/_version.py +++ b/src/prelude_python_sdk/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "prelude_python_sdk" -__version__ = "0.10.0" # x-release-please-version +__version__ = "0.11.0" # x-release-please-version diff --git a/src/prelude_python_sdk/resources/lookup.py b/src/prelude_python_sdk/resources/lookup.py index a876d32..7f29a9e 100644 --- a/src/prelude_python_sdk/resources/lookup.py +++ b/src/prelude_python_sdk/resources/lookup.py @@ -25,6 +25,10 @@ class LookupResource(SyncAPIResource): + """ + Retrieve detailed information about a phone number including carrier data, line type, and portability status. + """ + @cached_property def with_raw_response(self) -> LookupResourceWithRawResponse: """ @@ -93,6 +97,10 @@ def lookup( class AsyncLookupResource(AsyncAPIResource): + """ + Retrieve detailed information about a phone number including carrier data, line type, and portability status. + """ + @cached_property def with_raw_response(self) -> AsyncLookupResourceWithRawResponse: """ diff --git a/src/prelude_python_sdk/resources/notify.py b/src/prelude_python_sdk/resources/notify.py index 70d2366..f810768 100644 --- a/src/prelude_python_sdk/resources/notify.py +++ b/src/prelude_python_sdk/resources/notify.py @@ -40,6 +40,8 @@ class NotifyResource(SyncAPIResource): + """Send transactional and marketing messages with compliance enforcement.""" + @cached_property def with_raw_response(self) -> NotifyResourceWithRawResponse: """ @@ -297,6 +299,7 @@ def send( to: str, callback_url: str | Omit = omit, correlation_id: str | Omit = omit, + document: notify_send_params.Document | Omit = omit, expires_at: Union[str, datetime] | Omit = omit, from_: str | Omit = omit, locale: str | Omit = omit, @@ -325,6 +328,9 @@ def send( It is returned in the response and any webhook events that refer to this message. + document: A document to attach to the message. Only supported on WhatsApp templates that + have a document header. + expires_at: The message expiration date in RFC3339 format. The message will not be sent if this time is reached. @@ -360,6 +366,7 @@ def send( "to": to, "callback_url": callback_url, "correlation_id": correlation_id, + "document": document, "expires_at": expires_at, "from_": from_, "locale": locale, @@ -382,6 +389,7 @@ def send_batch( to: SequenceNotStr[str], callback_url: str | Omit = omit, correlation_id: str | Omit = omit, + document: notify_send_batch_params.Document | Omit = omit, expires_at: Union[str, datetime] | Omit = omit, from_: str | Omit = omit, locale: str | Omit = omit, @@ -407,6 +415,9 @@ def send_batch( correlation_id: A user-defined identifier to correlate this request with your internal systems. + document: A document to attach to the message. Only supported on WhatsApp templates that + have a document header. + expires_at: The message expiration date in RFC3339 format. Messages will not be sent after this time. @@ -437,6 +448,7 @@ def send_batch( "to": to, "callback_url": callback_url, "correlation_id": correlation_id, + "document": document, "expires_at": expires_at, "from_": from_, "locale": locale, @@ -454,6 +466,8 @@ def send_batch( class AsyncNotifyResource(AsyncAPIResource): + """Send transactional and marketing messages with compliance enforcement.""" + @cached_property def with_raw_response(self) -> AsyncNotifyResourceWithRawResponse: """ @@ -711,6 +725,7 @@ async def send( to: str, callback_url: str | Omit = omit, correlation_id: str | Omit = omit, + document: notify_send_params.Document | Omit = omit, expires_at: Union[str, datetime] | Omit = omit, from_: str | Omit = omit, locale: str | Omit = omit, @@ -739,6 +754,9 @@ async def send( It is returned in the response and any webhook events that refer to this message. + document: A document to attach to the message. Only supported on WhatsApp templates that + have a document header. + expires_at: The message expiration date in RFC3339 format. The message will not be sent if this time is reached. @@ -774,6 +792,7 @@ async def send( "to": to, "callback_url": callback_url, "correlation_id": correlation_id, + "document": document, "expires_at": expires_at, "from_": from_, "locale": locale, @@ -796,6 +815,7 @@ async def send_batch( to: SequenceNotStr[str], callback_url: str | Omit = omit, correlation_id: str | Omit = omit, + document: notify_send_batch_params.Document | Omit = omit, expires_at: Union[str, datetime] | Omit = omit, from_: str | Omit = omit, locale: str | Omit = omit, @@ -821,6 +841,9 @@ async def send_batch( correlation_id: A user-defined identifier to correlate this request with your internal systems. + document: A document to attach to the message. Only supported on WhatsApp templates that + have a document header. + expires_at: The message expiration date in RFC3339 format. Messages will not be sent after this time. @@ -851,6 +874,7 @@ async def send_batch( "to": to, "callback_url": callback_url, "correlation_id": correlation_id, + "document": document, "expires_at": expires_at, "from_": from_, "locale": locale, diff --git a/src/prelude_python_sdk/resources/transactional.py b/src/prelude_python_sdk/resources/transactional.py index 150e6ad..562c053 100644 --- a/src/prelude_python_sdk/resources/transactional.py +++ b/src/prelude_python_sdk/resources/transactional.py @@ -26,6 +26,8 @@ class TransactionalResource(SyncAPIResource): + """Send transactional messages (deprecated - use Notify API instead).""" + @cached_property def with_raw_response(self) -> TransactionalResourceWithRawResponse: """ @@ -53,6 +55,7 @@ def send( to: str, callback_url: str | Omit = omit, correlation_id: str | Omit = omit, + document: transactional_send_params.Document | Omit = omit, expires_at: str | Omit = omit, from_: str | Omit = omit, locale: str | Omit = omit, @@ -81,6 +84,9 @@ def send( returned in the response and any webhook events that refer to this transactionalmessage. + document: A document to attach to the message. Only supported on WhatsApp templates that + have a document header. + expires_at: The message expiration date. from_: The Sender ID. @@ -118,6 +124,7 @@ def send( "to": to, "callback_url": callback_url, "correlation_id": correlation_id, + "document": document, "expires_at": expires_at, "from_": from_, "locale": locale, @@ -134,6 +141,8 @@ def send( class AsyncTransactionalResource(AsyncAPIResource): + """Send transactional messages (deprecated - use Notify API instead).""" + @cached_property def with_raw_response(self) -> AsyncTransactionalResourceWithRawResponse: """ @@ -161,6 +170,7 @@ async def send( to: str, callback_url: str | Omit = omit, correlation_id: str | Omit = omit, + document: transactional_send_params.Document | Omit = omit, expires_at: str | Omit = omit, from_: str | Omit = omit, locale: str | Omit = omit, @@ -189,6 +199,9 @@ async def send( returned in the response and any webhook events that refer to this transactionalmessage. + document: A document to attach to the message. Only supported on WhatsApp templates that + have a document header. + expires_at: The message expiration date. from_: The Sender ID. @@ -226,6 +239,7 @@ async def send( "to": to, "callback_url": callback_url, "correlation_id": correlation_id, + "document": document, "expires_at": expires_at, "from_": from_, "locale": locale, diff --git a/src/prelude_python_sdk/resources/verification.py b/src/prelude_python_sdk/resources/verification.py index 7b47cd2..d86bca8 100644 --- a/src/prelude_python_sdk/resources/verification.py +++ b/src/prelude_python_sdk/resources/verification.py @@ -23,6 +23,8 @@ class VerificationResource(SyncAPIResource): + """Verify phone numbers.""" + @cached_property def with_raw_response(self) -> VerificationResourceWithRawResponse: """ @@ -149,6 +151,8 @@ def check( class AsyncVerificationResource(AsyncAPIResource): + """Verify phone numbers.""" + @cached_property def with_raw_response(self) -> AsyncVerificationResourceWithRawResponse: """ diff --git a/src/prelude_python_sdk/resources/verification_management.py b/src/prelude_python_sdk/resources/verification_management.py index b9f0b61..a682543 100644 --- a/src/prelude_python_sdk/resources/verification_management.py +++ b/src/prelude_python_sdk/resources/verification_management.py @@ -32,6 +32,8 @@ class VerificationManagementResource(SyncAPIResource): + """Verify phone numbers.""" + @cached_property def with_raw_response(self) -> VerificationManagementResourceWithRawResponse: """ @@ -242,6 +244,8 @@ def submit_sender_id( class AsyncVerificationManagementResource(AsyncAPIResource): + """Verify phone numbers.""" + @cached_property def with_raw_response(self) -> AsyncVerificationManagementResourceWithRawResponse: """ diff --git a/src/prelude_python_sdk/resources/watch.py b/src/prelude_python_sdk/resources/watch.py index ef12237..cdce1e5 100644 --- a/src/prelude_python_sdk/resources/watch.py +++ b/src/prelude_python_sdk/resources/watch.py @@ -26,6 +26,8 @@ class WatchResource(SyncAPIResource): + """Evaluate email addresses and phone numbers for trustworthiness.""" + @cached_property def with_raw_response(self) -> WatchResourceWithRawResponse: """ @@ -170,6 +172,8 @@ def send_feedbacks( class AsyncWatchResource(AsyncAPIResource): + """Evaluate email addresses and phone numbers for trustworthiness.""" + @cached_property def with_raw_response(self) -> AsyncWatchResourceWithRawResponse: """ diff --git a/src/prelude_python_sdk/types/lookup_lookup_response.py b/src/prelude_python_sdk/types/lookup_lookup_response.py index f585823..48eedb6 100644 --- a/src/prelude_python_sdk/types/lookup_lookup_response.py +++ b/src/prelude_python_sdk/types/lookup_lookup_response.py @@ -9,6 +9,8 @@ class NetworkInfo(BaseModel): + """The current carrier information.""" + carrier_name: Optional[str] = None """The name of the carrier.""" @@ -20,6 +22,8 @@ class NetworkInfo(BaseModel): class OriginalNetworkInfo(BaseModel): + """The original carrier information.""" + carrier_name: Optional[str] = None """The name of the original carrier.""" diff --git a/src/prelude_python_sdk/types/notify_get_subscription_config_response.py b/src/prelude_python_sdk/types/notify_get_subscription_config_response.py index c6ec2cd..90ada18 100644 --- a/src/prelude_python_sdk/types/notify_get_subscription_config_response.py +++ b/src/prelude_python_sdk/types/notify_get_subscription_config_response.py @@ -9,6 +9,8 @@ class Messages(BaseModel): + """The subscription messages configuration.""" + help_message: Optional[str] = None """Message sent when user requests help.""" diff --git a/src/prelude_python_sdk/types/notify_list_subscription_configs_response.py b/src/prelude_python_sdk/types/notify_list_subscription_configs_response.py index eaa7947..b126ef1 100644 --- a/src/prelude_python_sdk/types/notify_list_subscription_configs_response.py +++ b/src/prelude_python_sdk/types/notify_list_subscription_configs_response.py @@ -9,6 +9,8 @@ class ConfigMessages(BaseModel): + """The subscription messages configuration.""" + help_message: Optional[str] = None """Message sent when user requests help.""" diff --git a/src/prelude_python_sdk/types/notify_send_batch_params.py b/src/prelude_python_sdk/types/notify_send_batch_params.py index c38b667..cf0398f 100644 --- a/src/prelude_python_sdk/types/notify_send_batch_params.py +++ b/src/prelude_python_sdk/types/notify_send_batch_params.py @@ -9,7 +9,7 @@ from .._types import SequenceNotStr from .._utils import PropertyInfo -__all__ = ["NotifySendBatchParams"] +__all__ = ["NotifySendBatchParams", "Document"] class NotifySendBatchParams(TypedDict, total=False): @@ -25,6 +25,12 @@ class NotifySendBatchParams(TypedDict, total=False): correlation_id: str """A user-defined identifier to correlate this request with your internal systems.""" + document: Document + """A document to attach to the message. + + Only supported on WhatsApp templates that have a document header. + """ + expires_at: Annotated[Union[str, datetime], PropertyInfo(format="iso8601")] """The message expiration date in RFC3339 format. @@ -48,3 +54,16 @@ class NotifySendBatchParams(TypedDict, total=False): variables: Dict[str, str] """The variables to be replaced in the template.""" + + +class Document(TypedDict, total=False): + """A document to attach to the message. + + Only supported on WhatsApp templates that have a document header. + """ + + filename: Required[str] + """The filename to display for the document.""" + + url: Required[str] + """The URL of the document to attach. Must be a valid HTTP or HTTPS URL.""" diff --git a/src/prelude_python_sdk/types/notify_send_batch_response.py b/src/prelude_python_sdk/types/notify_send_batch_response.py index 0130b74..34af373 100644 --- a/src/prelude_python_sdk/types/notify_send_batch_response.py +++ b/src/prelude_python_sdk/types/notify_send_batch_response.py @@ -2,6 +2,7 @@ from typing import Dict, List, Optional from datetime import datetime +from typing_extensions import Literal from pydantic import Field as FieldInfo @@ -11,6 +12,8 @@ class ResultError(BaseModel): + """Present only if success is false.""" + code: Optional[str] = None """The error code.""" @@ -19,6 +22,8 @@ class ResultError(BaseModel): class ResultMessage(BaseModel): + """Present only if success is true.""" + id: Optional[str] = None """The message identifier.""" @@ -28,6 +33,21 @@ class ResultMessage(BaseModel): created_at: Optional[datetime] = None """The message creation date in RFC3339 format.""" + encoding: Optional[Literal["GSM-7", "UCS-2"]] = None + """The SMS encoding type based on message content. + + GSM-7 supports standard characters (up to 160 chars per segment), while UCS-2 + supports Unicode including emoji (up to 70 chars per segment). Only present for + SMS messages. + """ + + estimated_segment_count: Optional[int] = None + """The estimated number of SMS segments for this message. + + This value is not contractual; the actual segment count will be determined after + the SMS is sent by the provider. Only present for SMS messages. + """ + expires_at: Optional[datetime] = None """The message expiration date in RFC3339 format.""" diff --git a/src/prelude_python_sdk/types/notify_send_params.py b/src/prelude_python_sdk/types/notify_send_params.py index afc2f9d..ff79d80 100644 --- a/src/prelude_python_sdk/types/notify_send_params.py +++ b/src/prelude_python_sdk/types/notify_send_params.py @@ -8,7 +8,7 @@ from .._utils import PropertyInfo -__all__ = ["NotifySendParams"] +__all__ = ["NotifySendParams", "Document"] class NotifySendParams(TypedDict, total=False): @@ -28,6 +28,12 @@ class NotifySendParams(TypedDict, total=False): message. """ + document: Document + """A document to attach to the message. + + Only supported on WhatsApp templates that have a document header. + """ + expires_at: Annotated[Union[str, datetime], PropertyInfo(format="iso8601")] """The message expiration date in RFC3339 format. @@ -61,3 +67,16 @@ class NotifySendParams(TypedDict, total=False): variables: Dict[str, str] """The variables to be replaced in the template.""" + + +class Document(TypedDict, total=False): + """A document to attach to the message. + + Only supported on WhatsApp templates that have a document header. + """ + + filename: Required[str] + """The filename to display for the document.""" + + url: Required[str] + """The URL of the document to attach. Must be a valid HTTP or HTTPS URL.""" diff --git a/src/prelude_python_sdk/types/notify_send_response.py b/src/prelude_python_sdk/types/notify_send_response.py index c554c0e..f19a0f2 100644 --- a/src/prelude_python_sdk/types/notify_send_response.py +++ b/src/prelude_python_sdk/types/notify_send_response.py @@ -2,6 +2,7 @@ from typing import Dict, Optional from datetime import datetime +from typing_extensions import Literal from pydantic import Field as FieldInfo @@ -35,6 +36,21 @@ class NotifySendResponse(BaseModel): correlation_id: Optional[str] = None """A user-defined identifier to correlate this message with your internal systems.""" + encoding: Optional[Literal["GSM-7", "UCS-2"]] = None + """The SMS encoding type based on message content. + + GSM-7 supports standard characters (up to 160 chars per segment), while UCS-2 + supports Unicode including emoji (up to 70 chars per segment). Only present for + SMS messages. + """ + + estimated_segment_count: Optional[int] = None + """The estimated number of SMS segments for this message. + + This value is not contractual; the actual segment count will be determined after + the SMS is sent by the provider. Only present for SMS messages. + """ + from_: Optional[str] = FieldInfo(alias="from", default=None) """The Sender ID used for this message.""" diff --git a/src/prelude_python_sdk/types/transactional_send_params.py b/src/prelude_python_sdk/types/transactional_send_params.py index 97f176b..00f19a1 100644 --- a/src/prelude_python_sdk/types/transactional_send_params.py +++ b/src/prelude_python_sdk/types/transactional_send_params.py @@ -7,7 +7,7 @@ from .._utils import PropertyInfo -__all__ = ["TransactionalSendParams"] +__all__ = ["TransactionalSendParams", "Document"] class TransactionalSendParams(TypedDict, total=False): @@ -27,6 +27,12 @@ class TransactionalSendParams(TypedDict, total=False): transactionalmessage. """ + document: Document + """A document to attach to the message. + + Only supported on WhatsApp templates that have a document header. + """ + expires_at: str """The message expiration date.""" @@ -56,3 +62,16 @@ class TransactionalSendParams(TypedDict, total=False): variables: Dict[str, str] """The variables to be replaced in the template.""" + + +class Document(TypedDict, total=False): + """A document to attach to the message. + + Only supported on WhatsApp templates that have a document header. + """ + + filename: Required[str] + """The filename to display for the document.""" + + url: Required[str] + """The URL of the document to attach. Must be a valid HTTP or HTTPS URL.""" diff --git a/src/prelude_python_sdk/types/verification_check_params.py b/src/prelude_python_sdk/types/verification_check_params.py index dea9702..3da954d 100644 --- a/src/prelude_python_sdk/types/verification_check_params.py +++ b/src/prelude_python_sdk/types/verification_check_params.py @@ -20,6 +20,11 @@ class VerificationCheckParams(TypedDict, total=False): class Target(TypedDict, total=False): + """The verification target. + + Either a phone number or an email address. To use the email verification feature contact us to discuss your use case. + """ + type: Required[Literal["phone_number", "email_address"]] """The type of the target. Either "phone_number" or "email_address".""" diff --git a/src/prelude_python_sdk/types/verification_check_response.py b/src/prelude_python_sdk/types/verification_check_response.py index 28754e2..96ba4f9 100644 --- a/src/prelude_python_sdk/types/verification_check_response.py +++ b/src/prelude_python_sdk/types/verification_check_response.py @@ -9,6 +9,8 @@ class Metadata(BaseModel): + """The metadata for this verification.""" + correlation_id: Optional[str] = None """A user-defined identifier to correlate this verification with. diff --git a/src/prelude_python_sdk/types/verification_create_params.py b/src/prelude_python_sdk/types/verification_create_params.py index eba2a33..152385f 100644 --- a/src/prelude_python_sdk/types/verification_create_params.py +++ b/src/prelude_python_sdk/types/verification_create_params.py @@ -38,6 +38,11 @@ class VerificationCreateParams(TypedDict, total=False): class Target(TypedDict, total=False): + """The verification target. + + Either a phone number or an email address. To use the email verification feature contact us to discuss your use case. + """ + type: Required[Literal["phone_number", "email_address"]] """The type of the target. Either "phone_number" or "email_address".""" @@ -46,6 +51,11 @@ class Target(TypedDict, total=False): class Metadata(TypedDict, total=False): + """The metadata for this verification. + + This object will be returned with every response or webhook sent that refers to this verification. + """ + correlation_id: str """A user-defined identifier to correlate this verification with. @@ -55,25 +65,34 @@ class Metadata(TypedDict, total=False): class OptionsAppRealm(TypedDict, total=False): - platform: Required[Literal["android"]] - """The platform the SMS will be sent to. + """This allows automatic OTP retrieval on mobile apps and web browsers. - We are currently only supporting "android". + Supported platforms are Android (SMS Retriever API) and Web (WebOTP API). """ - value: Required[str] + platform: Required[Literal["android", "web"]] + """The platform for automatic OTP retrieval. + + Use "android" for the SMS Retriever API or "web" for the WebOTP API. """ - The Android SMS Retriever API hash code that identifies your app. For more - information, see - [Google documentation](https://developers.google.com/identity/sms-retriever/verify#computing_your_apps_hash_string). + + value: Required[str] + """The value depends on the platform: + + - For Android: The SMS Retriever API hash code (11 characters). See + [Google documentation](https://developers.google.com/identity/sms-retriever/verify#computing_your_apps_hash_string). + - For Web: The origin domain (e.g., "example.com" or "www.example.com"). See + [WebOTP API documentation](https://developer.mozilla.org/en-US/docs/Web/API/WebOTP_API). """ class Options(TypedDict, total=False): + """Verification options""" + app_realm: OptionsAppRealm - """This allows you to automatically retrieve and fill the OTP code on mobile apps. + """This allows automatic OTP retrieval on mobile apps and web browsers. - Currently only Android devices are supported. + Supported platforms are Android (SMS Retriever API) and Web (WebOTP API). """ callback_url: str @@ -97,9 +116,6 @@ class Options(TypedDict, total=False): more details, refer to [Custom Code](/verify/v2/documentation/custom-codes). """ - integration: Literal["auth0", "supabase"] - """The integration that triggered the verification.""" - locale: str """ A BCP-47 formatted locale string with the language the text message will be sent @@ -140,13 +156,19 @@ class Options(TypedDict, total=False): class Signals(TypedDict, total=False): + """The signals used for anti-fraud. + + For more details, refer to [Signals](/verify/v2/documentation/prevent-fraud#signals). + """ + app_version: str """The version of your application.""" device_id: str - """The unique identifier for the user's device. + """A unique ID for the user's device. - For Android, this corresponds to the `ANDROID_ID` and for iOS, this corresponds + You should ensure that each user device has a unique `device_id` value. Ideally, + for Android, this corresponds to the `ANDROID_ID` and for iOS, this corresponds to the `identifierForVendor`. """ @@ -157,21 +179,26 @@ class Signals(TypedDict, total=False): """The type of the user's device.""" ip: str - """The IP address of the user's device.""" + """The public IP v4 or v6 address of the end-user's device. + + You should collect this from your backend. If your backend is behind a proxy, + use the `X-Forwarded-For`, `Forwarded`, `True-Client-IP`, `CF-Connecting-IP` or + an equivalent header to get the actual public IP of the end-user's device. + """ is_trusted_user: bool """ - This signal should provide a higher level of trust, indicating that the user is - genuine. Contact us to discuss your use case. For more details, refer to + This signal should indicate a higher level of trust, explicitly stating that the + user is genuine. Contact us to discuss your use case. For more details, refer to [Signals](/verify/v2/documentation/prevent-fraud#signals). """ ja4_fingerprint: str - """The JA4 fingerprint observed for the connection. + """The JA4 fingerprint observed for the end-user's connection. - Prelude will infer it automatically when requests go through our client SDK - (which uses Prelude's edge), but you can also provide it explicitly if you - terminate TLS yourself. + Prelude will infer it automatically when you use our Frontend SDKs (which use + Prelude's edge network), but you can also forward the value if you terminate TLS + yourself. """ os_version: str diff --git a/src/prelude_python_sdk/types/verification_create_response.py b/src/prelude_python_sdk/types/verification_create_response.py index 91e02bc..f187077 100644 --- a/src/prelude_python_sdk/types/verification_create_response.py +++ b/src/prelude_python_sdk/types/verification_create_response.py @@ -9,6 +9,8 @@ class Metadata(BaseModel): + """The metadata for this verification.""" + correlation_id: Optional[str] = None """A user-defined identifier to correlate this verification with. @@ -18,6 +20,8 @@ class Metadata(BaseModel): class Silent(BaseModel): + """The silent verification specific properties.""" + request_url: str """The URL to start the silent verification towards.""" @@ -29,8 +33,16 @@ class VerificationCreateResponse(BaseModel): method: Literal["email", "message", "silent", "voice"] """The method used for verifying this phone number.""" - status: Literal["success", "retry", "blocked"] - """The status of the verification.""" + status: Literal["success", "retry", "challenged", "blocked"] + """The status of the verification. + + - `success` - A new verification window was created. + - `retry` - A new attempt was created for an existing verification window. + - `challenged` - The verification is suspicious and is restricted to non-SMS and + non-voice channels only. This mode must be enabled for your customer account + by Prelude support. + - `blocked` - The verification was blocked. + """ channels: Optional[List[Literal["rcs", "silent", "sms", "telegram", "viber", "voice", "whatsapp", "zalo"]]] = None """The ordered sequence of channels to be used for verification""" diff --git a/src/prelude_python_sdk/types/verification_management_list_sender_ids_response.py b/src/prelude_python_sdk/types/verification_management_list_sender_ids_response.py index b918111..8ab4ef8 100644 --- a/src/prelude_python_sdk/types/verification_management_list_sender_ids_response.py +++ b/src/prelude_python_sdk/types/verification_management_list_sender_ids_response.py @@ -22,4 +22,6 @@ class SenderID(BaseModel): class VerificationManagementListSenderIDsResponse(BaseModel): + """A list of Sender ID.""" + sender_ids: Optional[List[SenderID]] = None diff --git a/src/prelude_python_sdk/types/watch_predict_params.py b/src/prelude_python_sdk/types/watch_predict_params.py index b341838..18bfb76 100644 --- a/src/prelude_python_sdk/types/watch_predict_params.py +++ b/src/prelude_python_sdk/types/watch_predict_params.py @@ -26,6 +26,8 @@ class WatchPredictParams(TypedDict, total=False): class Target(TypedDict, total=False): + """The prediction target. Only supports phone numbers for now.""" + type: Required[Literal["phone_number", "email_address"]] """The type of the target. Either "phone_number" or "email_address".""" @@ -34,6 +36,8 @@ class Target(TypedDict, total=False): class Metadata(TypedDict, total=False): + """The metadata for this prediction.""" + correlation_id: str """A user-defined identifier to correlate this prediction with. @@ -43,13 +47,19 @@ class Metadata(TypedDict, total=False): class Signals(TypedDict, total=False): + """The signals used for anti-fraud. + + For more details, refer to [Signals](/verify/v2/documentation/prevent-fraud#signals). + """ + app_version: str """The version of your application.""" device_id: str - """The unique identifier for the user's device. + """A unique ID for the user's device. - For Android, this corresponds to the `ANDROID_ID` and for iOS, this corresponds + You should ensure that each user device has a unique `device_id` value. Ideally, + for Android, this corresponds to the `ANDROID_ID` and for iOS, this corresponds to the `identifierForVendor`. """ @@ -60,21 +70,26 @@ class Signals(TypedDict, total=False): """The type of the user's device.""" ip: str - """The IP address of the user's device.""" + """The public IP v4 or v6 address of the end-user's device. + + You should collect this from your backend. If your backend is behind a proxy, + use the `X-Forwarded-For`, `Forwarded`, `True-Client-IP`, `CF-Connecting-IP` or + an equivalent header to get the actual public IP of the end-user's device. + """ is_trusted_user: bool """ - This signal should provide a higher level of trust, indicating that the user is - genuine. Contact us to discuss your use case. For more details, refer to + This signal should indicate a higher level of trust, explicitly stating that the + user is genuine. Contact us to discuss your use case. For more details, refer to [Signals](/verify/v2/documentation/prevent-fraud#signals). """ ja4_fingerprint: str - """The JA4 fingerprint observed for the connection. + """The JA4 fingerprint observed for the end-user's connection. - Prelude will infer it automatically when requests go through our client SDK - (which uses Prelude's edge), but you can also provide it explicitly if you - terminate TLS yourself. + Prelude will infer it automatically when you use our Frontend SDKs (which use + Prelude's edge network), but you can also forward the value if you terminate TLS + yourself. """ os_version: str diff --git a/src/prelude_python_sdk/types/watch_predict_response.py b/src/prelude_python_sdk/types/watch_predict_response.py index 0bd330c..906108d 100644 --- a/src/prelude_python_sdk/types/watch_predict_response.py +++ b/src/prelude_python_sdk/types/watch_predict_response.py @@ -1,5 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from typing import List, Optional from typing_extensions import Literal from .._models import BaseModel @@ -19,3 +20,46 @@ class WatchPredictResponse(BaseModel): Report it back to us to help us diagnose your issues. """ + + risk_factors: Optional[ + List[ + Literal[ + "behavioral_pattern", + "device_attribute", + "fraud_database", + "location_discrepancy", + "network_fingerprint", + "poor_conversion_history", + "prefix_concentration", + "suspected_request_tampering", + "suspicious_ip_address", + "temporary_phone_number", + ] + ] + ] = None + """The risk factors that contributed to the suspicious prediction. + + Only present when prediction is "suspicious" and the anti-fraud system detected + specific risk signals. + + - `behavioral_pattern` - The phone number past behavior during verification + flows exhibits suspicious patterns. + - `device_attribute` - The device exhibits characteristics associated with + suspicious activity patterns. + - `fraud_database` - The phone number has been flagged as suspicious in one or + more of our fraud databases. + - `location_discrepancy` - The phone number prefix and IP address discrepancy + indicates potential fraud. + - `network_fingerprint` - The network connection exhibits characteristics + associated with suspicious activity patterns. + - `poor_conversion_history` - The phone number has a history of poorly + converting to a verified phone number. + - `prefix_concentration` - The phone number is part of a range known to be + associated with suspicious activity patterns. + - `suspected_request_tampering` - The SDK signature is invalid and the request + is considered to be tampered with. + - `suspicious_ip_address` - The IP address is deemed to be associated with + suspicious activity patterns. + - `temporary_phone_number` - The phone number is known to be a temporary or + disposable number. + """ diff --git a/src/prelude_python_sdk/types/watch_send_events_params.py b/src/prelude_python_sdk/types/watch_send_events_params.py index ab5b492..949b93d 100644 --- a/src/prelude_python_sdk/types/watch_send_events_params.py +++ b/src/prelude_python_sdk/types/watch_send_events_params.py @@ -14,6 +14,8 @@ class WatchSendEventsParams(TypedDict, total=False): class EventTarget(TypedDict, total=False): + """The event target. Only supports phone numbers for now.""" + type: Required[Literal["phone_number", "email_address"]] """The type of the target. Either "phone_number" or "email_address".""" diff --git a/src/prelude_python_sdk/types/watch_send_feedbacks_params.py b/src/prelude_python_sdk/types/watch_send_feedbacks_params.py index 1367260..595164c 100644 --- a/src/prelude_python_sdk/types/watch_send_feedbacks_params.py +++ b/src/prelude_python_sdk/types/watch_send_feedbacks_params.py @@ -5,7 +5,7 @@ from typing import Iterable from typing_extensions import Literal, Required, TypedDict -__all__ = ["WatchSendFeedbacksParams", "Feedback", "FeedbackTarget", "FeedbackMetadata", "FeedbackSignals"] +__all__ = ["WatchSendFeedbacksParams", "Feedback", "FeedbackTarget", "FeedbackMetadata"] class WatchSendFeedbacksParams(TypedDict, total=False): @@ -14,6 +14,8 @@ class WatchSendFeedbacksParams(TypedDict, total=False): class FeedbackTarget(TypedDict, total=False): + """The feedback target. Only supports phone numbers for now.""" + type: Required[Literal["phone_number", "email_address"]] """The type of the target. Either "phone_number" or "email_address".""" @@ -22,6 +24,8 @@ class FeedbackTarget(TypedDict, total=False): class FeedbackMetadata(TypedDict, total=False): + """The metadata for this feedback.""" + correlation_id: str """A user-defined identifier to correlate this feedback with. @@ -30,53 +34,6 @@ class FeedbackMetadata(TypedDict, total=False): """ -class FeedbackSignals(TypedDict, total=False): - app_version: str - """The version of your application.""" - - device_id: str - """The unique identifier for the user's device. - - For Android, this corresponds to the `ANDROID_ID` and for iOS, this corresponds - to the `identifierForVendor`. - """ - - device_model: str - """The model of the user's device.""" - - device_platform: Literal["android", "ios", "ipados", "tvos", "web"] - """The type of the user's device.""" - - ip: str - """The IP address of the user's device.""" - - is_trusted_user: bool - """ - This signal should provide a higher level of trust, indicating that the user is - genuine. Contact us to discuss your use case. For more details, refer to - [Signals](/verify/v2/documentation/prevent-fraud#signals). - """ - - ja4_fingerprint: str - """The JA4 fingerprint observed for the connection. - - Prelude will infer it automatically when requests go through our client SDK - (which uses Prelude's edge), but you can also provide it explicitly if you - terminate TLS yourself. - """ - - os_version: str - """The version of the user's device operating system.""" - - user_agent: str - """The user agent of the user's device. - - If the individual fields (os_version, device_platform, device_model) are - provided, we will prioritize those values instead of parsing them from the user - agent string. - """ - - class Feedback(TypedDict, total=False): target: Required[FeedbackTarget] """The feedback target. Only supports phone numbers for now.""" @@ -84,15 +41,5 @@ class Feedback(TypedDict, total=False): type: Required[Literal["verification.started", "verification.completed"]] """The type of feedback.""" - dispatch_id: str - """The identifier of the dispatch that came from the front-end SDK.""" - metadata: FeedbackMetadata """The metadata for this feedback.""" - - signals: FeedbackSignals - """The signals used for anti-fraud. - - For more details, refer to - [Signals](/verify/v2/documentation/prevent-fraud#signals). - """ diff --git a/tests/api_resources/test_notify.py b/tests/api_resources/test_notify.py index 67db153..a85e95d 100644 --- a/tests/api_resources/test_notify.py +++ b/tests/api_resources/test_notify.py @@ -251,7 +251,7 @@ def test_path_params_list_subscription_phone_numbers(self, client: Prelude) -> N config_id="", ) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize def test_method_send(self, client: Prelude) -> None: notify = client.notify.send( @@ -260,7 +260,7 @@ def test_method_send(self, client: Prelude) -> None: ) assert_matches_type(NotifySendResponse, notify, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize def test_method_send_with_all_params(self, client: Prelude) -> None: notify = client.notify.send( @@ -268,6 +268,10 @@ def test_method_send_with_all_params(self, client: Prelude) -> None: to="+33612345678", callback_url="https://your-app.com/webhooks/notify", correlation_id="order-12345", + document={ + "filename": "invoice.pdf", + "url": "https://example.com/invoice.pdf", + }, expires_at=parse_datetime("2025-12-25T18:00:00Z"), from_="from", locale="el-GR", @@ -280,7 +284,7 @@ def test_method_send_with_all_params(self, client: Prelude) -> None: ) assert_matches_type(NotifySendResponse, notify, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize def test_raw_response_send(self, client: Prelude) -> None: response = client.notify.with_raw_response.send( @@ -293,7 +297,7 @@ def test_raw_response_send(self, client: Prelude) -> None: notify = response.parse() assert_matches_type(NotifySendResponse, notify, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize def test_streaming_response_send(self, client: Prelude) -> None: with client.notify.with_streaming_response.send( @@ -323,6 +327,10 @@ def test_method_send_batch_with_all_params(self, client: Prelude) -> None: to=["+33612345678", "+15551234567"], callback_url="https://your-app.com/webhooks/notify", correlation_id="campaign-12345", + document={ + "filename": "invoice.pdf", + "url": "https://example.com/invoice.pdf", + }, expires_at=parse_datetime("2025-12-25T18:00:00Z"), from_="from", locale="el-GR", @@ -594,7 +602,7 @@ async def test_path_params_list_subscription_phone_numbers(self, async_client: A config_id="", ) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize async def test_method_send(self, async_client: AsyncPrelude) -> None: notify = await async_client.notify.send( @@ -603,7 +611,7 @@ async def test_method_send(self, async_client: AsyncPrelude) -> None: ) assert_matches_type(NotifySendResponse, notify, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize async def test_method_send_with_all_params(self, async_client: AsyncPrelude) -> None: notify = await async_client.notify.send( @@ -611,6 +619,10 @@ async def test_method_send_with_all_params(self, async_client: AsyncPrelude) -> to="+33612345678", callback_url="https://your-app.com/webhooks/notify", correlation_id="order-12345", + document={ + "filename": "invoice.pdf", + "url": "https://example.com/invoice.pdf", + }, expires_at=parse_datetime("2025-12-25T18:00:00Z"), from_="from", locale="el-GR", @@ -623,7 +635,7 @@ async def test_method_send_with_all_params(self, async_client: AsyncPrelude) -> ) assert_matches_type(NotifySendResponse, notify, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize async def test_raw_response_send(self, async_client: AsyncPrelude) -> None: response = await async_client.notify.with_raw_response.send( @@ -636,7 +648,7 @@ async def test_raw_response_send(self, async_client: AsyncPrelude) -> None: notify = await response.parse() assert_matches_type(NotifySendResponse, notify, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize async def test_streaming_response_send(self, async_client: AsyncPrelude) -> None: async with async_client.notify.with_streaming_response.send( @@ -666,6 +678,10 @@ async def test_method_send_batch_with_all_params(self, async_client: AsyncPrelud to=["+33612345678", "+15551234567"], callback_url="https://your-app.com/webhooks/notify", correlation_id="campaign-12345", + document={ + "filename": "invoice.pdf", + "url": "https://example.com/invoice.pdf", + }, expires_at=parse_datetime("2025-12-25T18:00:00Z"), from_="from", locale="el-GR", diff --git a/tests/api_resources/test_transactional.py b/tests/api_resources/test_transactional.py index 77ee4b1..416b235 100644 --- a/tests/api_resources/test_transactional.py +++ b/tests/api_resources/test_transactional.py @@ -19,7 +19,7 @@ class TestTransactional: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize def test_method_send(self, client: Prelude) -> None: with pytest.warns(DeprecationWarning): @@ -30,7 +30,7 @@ def test_method_send(self, client: Prelude) -> None: assert_matches_type(TransactionalSendResponse, transactional, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize def test_method_send_with_all_params(self, client: Prelude) -> None: with pytest.warns(DeprecationWarning): @@ -39,6 +39,10 @@ def test_method_send_with_all_params(self, client: Prelude) -> None: to="+30123456789", callback_url="callback_url", correlation_id="correlation_id", + document={ + "filename": "invoice.pdf", + "url": "https://example.com/invoice.pdf", + }, expires_at="expires_at", from_="from", locale="el-GR", @@ -48,7 +52,7 @@ def test_method_send_with_all_params(self, client: Prelude) -> None: assert_matches_type(TransactionalSendResponse, transactional, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize def test_raw_response_send(self, client: Prelude) -> None: with pytest.warns(DeprecationWarning): @@ -62,7 +66,7 @@ def test_raw_response_send(self, client: Prelude) -> None: transactional = response.parse() assert_matches_type(TransactionalSendResponse, transactional, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize def test_streaming_response_send(self, client: Prelude) -> None: with pytest.warns(DeprecationWarning): @@ -84,7 +88,7 @@ class TestAsyncTransactional: "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] ) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize async def test_method_send(self, async_client: AsyncPrelude) -> None: with pytest.warns(DeprecationWarning): @@ -95,7 +99,7 @@ async def test_method_send(self, async_client: AsyncPrelude) -> None: assert_matches_type(TransactionalSendResponse, transactional, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize async def test_method_send_with_all_params(self, async_client: AsyncPrelude) -> None: with pytest.warns(DeprecationWarning): @@ -104,6 +108,10 @@ async def test_method_send_with_all_params(self, async_client: AsyncPrelude) -> to="+30123456789", callback_url="callback_url", correlation_id="correlation_id", + document={ + "filename": "invoice.pdf", + "url": "https://example.com/invoice.pdf", + }, expires_at="expires_at", from_="from", locale="el-GR", @@ -113,7 +121,7 @@ async def test_method_send_with_all_params(self, async_client: AsyncPrelude) -> assert_matches_type(TransactionalSendResponse, transactional, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize async def test_raw_response_send(self, async_client: AsyncPrelude) -> None: with pytest.warns(DeprecationWarning): @@ -127,7 +135,7 @@ async def test_raw_response_send(self, async_client: AsyncPrelude) -> None: transactional = await response.parse() assert_matches_type(TransactionalSendResponse, transactional, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize async def test_streaming_response_send(self, async_client: AsyncPrelude) -> None: with pytest.warns(DeprecationWarning): diff --git a/tests/api_resources/test_verification.py b/tests/api_resources/test_verification.py index 8e7c7ec..b2eaa31 100644 --- a/tests/api_resources/test_verification.py +++ b/tests/api_resources/test_verification.py @@ -20,7 +20,7 @@ class TestVerification: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize def test_method_create(self, client: Prelude) -> None: verification = client.verification.create( @@ -31,7 +31,7 @@ def test_method_create(self, client: Prelude) -> None: ) assert_matches_type(VerificationCreateResponse, verification, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize def test_method_create_with_all_params(self, client: Prelude) -> None: verification = client.verification.create( @@ -49,7 +49,6 @@ def test_method_create_with_all_params(self, client: Prelude) -> None: "callback_url": "callback_url", "code_size": 5, "custom_code": "123456", - "integration": "auth0", "locale": "el-GR", "method": "auto", "preferred_channel": "sms", @@ -62,7 +61,7 @@ def test_method_create_with_all_params(self, client: Prelude) -> None: "device_id": "8F0B8FDD-C2CB-4387-B20A-56E9B2E5A0D2", "device_model": "iPhone17,2", "device_platform": "ios", - "ip": "192.0.2.1", + "ip": "203.0.113.123", "is_trusted_user": False, "ja4_fingerprint": "t13d1516h2_8daaf6152771_e5627efa2ab1", "os_version": "18.0.1", @@ -71,7 +70,7 @@ def test_method_create_with_all_params(self, client: Prelude) -> None: ) assert_matches_type(VerificationCreateResponse, verification, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize def test_raw_response_create(self, client: Prelude) -> None: response = client.verification.with_raw_response.create( @@ -86,7 +85,7 @@ def test_raw_response_create(self, client: Prelude) -> None: verification = response.parse() assert_matches_type(VerificationCreateResponse, verification, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize def test_streaming_response_create(self, client: Prelude) -> None: with client.verification.with_streaming_response.create( @@ -152,7 +151,7 @@ class TestAsyncVerification: "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] ) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize async def test_method_create(self, async_client: AsyncPrelude) -> None: verification = await async_client.verification.create( @@ -163,7 +162,7 @@ async def test_method_create(self, async_client: AsyncPrelude) -> None: ) assert_matches_type(VerificationCreateResponse, verification, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize async def test_method_create_with_all_params(self, async_client: AsyncPrelude) -> None: verification = await async_client.verification.create( @@ -181,7 +180,6 @@ async def test_method_create_with_all_params(self, async_client: AsyncPrelude) - "callback_url": "callback_url", "code_size": 5, "custom_code": "123456", - "integration": "auth0", "locale": "el-GR", "method": "auto", "preferred_channel": "sms", @@ -194,7 +192,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncPrelude) - "device_id": "8F0B8FDD-C2CB-4387-B20A-56E9B2E5A0D2", "device_model": "iPhone17,2", "device_platform": "ios", - "ip": "192.0.2.1", + "ip": "203.0.113.123", "is_trusted_user": False, "ja4_fingerprint": "t13d1516h2_8daaf6152771_e5627efa2ab1", "os_version": "18.0.1", @@ -203,7 +201,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncPrelude) - ) assert_matches_type(VerificationCreateResponse, verification, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize async def test_raw_response_create(self, async_client: AsyncPrelude) -> None: response = await async_client.verification.with_raw_response.create( @@ -218,7 +216,7 @@ async def test_raw_response_create(self, async_client: AsyncPrelude) -> None: verification = await response.parse() assert_matches_type(VerificationCreateResponse, verification, path=["response"]) - @pytest.mark.skip(reason="Prism doesn't support callbacks yet") + @pytest.mark.skip(reason="Mock server doesn't support callbacks yet") @parametrize async def test_streaming_response_create(self, async_client: AsyncPrelude) -> None: async with async_client.verification.with_streaming_response.create( diff --git a/tests/api_resources/test_watch.py b/tests/api_resources/test_watch.py index 51d933c..5d074d8 100644 --- a/tests/api_resources/test_watch.py +++ b/tests/api_resources/test_watch.py @@ -45,7 +45,7 @@ def test_method_predict_with_all_params(self, client: Prelude) -> None: "device_id": "8F0B8FDD-C2CB-4387-B20A-56E9B2E5A0D2", "device_model": "iPhone17,2", "device_platform": "ios", - "ip": "192.0.2.1", + "ip": "203.0.113.123", "is_trusted_user": False, "ja4_fingerprint": "t13d1516h2_8daaf6152771_e5627efa2ab1", "os_version": "18.0.1", @@ -227,7 +227,7 @@ async def test_method_predict_with_all_params(self, async_client: AsyncPrelude) "device_id": "8F0B8FDD-C2CB-4387-B20A-56E9B2E5A0D2", "device_model": "iPhone17,2", "device_platform": "ios", - "ip": "192.0.2.1", + "ip": "203.0.113.123", "is_trusted_user": False, "ja4_fingerprint": "t13d1516h2_8daaf6152771_e5627efa2ab1", "os_version": "18.0.1", diff --git a/tests/test_client.py b/tests/test_client.py index c7e59df..8d9049a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,10 +8,11 @@ import json import asyncio import inspect +import dataclasses import tracemalloc -from typing import Any, Union, cast +from typing import Any, Union, TypeVar, Callable, Iterable, Iterator, Optional, Coroutine, cast from unittest import mock -from typing_extensions import Literal +from typing_extensions import Literal, AsyncIterator, override import httpx import pytest @@ -36,6 +37,7 @@ from .utils import update_env +T = TypeVar("T") base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") api_token = "My API Token" @@ -50,6 +52,57 @@ def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float: return 0.1 +def mirror_request_content(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=request.content) + + +# note: we can't use the httpx.MockTransport class as it consumes the request +# body itself, which means we can't test that the body is read lazily +class MockTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): + def __init__( + self, + handler: Callable[[httpx.Request], httpx.Response] + | Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]], + ) -> None: + self.handler = handler + + @override + def handle_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert not inspect.iscoroutinefunction(self.handler), "handler must not be a coroutine function" + assert inspect.isfunction(self.handler), "handler must be a function" + return self.handler(request) + + @override + async def handle_async_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert inspect.iscoroutinefunction(self.handler), "handler must be a coroutine function" + return await self.handler(request) + + +@dataclasses.dataclass +class Counter: + value: int = 0 + + +def _make_sync_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> Iterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + +async def _make_async_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> AsyncIterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + def _get_open_connections(client: Prelude | AsyncPrelude) -> int: transport = client._client._transport assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) @@ -505,6 +558,70 @@ def test_multipart_repeating_array(self, client: Prelude) -> None: b"", ] + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload(self, respx_mock: MockRouter, client: Prelude) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + def test_binary_content_upload_with_iterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_sync_iterator([file_content], counter=counter) + + def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=request.read()) + + with Prelude( + base_url=base_url, + api_token=api_token, + _strict_response_validation=True, + http_client=httpx.Client(transport=MockTransport(handler=mock_handler)), + ) as client: + response = client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload_with_body_is_deprecated(self, respx_mock: MockRouter, client: Prelude) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + @pytest.mark.respx(base_url=base_url) def test_basic_union_response(self, respx_mock: MockRouter, client: Prelude) -> None: class Model1(BaseModel): @@ -870,6 +987,14 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + # Delete in case our environment has any proxy env vars set + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) client = DefaultHttpxClient() @@ -1364,6 +1489,72 @@ def test_multipart_repeating_array(self, async_client: AsyncPrelude) -> None: b"", ] + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload(self, respx_mock: MockRouter, async_client: AsyncPrelude) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = await async_client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + async def test_binary_content_upload_with_asynciterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_async_iterator([file_content], counter=counter) + + async def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=await request.aread()) + + async with AsyncPrelude( + base_url=base_url, + api_token=api_token, + _strict_response_validation=True, + http_client=httpx.AsyncClient(transport=MockTransport(handler=mock_handler)), + ) as client: + response = await client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload_with_body_is_deprecated( + self, respx_mock: MockRouter, async_client: AsyncPrelude + ) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = await async_client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + @pytest.mark.respx(base_url=base_url) async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncPrelude) -> None: class Model1(BaseModel): @@ -1738,6 +1929,14 @@ async def test_get_platform(self) -> None: async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + # Delete in case our environment has any proxy env vars set + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) client = DefaultAsyncHttpxClient() diff --git a/tests/test_utils/test_json.py b/tests/test_utils/test_json.py new file mode 100644 index 0000000..a6dd30c --- /dev/null +++ b/tests/test_utils/test_json.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import datetime +from typing import Union + +import pydantic + +from prelude_python_sdk import _compat +from prelude_python_sdk._utils._json import openapi_dumps + + +class TestOpenapiDumps: + def test_basic(self) -> None: + data = {"key": "value", "number": 42} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"key":"value","number":42}' + + def test_datetime_serialization(self) -> None: + dt = datetime.datetime(2023, 1, 1, 12, 0, 0) + data = {"datetime": dt} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"datetime":"2023-01-01T12:00:00"}' + + def test_pydantic_model_serialization(self) -> None: + class User(pydantic.BaseModel): + first_name: str + last_name: str + age: int + + model_instance = User(first_name="John", last_name="Kramer", age=83) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"first_name":"John","last_name":"Kramer","age":83}}' + + def test_pydantic_model_with_default_values(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + score: int = 0 + + model_instance = User(name="Alice") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Alice"}}' + + def test_pydantic_model_with_default_values_overridden(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + + model_instance = User(name="Bob", role="admin", active=False) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Bob","role":"admin","active":false}}' + + def test_pydantic_model_with_alias(self) -> None: + class User(pydantic.BaseModel): + first_name: str = pydantic.Field(alias="firstName") + last_name: str = pydantic.Field(alias="lastName") + + model_instance = User(firstName="John", lastName="Doe") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"firstName":"John","lastName":"Doe"}}' + + def test_pydantic_model_with_alias_and_default(self) -> None: + class User(pydantic.BaseModel): + user_name: str = pydantic.Field(alias="userName") + user_role: str = pydantic.Field(default="member", alias="userRole") + is_active: bool = pydantic.Field(default=True, alias="isActive") + + model_instance = User(userName="charlie") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"charlie"}}' + + model_with_overrides = User(userName="diana", userRole="admin", isActive=False) + data = {"model": model_with_overrides} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"diana","userRole":"admin","isActive":false}}' + + def test_pydantic_model_with_nested_models_and_defaults(self) -> None: + class Address(pydantic.BaseModel): + street: str + city: str = "Unknown" + + class User(pydantic.BaseModel): + name: str + address: Address + verified: bool = False + + if _compat.PYDANTIC_V1: + # to handle forward references in Pydantic v1 + User.update_forward_refs(**locals()) # type: ignore[reportDeprecated] + + address = Address(street="123 Main St") + user = User(name="Diana", address=address) + data = {"user": user} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"user":{"name":"Diana","address":{"street":"123 Main St"}}}' + + address_with_city = Address(street="456 Oak Ave", city="Boston") + user_verified = User(name="Eve", address=address_with_city, verified=True) + data = {"user": user_verified} + json_bytes = openapi_dumps(data) + assert ( + json_bytes == b'{"user":{"name":"Eve","address":{"street":"456 Oak Ave","city":"Boston"},"verified":true}}' + ) + + def test_pydantic_model_with_optional_fields(self) -> None: + class User(pydantic.BaseModel): + name: str + email: Union[str, None] + phone: Union[str, None] + + model_with_none = User(name="Eve", email=None, phone=None) + data = {"model": model_with_none} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Eve","email":null,"phone":null}}' + + model_with_values = User(name="Frank", email="frank@example.com", phone=None) + data = {"model": model_with_values} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Frank","email":"frank@example.com","phone":null}}'