Skip to content

release: 0.1.0-alpha.2 #5

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.1.0-alpha.1"
".": "0.1.0-alpha.2"
}
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 0.1.0-alpha.2 (2024-11-22)

Full Changelog: [v0.1.0-alpha.1...v0.1.0-alpha.2](https://github.com/sentdm/sent-python/compare/v0.1.0-alpha.1...v0.1.0-alpha.2)

### Chores

* **internal:** fix compat model_dump method when warnings are passed ([#8](https://github.com/sentdm/sent-python/issues/8)) ([8049dc1](https://github.com/sentdm/sent-python/commit/8049dc1866e3cffd17a7de420d18f501e2eab41a))
* rebuild project due to codegen change ([#4](https://github.com/sentdm/sent-python/issues/4)) ([6c2d9dc](https://github.com/sentdm/sent-python/commit/6c2d9dc8f0680358bef61662237c05b1e53f357f))
* rebuild project due to codegen change ([#6](https://github.com/sentdm/sent-python/issues/6)) ([370c08c](https://github.com/sentdm/sent-python/commit/370c08ce3565afed9f7b6bbc61b0b4469c3c01f5))
* rebuild project due to codegen change ([#7](https://github.com/sentdm/sent-python/issues/7)) ([19b1231](https://github.com/sentdm/sent-python/commit/19b12318b61d60a33d0fdde4ddae9759f01e7764))

## 0.1.0-alpha.1 (2024-10-31)

Full Changelog: [v0.0.1-alpha.0...v0.1.0-alpha.1](https://github.com/sentdm/sent-python/compare/v0.0.1-alpha.0...v0.1.0-alpha.1)
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@

[![PyPI version](https://img.shields.io/pypi/v/sentdm.svg)](https://pypi.org/project/sentdm/)

The Sent Python library provides convenient access to the Sent REST API from any Python 3.7+
The Sent Python library provides convenient access to the Sent REST API from any Python 3.8+
application. The library includes type definitions for all request params and response fields,
and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx).

@@ -304,7 +304,7 @@ print(sent.__version__)

## Requirements

Python 3.7 or higher.
Python 3.8 or higher.

## Contributing

8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "sentdm"
version = "0.1.0-alpha.1"
version = "0.1.0-alpha.2"
description = "The official Python library for the Sent API"
dynamic = ["readme"]
license = "Apache-2.0"
@@ -16,11 +16,10 @@ dependencies = [
"sniffio",
"cached-property; python_version < '3.8'",
]
requires-python = ">= 3.7"
requires-python = ">= 3.8"
classifiers = [
"Typing :: Typed",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
@@ -56,6 +55,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]
@@ -139,7 +139,7 @@ filterwarnings = [
# there are a couple of flags that are still disabled by
# default in strict mode as they are experimental and niche.
typeCheckingMode = "strict"
pythonVersion = "3.7"
pythonVersion = "3.8"

exclude = [
"_dev",
3 changes: 2 additions & 1 deletion requirements-dev.lock
Original file line number Diff line number Diff line change
@@ -48,9 +48,10 @@ markdown-it-py==3.0.0
# via rich
mdurl==0.1.2
# via markdown-it-py
mypy==1.11.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
9 changes: 6 additions & 3 deletions src/sent/_compat.py
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@

from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast, overload
from datetime import date, datetime
from typing_extensions import Self
from typing_extensions import Self, Literal

import pydantic
from pydantic.fields import FieldInfo
@@ -137,13 +137,16 @@ def model_dump(
exclude_unset: bool = False,
exclude_defaults: bool = False,
warnings: bool = True,
mode: Literal["json", "python"] = "python",
) -> dict[str, Any]:
if PYDANTIC_V2:
if PYDANTIC_V2 or hasattr(model, "model_dump"):
return model.model_dump(
mode=mode,
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]",
9 changes: 6 additions & 3 deletions src/sent/_models.py
Original file line number Diff line number Diff line change
@@ -37,6 +37,7 @@
PropertyInfo,
is_list,
is_given,
json_safe,
lru_cache,
is_mapping,
parse_date,
@@ -279,8 +280,8 @@ def model_dump(
Returns:
A dictionary representation of the model.
"""
if mode != "python":
raise ValueError("mode is only supported in Pydantic v2")
if mode not in {"json", "python"}:
raise ValueError("mode must be either 'json' or 'python'")
if round_trip != False:
raise ValueError("round_trip is only supported in Pydantic v2")
if warnings != True:
@@ -289,7 +290,7 @@ def model_dump(
raise ValueError("context is only supported in Pydantic v2")
if serialize_as_any != False:
raise ValueError("serialize_as_any is only supported in Pydantic v2")
return super().dict( # pyright: ignore[reportDeprecated]
dumped = super().dict( # pyright: ignore[reportDeprecated]
include=include,
exclude=exclude,
by_alias=by_alias,
@@ -298,6 +299,8 @@ def model_dump(
exclude_none=exclude_none,
)

return cast(dict[str, Any], json_safe(dumped)) if mode == "json" else dumped

@override
def model_dump_json(
self,
1 change: 1 addition & 0 deletions src/sent/_utils/__init__.py
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@
is_list as is_list,
is_given as is_given,
is_tuple as is_tuple,
json_safe as json_safe,
lru_cache as lru_cache,
is_mapping as is_mapping,
is_tuple_t as is_tuple_t,
90 changes: 40 additions & 50 deletions src/sent/_utils/_sync.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 12 additions & 2 deletions src/sent/_utils/_transform.py
Original file line number Diff line number Diff line change
@@ -173,6 +173,11 @@ def _transform_recursive(
# Iterable[T]
or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str))
):
# dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually
# intended as an iterable, so we don't transform it.
if isinstance(data, dict):
return cast(object, data)

inner_type = extract_type_arg(stripped_type, 0)
return [_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data]

@@ -186,7 +191,7 @@ def _transform_recursive(
return data

if isinstance(data, pydantic.BaseModel):
return model_dump(data, exclude_unset=True)
return model_dump(data, exclude_unset=True, mode="json")

annotated_type = _get_annotated_type(annotation)
if annotated_type is None:
@@ -311,6 +316,11 @@ async def _async_transform_recursive(
# Iterable[T]
or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str))
):
# dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually
# intended as an iterable, so we don't transform it.
if isinstance(data, dict):
return cast(object, data)

inner_type = extract_type_arg(stripped_type, 0)
return [await _async_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data]

@@ -324,7 +334,7 @@ async def _async_transform_recursive(
return data

if isinstance(data, pydantic.BaseModel):
return model_dump(data, exclude_unset=True)
return model_dump(data, exclude_unset=True, mode="json")

annotated_type = _get_annotated_type(annotation)
if annotated_type is None:
17 changes: 17 additions & 0 deletions src/sent/_utils/_utils.py
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@
overload,
)
from pathlib import Path
from datetime import date, datetime
from typing_extensions import TypeGuard

import sniffio
@@ -395,3 +396,19 @@ def lru_cache(*, maxsize: int | None = 128) -> Callable[[CallableT], CallableT]:
maxsize=maxsize,
)
return cast(Any, wrapper) # type: ignore[no-any-return]


def json_safe(data: object) -> object:
"""Translates a mapping / sequence recursively in the same fashion
as `pydantic` v2's `model_dump(mode="json")`.
"""
if is_mapping(data):
return {json_safe(key): json_safe(value) for key, value in data.items()}

if is_iterable(data) and not isinstance(data, (str, bytes, bytearray)):
return [json_safe(item) for item in data]

if isinstance(data, (datetime, date)):
return data.isoformat()

return data
2 changes: 1 addition & 1 deletion src/sent/_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

__title__ = "sent"
__version__ = "0.1.0-alpha.1" # x-release-please-version
__version__ = "0.1.0-alpha.2" # x-release-please-version
47 changes: 0 additions & 47 deletions src/sent/resources/contact/__init__.py

This file was deleted.

134 changes: 0 additions & 134 deletions src/sent/resources/contact/contact.py

This file was deleted.

165 changes: 0 additions & 165 deletions src/sent/resources/contact/id.py

This file was deleted.

165 changes: 0 additions & 165 deletions src/sent/resources/contact/phone.py

This file was deleted.

185 changes: 0 additions & 185 deletions src/sent/resources/contacts.py

This file was deleted.

3 changes: 0 additions & 3 deletions src/sent/types/contact/__init__.py

This file was deleted.

40 changes: 0 additions & 40 deletions src/sent/types/sent_dm_services_contracts_data_contact_dto.py

This file was deleted.

36 changes: 0 additions & 36 deletions src/sent/types/sent_dm_services_contracts_data_customer_dto.py

This file was deleted.

This file was deleted.

30 changes: 0 additions & 30 deletions src/sent/types/sent_dm_services_contracts_data_sms_payload_dto.py

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

1 change: 0 additions & 1 deletion tests/api_resources/contact/__init__.py

This file was deleted.

118 changes: 0 additions & 118 deletions tests/api_resources/contact/test_id.py

This file was deleted.

118 changes: 0 additions & 118 deletions tests/api_resources/contact/test_phone.py

This file was deleted.

42 changes: 40 additions & 2 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -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

@@ -670,7 +673,7 @@ class Model(BaseModel):
[3, "", 0.5],
[2, "", 0.5 * 2.0],
[1, "", 0.5 * 4.0],
[-1100, "", 7.8], # test large number potentially overflowing
[-1100, "", 8], # test large number potentially overflowing
],
)
@mock.patch("time.time", mock.MagicMock(return_value=1696004797))
@@ -1423,7 +1426,7 @@ class Model(BaseModel):
[3, "", 0.5],
[2, "", 0.5 * 2.0],
[1, "", 0.5 * 4.0],
[-1100, "", 7.8], # test large number potentially overflowing
[-1100, "", 8], # test large number potentially overflowing
],
)
@mock.patch("time.time", mock.MagicMock(return_value=1696004797))
@@ -1545,3 +1548,38 @@ def retry_handler(_request: httpx.Request) -> httpx.Response:
response = await client.messages.with_raw_response.create(extra_headers={"x-stainless-retry-count": "42"})

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 sent._utils import asyncify
from sent._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
29 changes: 15 additions & 14 deletions tests/test_models.py
Original file line number Diff line number Diff line change
@@ -520,19 +520,15 @@ class Model(BaseModel):
assert m3.to_dict(exclude_none=True) == {}
assert m3.to_dict(exclude_defaults=True) == {}

if PYDANTIC_V2:
class Model2(BaseModel):
created_at: datetime

class Model2(BaseModel):
created_at: datetime

time_str = "2024-03-21T11:39:01.275859"
m4 = Model2.construct(created_at=time_str)
assert m4.to_dict(mode="python") == {"created_at": datetime.fromisoformat(time_str)}
assert m4.to_dict(mode="json") == {"created_at": time_str}
else:
with pytest.raises(ValueError, match="mode is only supported in Pydantic v2"):
m.to_dict(mode="json")
time_str = "2024-03-21T11:39:01.275859"
m4 = Model2.construct(created_at=time_str)
assert m4.to_dict(mode="python") == {"created_at": datetime.fromisoformat(time_str)}
assert m4.to_dict(mode="json") == {"created_at": time_str}

if not PYDANTIC_V2:
with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"):
m.to_dict(warnings=False)

@@ -558,16 +554,21 @@ class Model(BaseModel):
assert m3.model_dump(exclude_none=True) == {}

if not PYDANTIC_V2:
with pytest.raises(ValueError, match="mode is only supported in Pydantic v2"):
m.model_dump(mode="json")

with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"):
m.model_dump(round_trip=True)

with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"):
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)
15 changes: 15 additions & 0 deletions tests/test_transform.py
Original file line number Diff line number Diff line change
@@ -177,17 +177,32 @@ class DateDict(TypedDict, total=False):
foo: Annotated[date, PropertyInfo(format="iso8601")]


class DatetimeModel(BaseModel):
foo: datetime


class DateModel(BaseModel):
foo: Optional[date]


@parametrize
@pytest.mark.asyncio
async def test_iso8601_format(use_async: bool) -> None:
dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00")
tz = "Z" if PYDANTIC_V2 else "+00:00"
assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap]
assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692" + tz} # type: ignore[comparison-overlap]

dt = dt.replace(tzinfo=None)
assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap]
assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap]

assert await transform({"foo": None}, DateDict, use_async) == {"foo": None} # type: ignore[comparison-overlap]
assert await transform(DateModel(foo=None), Any, use_async) == {"foo": None} # type: ignore
assert await transform({"foo": date.fromisoformat("2023-02-23")}, DateDict, use_async) == {"foo": "2023-02-23"} # type: ignore[comparison-overlap]
assert await transform(DateModel(foo=date.fromisoformat("2023-02-23")), DateDict, use_async) == {
"foo": "2023-02-23"
} # type: ignore[comparison-overlap]


@parametrize