diff --git a/.release-please-manifest.json b/.release-please-manifest.json index aaf968a..b56c3d0 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.0-alpha.3" + ".": "0.1.0-alpha.4" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 9a460fd..0d90c5c 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,2 +1,2 @@ configured_endpoints: 5 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/prelude%2Fprelude-c1f72f65743e762371400a6f36ba21d4e68ceaa351cb3ea7674cbc04a39e298c.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/prelude%2Fprelude-64c1896dedae5302f4967c8edb2a675a48cba330193a20bdda1409fe3f9f9972.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index cac2d79..d4ea7c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.1.0-alpha.4 (2024-11-27) + +Full Changelog: [v0.1.0-alpha.3...v0.1.0-alpha.4](https://github.com/prelude-so/python-sdk/compare/v0.1.0-alpha.3...v0.1.0-alpha.4) + +### Features + +* **api:** update via SDK Studio ([#12](https://github.com/prelude-so/python-sdk/issues/12)) ([380ea22](https://github.com/prelude-so/python-sdk/commit/380ea22a509deeb05b9b27af7b21aae5a70b4380)) +* **api:** update via SDK Studio ([#16](https://github.com/prelude-so/python-sdk/issues/16)) ([a885d0a](https://github.com/prelude-so/python-sdk/commit/a885d0a4aaa978adf582c8743011765dfc65614f)) + + +### Chores + +* **internal:** fix compat model_dump method when warnings are passed ([#13](https://github.com/prelude-so/python-sdk/issues/13)) ([7f9b088](https://github.com/prelude-so/python-sdk/commit/7f9b08842698d0eb6911464089583d56db63e0cf)) +* rebuild project due to codegen change ([#10](https://github.com/prelude-so/python-sdk/issues/10)) ([afd8c51](https://github.com/prelude-so/python-sdk/commit/afd8c5127bce604ba78290aaf62659a3c02471a5)) +* remove now unused `cached-property` dep ([#15](https://github.com/prelude-so/python-sdk/issues/15)) ([292303e](https://github.com/prelude-so/python-sdk/commit/292303e362071f1ba1d7ce2e8311653e4c4ec3f6)) + + +### Documentation + +* add info log level to readme ([#14](https://github.com/prelude-so/python-sdk/issues/14)) ([ee4b1b2](https://github.com/prelude-so/python-sdk/commit/ee4b1b2cbfbec80d0ec43cb8e7c54cd0acaad7b9)) + ## 0.1.0-alpha.3 (2024-11-14) Full Changelog: [v0.1.0-alpha.2...v0.1.0-alpha.3](https://github.com/prelude-so/python-sdk/compare/v0.1.0-alpha.2...v0.1.0-alpha.3) diff --git a/README.md b/README.md index 05d3e31..22db93c 100644 --- a/README.md +++ b/README.md @@ -193,12 +193,14 @@ Note that requests that time out are [retried twice by default](#retries). We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. -You can enable logging by setting the environment variable `PRELUDE_LOG` to `debug`. +You can enable logging by setting the environment variable `PRELUDE_LOG` to `info`. ```shell -$ export PRELUDE_LOG=debug +$ export PRELUDE_LOG=info ``` +Or to `debug` for more verbose logging. + ### How to tell whether `None` means `null` or missing In an API response, a field may be explicitly `null`, or missing entirely; in either case, its value is `None` in this library. You can differentiate the two cases with `.model_fields_set`: diff --git a/pyproject.toml b/pyproject.toml index 30d187d..e818b0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "prelude-python-sdk" -version = "0.1.0-alpha.3" +version = "0.1.0-alpha.4" description = "The official Python library for the Prelude API" dynamic = ["readme"] license = "Apache-2.0" @@ -14,7 +14,6 @@ dependencies = [ "anyio>=3.5.0, <5", "distro>=1.7.0, <2", "sniffio", - "cached-property; python_version < '3.8'", ] requires-python = ">= 3.8" classifiers = [ @@ -55,6 +54,7 @@ dev-dependencies = [ "dirty-equals>=0.6.0", "importlib-metadata>=6.7.0", "rich>=13.7.1", + "nest_asyncio==1.6.0" ] [tool.rye.scripts] diff --git a/requirements-dev.lock b/requirements-dev.lock index 4eb66d1..3004446 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -51,6 +51,7 @@ mdurl==0.1.2 mypy==1.13.0 mypy-extensions==1.0.0 # via mypy +nest-asyncio==1.6.0 nodeenv==1.8.0 # via pyright nox==2023.4.22 diff --git a/src/prelude_python_sdk/_compat.py b/src/prelude_python_sdk/_compat.py index 4794129..92d9ee6 100644 --- a/src/prelude_python_sdk/_compat.py +++ b/src/prelude_python_sdk/_compat.py @@ -145,7 +145,8 @@ def model_dump( exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, - warnings=warnings, + # warnings are not supported in Pydantic v1 + warnings=warnings if PYDANTIC_V2 else True, ) return cast( "dict[str, Any]", @@ -213,9 +214,6 @@ def __set_name__(self, owner: type[Any], name: str) -> None: ... # __set__ is not defined at runtime, but @cached_property is designed to be settable def __set__(self, instance: object, value: _T) -> None: ... else: - try: - from functools import cached_property as cached_property - except ImportError: - from cached_property import cached_property as cached_property + from functools import cached_property as cached_property typed_cached_property = cached_property diff --git a/src/prelude_python_sdk/_utils/_sync.py b/src/prelude_python_sdk/_utils/_sync.py index d0d8103..8b3aaf2 100644 --- a/src/prelude_python_sdk/_utils/_sync.py +++ b/src/prelude_python_sdk/_utils/_sync.py @@ -1,56 +1,62 @@ from __future__ import annotations +import sys +import asyncio import functools -from typing import TypeVar, Callable, Awaitable +import contextvars +from typing import Any, TypeVar, Callable, Awaitable from typing_extensions import ParamSpec -import anyio -import anyio.to_thread - -from ._reflection import function_has_argument - T_Retval = TypeVar("T_Retval") T_ParamSpec = ParamSpec("T_ParamSpec") -# copied from `asyncer`, https://github.com/tiangolo/asyncer -def asyncify( - function: Callable[T_ParamSpec, T_Retval], - *, - cancellable: bool = False, - limiter: anyio.CapacityLimiter | None = None, -) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: +if sys.version_info >= (3, 9): + to_thread = asyncio.to_thread +else: + # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread + # for Python 3.8 support + async def to_thread( + func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs + ) -> Any: + """Asynchronously run function *func* in a separate thread. + + Any *args and **kwargs supplied for this function are directly passed + to *func*. Also, the current :class:`contextvars.Context` is propagated, + allowing context variables from the main thread to be accessed in the + separate thread. + + Returns a coroutine that can be awaited to get the eventual result of *func*. + """ + loop = asyncio.events.get_running_loop() + ctx = contextvars.copy_context() + func_call = functools.partial(ctx.run, func, *args, **kwargs) + return await loop.run_in_executor(None, func_call) + + +# inspired by `asyncer`, https://github.com/tiangolo/asyncer +def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: """ Take a blocking function and create an async one that receives the same - positional and keyword arguments, and that when called, calls the original function - in a worker thread using `anyio.to_thread.run_sync()`. Internally, - `asyncer.asyncify()` uses the same `anyio.to_thread.run_sync()`, but it supports - keyword arguments additional to positional arguments and it adds better support for - autocompletion and inline errors for the arguments of the function called and the - return value. - - If the `cancellable` option is enabled and the task waiting for its completion is - cancelled, the thread will still run its course but its return value (or any raised - exception) will be ignored. + positional and keyword arguments. For python version 3.9 and above, it uses + asyncio.to_thread to run the function in a separate thread. For python version + 3.8, it uses locally defined copy of the asyncio.to_thread function which was + introduced in python 3.9. - Use it like this: + Usage: - ```Python - def do_work(arg1, arg2, kwarg1="", kwarg2="") -> str: - # Do work - return "Some result" + ```python + def blocking_func(arg1, arg2, kwarg1=None): + # blocking code + return result - result = await to_thread.asyncify(do_work)("spam", "ham", kwarg1="a", kwarg2="b") - print(result) + result = asyncify(blocking_function)(arg1, arg2, kwarg1=value1) ``` ## Arguments `function`: a blocking regular callable (e.g. a function) - `cancellable`: `True` to allow cancellation of the operation - `limiter`: capacity limiter to use to limit the total amount of threads running - (if omitted, the default limiter is used) ## Return @@ -60,22 +66,6 @@ def do_work(arg1, arg2, kwarg1="", kwarg2="") -> str: """ async def wrapper(*args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs) -> T_Retval: - partial_f = functools.partial(function, *args, **kwargs) - - # In `v4.1.0` anyio added the `abandon_on_cancel` argument and deprecated the old - # `cancellable` argument, so we need to use the new `abandon_on_cancel` to avoid - # surfacing deprecation warnings. - if function_has_argument(anyio.to_thread.run_sync, "abandon_on_cancel"): - return await anyio.to_thread.run_sync( - partial_f, - abandon_on_cancel=cancellable, - limiter=limiter, - ) - - return await anyio.to_thread.run_sync( - partial_f, - cancellable=cancellable, - limiter=limiter, - ) + return await to_thread(function, *args, **kwargs) return wrapper diff --git a/src/prelude_python_sdk/_version.py b/src/prelude_python_sdk/_version.py index bdc8c16..3981f1c 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.1.0-alpha.3" # x-release-please-version +__version__ = "0.1.0-alpha.4" # x-release-please-version diff --git a/src/prelude_python_sdk/types/verification_check_response.py b/src/prelude_python_sdk/types/verification_check_response.py index 4bcb3b4..7bca9cf 100644 --- a/src/prelude_python_sdk/types/verification_check_response.py +++ b/src/prelude_python_sdk/types/verification_check_response.py @@ -13,6 +13,9 @@ class Metadata(BaseModel): class VerificationCheckResponse(BaseModel): + status: Literal["success", "failure", "expired_or_not_found"] + """The status of the check.""" + id: Optional[str] = None """The verification identifier.""" @@ -20,6 +23,3 @@ class VerificationCheckResponse(BaseModel): """The metadata for this verification.""" request_id: Optional[str] = None - - status: Optional[Literal["success", "failure", "expired"]] = None - """The status of the check.""" diff --git a/src/prelude_python_sdk/types/verification_create_params.py b/src/prelude_python_sdk/types/verification_create_params.py index 1b59abf..c7453df 100644 --- a/src/prelude_python_sdk/types/verification_create_params.py +++ b/src/prelude_python_sdk/types/verification_create_params.py @@ -46,6 +46,13 @@ class Options(TypedDict, total=False): devices. """ + custom_code: str + """The custom code to use for OTP verification. + + This feature is only available for compatibility purposes and subject to + Prelude’s approval. Contact us to discuss your use case. + """ + locale: str """ A BCP-47 formatted locale string with the language the text message will be sent @@ -82,7 +89,7 @@ class Signals(TypedDict, total=False): device_model: str """The model of the user's device.""" - device_platform: Literal["android", "ios", "web"] + device_platform: Literal["android", "ios", "ipados", "tvos", "web"] """The type of the user's device.""" ip: str diff --git a/src/prelude_python_sdk/types/verification_create_response.py b/src/prelude_python_sdk/types/verification_create_response.py index f1121cb..fdc66ff 100644 --- a/src/prelude_python_sdk/types/verification_create_response.py +++ b/src/prelude_python_sdk/types/verification_create_response.py @@ -13,16 +13,16 @@ class Metadata(BaseModel): class VerificationCreateResponse(BaseModel): - id: Optional[str] = None + id: str """The verification identifier.""" + method: Literal["message"] + """The method used for verifying this phone number.""" + + status: Literal["success", "retry", "blocked"] + """The status of the verification.""" + metadata: Optional[Metadata] = None """The metadata for this verification.""" - method: Optional[Literal["message"]] = None - """The method used for verifying this phone number.""" - request_id: Optional[str] = None - - status: Optional[Literal["success", "retry", "blocked"]] = None - """The status of the verification.""" diff --git a/src/prelude_python_sdk/types/watch_feed_back_response.py b/src/prelude_python_sdk/types/watch_feed_back_response.py index aa10b12..c879860 100644 --- a/src/prelude_python_sdk/types/watch_feed_back_response.py +++ b/src/prelude_python_sdk/types/watch_feed_back_response.py @@ -1,6 +1,5 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Optional from .._models import BaseModel @@ -8,5 +7,5 @@ class WatchFeedBackResponse(BaseModel): - id: Optional[str] = None + id: str """A unique identifier for your feedback request.""" diff --git a/src/prelude_python_sdk/types/watch_predict_response.py b/src/prelude_python_sdk/types/watch_predict_response.py index d85e251..cb5a871 100644 --- a/src/prelude_python_sdk/types/watch_predict_response.py +++ b/src/prelude_python_sdk/types/watch_predict_response.py @@ -20,10 +20,10 @@ class Reasoning(BaseModel): class WatchPredictResponse(BaseModel): - id: Optional[str] = None + id: str """A unique identifier for your prediction request.""" - prediction: Optional[Literal["allow", "block"]] = None + prediction: Literal["allow", "block"] """A label indicating the trustworthiness of the phone number.""" - reasoning: Optional[Reasoning] = None + reasoning: Reasoning diff --git a/tests/api_resources/test_transactional.py b/tests/api_resources/test_transactional.py index 7a105f6..89130db 100644 --- a/tests/api_resources/test_transactional.py +++ b/tests/api_resources/test_transactional.py @@ -23,8 +23,8 @@ class TestTransactional: @parametrize def test_method_send(self, client: Prelude) -> None: transactional = client.transactional.send( - template_id="template_id", - to="to", + template_id="template_01jd1xq0cffycayqtdkdbv4d61", + to="+30123456789", ) assert_matches_type(TransactionalSendResponse, transactional, path=["response"]) @@ -34,13 +34,13 @@ def test_method_send(self, client: Prelude) -> None: @parametrize def test_method_send_with_all_params(self, client: Prelude) -> None: transactional = client.transactional.send( - template_id="template_id", - to="to", + template_id="template_01jd1xq0cffycayqtdkdbv4d61", + to="+30123456789", callback_url="callback_url", correlation_id="correlation_id", expires_at="expires_at", from_="from", - variables={"foo": "string"}, + variables={"foo": "bar"}, ) assert_matches_type(TransactionalSendResponse, transactional, path=["response"]) @@ -50,8 +50,8 @@ def test_method_send_with_all_params(self, client: Prelude) -> None: @parametrize def test_raw_response_send(self, client: Prelude) -> None: response = client.transactional.with_raw_response.send( - template_id="template_id", - to="to", + template_id="template_01jd1xq0cffycayqtdkdbv4d61", + to="+30123456789", ) assert response.is_closed is True @@ -65,8 +65,8 @@ def test_raw_response_send(self, client: Prelude) -> None: @parametrize def test_streaming_response_send(self, client: Prelude) -> None: with client.transactional.with_streaming_response.send( - template_id="template_id", - to="to", + template_id="template_01jd1xq0cffycayqtdkdbv4d61", + to="+30123456789", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -86,8 +86,8 @@ class TestAsyncTransactional: @parametrize async def test_method_send(self, async_client: AsyncPrelude) -> None: transactional = await async_client.transactional.send( - template_id="template_id", - to="to", + template_id="template_01jd1xq0cffycayqtdkdbv4d61", + to="+30123456789", ) assert_matches_type(TransactionalSendResponse, transactional, path=["response"]) @@ -97,13 +97,13 @@ async def test_method_send(self, async_client: AsyncPrelude) -> None: @parametrize async def test_method_send_with_all_params(self, async_client: AsyncPrelude) -> None: transactional = await async_client.transactional.send( - template_id="template_id", - to="to", + template_id="template_01jd1xq0cffycayqtdkdbv4d61", + to="+30123456789", callback_url="callback_url", correlation_id="correlation_id", expires_at="expires_at", from_="from", - variables={"foo": "string"}, + variables={"foo": "bar"}, ) assert_matches_type(TransactionalSendResponse, transactional, path=["response"]) @@ -113,8 +113,8 @@ async def test_method_send_with_all_params(self, async_client: AsyncPrelude) -> @parametrize async def test_raw_response_send(self, async_client: AsyncPrelude) -> None: response = await async_client.transactional.with_raw_response.send( - template_id="template_id", - to="to", + template_id="template_01jd1xq0cffycayqtdkdbv4d61", + to="+30123456789", ) assert response.is_closed is True @@ -128,8 +128,8 @@ async def test_raw_response_send(self, async_client: AsyncPrelude) -> None: @parametrize async def test_streaming_response_send(self, async_client: AsyncPrelude) -> None: async with async_client.transactional.with_streaming_response.send( - template_id="template_id", - to="to", + template_id="template_01jd1xq0cffycayqtdkdbv4d61", + to="+30123456789", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" diff --git a/tests/api_resources/test_verification.py b/tests/api_resources/test_verification.py index 9f15bb4..8afceea 100644 --- a/tests/api_resources/test_verification.py +++ b/tests/api_resources/test_verification.py @@ -40,6 +40,7 @@ def test_method_create_with_all_params(self, client: Prelude) -> None: metadata={"correlation_id": "correlation_id"}, options={ "app_realm": "app_realm", + "custom_code": "custom_code", "locale": "el-GR", "sender_id": "sender_id", "template_id": "template_id", @@ -153,6 +154,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncPrelude) - metadata={"correlation_id": "correlation_id"}, options={ "app_realm": "app_realm", + "custom_code": "custom_code", "locale": "el-GR", "sender_id": "sender_id", "template_id": "template_id", diff --git a/tests/test_client.py b/tests/test_client.py index ce045d0..3bb0769 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,11 +4,14 @@ import gc import os +import sys import json import asyncio import inspect +import subprocess import tracemalloc from typing import Any, Union, cast +from textwrap import dedent from unittest import mock from typing_extensions import Literal @@ -1672,3 +1675,38 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: ) assert response.http_request.headers.get("x-stainless-retry-count") == "42" + + def test_get_platform(self) -> None: + # A previous implementation of asyncify could leave threads unterminated when + # used with nest_asyncio. + # + # Since nest_asyncio.apply() is global and cannot be un-applied, this + # test is run in a separate process to avoid affecting other tests. + test_code = dedent(""" + import asyncio + import nest_asyncio + import threading + + from prelude_python_sdk._utils import asyncify + from prelude_python_sdk._base_client import get_platform + + async def test_main() -> None: + result = await asyncify(get_platform)() + print(result) + for thread in threading.enumerate(): + print(thread.name) + + nest_asyncio.apply() + asyncio.run(test_main()) + """) + with subprocess.Popen( + [sys.executable, "-c", test_code], + text=True, + ) as process: + try: + process.wait(2) + if process.returncode: + raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code") + except subprocess.TimeoutExpired as e: + process.kill() + raise AssertionError("calling get_platform using asyncify resulted in a hung process") from e diff --git a/tests/test_models.py b/tests/test_models.py index 8d268e8..455b775 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -561,6 +561,14 @@ class Model(BaseModel): m.model_dump(warnings=False) +def test_compat_method_no_error_for_warnings() -> None: + class Model(BaseModel): + foo: Optional[str] + + m = Model(foo="hello") + assert isinstance(model_dump(m, warnings=False), dict) + + def test_to_json() -> None: class Model(BaseModel): foo: Optional[str] = Field(alias="FOO", default=None)