Skip to content
Permalink

Comparing changes

This is a direct comparison between two commits made in this repository or its related repositories. View the default comparison for this range or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: sentdm/sent-python
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: 9bdaa929e5c4bdaa3b165085929dfb9180111607
Choose a base ref
..
head repository: sentdm/sent-python
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: 19b12318b61d60a33d0fdde4ddae9759f01e7764
Choose a head ref
Showing with 83 additions and 62 deletions.
  1. +1 −1 .release-please-manifest.json
  2. +0 −9 CHANGELOG.md
  3. +2 −1 pyproject.toml
  4. +1 −0 requirements-dev.lock
  5. +40 −50 src/sent/_utils/_sync.py
  6. +1 −1 src/sent/_version.py
  7. +38 −0 tests/test_client.py
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.2"
".": "0.1.0-alpha.1"
}
9 changes: 0 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,5 @@
# Changelog

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

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

* 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))

## 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)
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "sentdm"
version = "0.1.0-alpha.2"
version = "0.1.0-alpha.1"
description = "The official Python library for the Sent API"
dynamic = ["readme"]
license = "Apache-2.0"
@@ -55,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]
1 change: 1 addition & 0 deletions requirements-dev.lock
Original file line number Diff line number Diff line change
@@ -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
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
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.2" # x-release-please-version
__version__ = "0.1.0-alpha.1" # x-release-please-version
38 changes: 38 additions & 0 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

@@ -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