Skip to content
Open
Show file tree
Hide file tree
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 README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ AnyIO offers the following functionality:
streams)
* Worker threads
* Subprocesses
* Subinterpreter support for code parallelization (on Python 3.13 and later)
* Subinterpreter support for code parallelization (on Python 3.14 and later)
* Asynchronous file I/O (using worker threads)
* Signal handling
* Asynchronous versions of the functools_ and itertools_ modules
Expand Down
2 changes: 1 addition & 1 deletion docs/subinterpreters.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ This is done by using :func:`.to_interpreter.run_sync`::
Limitations
-----------

* Subinterpreters are only supported on Python 3.13 or later
* Subinterpreters are only supported on Python 3.14 or later
* Code in the ``__main__`` module cannot be run with this (as a consequence, this
applies to any functions defined in the REPL)
* The target functions cannot react to cancellation
Expand Down
2 changes: 1 addition & 1 deletion docs/subprocesses.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Exceptions to this rule are:
#. Blocking I/O operations
#. C extension code that explicitly releases the Global Interpreter Lock
#. :doc:`Subinterpreter workers <subinterpreters>`
(experimental; available on Python 3.13 and later)
(experimental; available on Python 3.14 and later)

If the code you wish to run does not belong in this category, it's best to use worker
processes instead in order to take advantage of multiple CPU cores.
Expand Down
5 changes: 5 additions & 0 deletions docs/versionhistory.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ Version history

This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.

**UNRELEASED**

- **BACKWARDS INCOMPATIBLE** Removed subinterpreter support for Python 3.13
(`#1158 <https://github.com/agronholm/anyio/issues/1158>`_; PR by @wanxiankai)

**4.14.2**

- Changed ``ByteReceiveStream.receive()`` implementations to raise a ``ValueError`` when
Expand Down
82 changes: 1 addition & 81 deletions src/anyio/to_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,88 +58,13 @@ def call(
raise res

return res
elif sys.version_info >= (3, 13):
import _interpqueues
import _interpreters

UNBOUND: Final = 2 # I have no clue how this works, but it was used in the stdlib
FMT_UNPICKLED: Final = 0
FMT_PICKLED: Final = 1
QUEUE_PICKLE_ARGS: Final = (FMT_PICKLED, UNBOUND)
QUEUE_UNPICKLE_ARGS: Final = (FMT_UNPICKLED, UNBOUND)

_run_func = compile(
"""
import _interpqueues
from _interpreters import NotShareableError
from pickle import loads, dumps, HIGHEST_PROTOCOL

QUEUE_PICKLE_ARGS = (1, 2)
QUEUE_UNPICKLE_ARGS = (0, 2)

item = _interpqueues.get(queue_id)[0]
try:
func, args = loads(item)
retval = func(*args)
except BaseException as exc:
is_exception = True
retval = exc
else:
is_exception = False

try:
_interpqueues.put(queue_id, (retval, is_exception), *QUEUE_UNPICKLE_ARGS)
except NotShareableError:
retval = dumps(retval, HIGHEST_PROTOCOL)
_interpqueues.put(queue_id, (retval, is_exception), *QUEUE_PICKLE_ARGS)
""",
"<string>",
"exec",
)

class _Worker:
last_used: float = 0

def __init__(self) -> None:
self._interpreter_id = _interpreters.create()
self._queue_id = _interpqueues.create(1, *QUEUE_UNPICKLE_ARGS)
_interpreters.set___main___attrs(
self._interpreter_id, {"queue_id": self._queue_id}
)

def destroy(self) -> None:
_interpqueues.destroy(self._queue_id)
_interpreters.destroy(self._interpreter_id)

def call(
self,
func: Callable[..., T_Retval],
args: tuple[Any, ...],
) -> T_Retval:
import pickle

item = pickle.dumps((func, args), pickle.HIGHEST_PROTOCOL)
_interpqueues.put(self._queue_id, item, *QUEUE_PICKLE_ARGS)
exc_info = _interpreters.exec(self._interpreter_id, _run_func)
if exc_info:
raise BrokenWorkerInterpreter(exc_info)

res = _interpqueues.get(self._queue_id)
(res, is_exception), fmt = res[:2]
if fmt == FMT_PICKLED:
res = pickle.loads(res)

if is_exception:
raise res

return res
else:

class _Worker:
last_used: float = 0

def __init__(self) -> None:
raise RuntimeError("subinterpreters require at least Python 3.13")
raise RuntimeError("subinterpreters require at least Python 3.14")

def call(
self,
Expand Down Expand Up @@ -179,11 +104,6 @@ async def run_sync(
"""
Call the given function with the given arguments in a subinterpreter.

.. warning:: On Python 3.13, the :mod:`concurrent.interpreters` module was not yet
available, so the code path for that Python version relies on an undocumented,
private API. As such, it is recommended to not rely on this function for anything
mission-critical on Python 3.13.

:param func: a callable
:param args: the positional arguments for the callable
:param limiter: capacity limiter to use to limit the total number of subinterpreters
Expand Down
25 changes: 18 additions & 7 deletions tests/test_to_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@

from anyio import to_interpreter

pytestmark = [
pytest.mark.skipif(sys.version_info < (3, 13), reason="requires Python 3.13+"),
]
requires_py314 = pytest.mark.skipif(
sys.version_info < (3, 14), reason="requires Python 3.14+"
)


@fixture(autouse=True)
Expand All @@ -24,27 +24,38 @@ async def destroy_workers() -> AsyncGenerator[None]:
idle_workers.clear()


@pytest.mark.skipif(sys.version_info >= (3, 14), reason="requires Python < 3.14")
async def test_run_sync_requires_python_314() -> None:
with pytest.raises(
RuntimeError, match="subinterpreters require at least Python 3.14"
):
await to_interpreter.run_sync(int, "1")


@requires_py314
async def test_run_sync() -> None:
"""
Test that the function runs in a different interpreter, and the same interpreter in
both calls.

"""
import _interpreters
from concurrent.interpreters import get_current

main_interpreter_id, _ = _interpreters.get_current()
interpreter_id, _ = await to_interpreter.run_sync(_interpreters.get_current)
interpreter_id_2, _ = await to_interpreter.run_sync(_interpreters.get_current)
main_interpreter_id = get_current().id
interpreter_id = (await to_interpreter.run_sync(get_current)).id
interpreter_id_2 = (await to_interpreter.run_sync(get_current)).id
assert interpreter_id == interpreter_id_2
assert interpreter_id != main_interpreter_id


@requires_py314
async def test_args_kwargs() -> None:
"""Test that partial() can be used to pass keyword arguments."""
result = await to_interpreter.run_sync(partial(sorted, reverse=True), ["a", "b"])
assert result == ["b", "a"]


@requires_py314
async def test_exception() -> None:
"""Test that exceptions are delivered properly."""
with pytest.raises(ValueError, match="invalid literal for int"):
Expand Down
Loading