Skip to content

Commit

Permalink
Added preliminary support for Python 3.14 (#813)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Alex Grönholm <[email protected]>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Jan 2, 2025
1 parent 9a792f3 commit e8730ae
Show file tree
Hide file tree
Showing 9 changed files with 175 additions and 41 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", pypy-3.10]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", pypy-3.10]
include:
- os: macos-latest
python-version: "3.9"
Expand Down
2 changes: 2 additions & 0 deletions docs/versionhistory.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.

**UNRELEASED**

- Added support for the ``copy()``, ``copy_into()``, ``move()`` and ``move_into()``
methods in ``anyio.Path``, available in Python 3.14
- Configure ``SO_RCVBUF``, ``SO_SNDBUF`` and ``TCP_NODELAY`` on the selector
thread waker socket pair. This should improve the performance of ``wait_readable()``
and ``wait_writable()`` when using the ``ProactorEventLoop``
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,12 @@ test = [
"hypothesis >= 4.0",
"psutil >= 5.9",
"pytest >= 7.0",
"pytest-mock >= 3.6.1",
"trustme",
"truststore >= 0.9.1; python_version >= '3.10'",
"""\
uvloop >= 0.21; platform_python_implementation == 'CPython' \
and platform_system != 'Windows'\
and platform_system != 'Windows' \
and python_version < '3.14'\
"""
]
doc = [
Expand Down
57 changes: 56 additions & 1 deletion src/anyio/_core/_fileio.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
import os
import pathlib
import sys
from collections.abc import AsyncIterator, Callable, Iterable, Iterator, Sequence
from collections.abc import (
AsyncIterator,
Callable,
Iterable,
Iterator,
Sequence,
)
from dataclasses import dataclass
from functools import partial
from os import PathLike
Expand Down Expand Up @@ -220,11 +226,15 @@ class Path:
Some methods may be unavailable or have limited functionality, based on the Python
version:
* :meth:`~pathlib.Path.copy` (available on Python 3.14 or later)
* :meth:`~pathlib.Path.copy_into` (available on Python 3.14 or later)
* :meth:`~pathlib.Path.from_uri` (available on Python 3.13 or later)
* :meth:`~pathlib.Path.full_match` (available on Python 3.13 or later)
* :meth:`~pathlib.Path.is_junction` (available on Python 3.12 or later)
* :meth:`~pathlib.Path.match` (the ``case_sensitive`` paramater is only available on
Python 3.13 or later)
* :meth:`~pathlib.Path.move` (available on Python 3.14 or later)
* :meth:`~pathlib.Path.move_into` (available on Python 3.14 or later)
* :meth:`~pathlib.Path.relative_to` (the ``walk_up`` parameter is only available on
Python 3.12 or later)
* :meth:`~pathlib.Path.walk` (available on Python 3.12 or later)
Expand Down Expand Up @@ -396,6 +406,51 @@ def match(
def match(self, path_pattern: str) -> bool:
return self._path.match(path_pattern)

if sys.version_info >= (3, 14):

async def copy(
self,
target: str | os.PathLike[str],
*,
follow_symlinks: bool = True,
dirs_exist_ok: bool = False,
preserve_metadata: bool = False,
) -> Path:
func = partial(
self._path.copy,
follow_symlinks=follow_symlinks,
dirs_exist_ok=dirs_exist_ok,
preserve_metadata=preserve_metadata,
)
return Path(await to_thread.run_sync(func, target))

async def copy_into(
self,
target_dir: str | os.PathLike[str],
*,
follow_symlinks: bool = True,
dirs_exist_ok: bool = False,
preserve_metadata: bool = False,
) -> Path:
func = partial(
self._path.copy_into,
follow_symlinks=follow_symlinks,
dirs_exist_ok=dirs_exist_ok,
preserve_metadata=preserve_metadata,
)
return Path(await to_thread.run_sync(func, target_dir))

async def move(self, target: str | os.PathLike[str]) -> Path:
# Upstream does not handle anyio.Path properly as a PathLike
target = pathlib.Path(target)
return Path(await to_thread.run_sync(self._path.move, target))

async def move_into(
self,
target_dir: str | os.PathLike[str],
) -> Path:
return Path(await to_thread.run_sync(self._path.move_into, target_dir))

def is_relative_to(self, other: str | PathLike[str]) -> bool:
try:
self.relative_to(other)
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
pytest.mark.skip(reason="uvloop is missing shutdown_default_executor()")
)

pytest_plugins = ["pytester", "pytest_mock"]
pytest_plugins = ["pytester"]


@pytest.fixture(
Expand Down
18 changes: 8 additions & 10 deletions tests/streams/test_tls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
from contextlib import AbstractContextManager, ExitStack
from threading import Thread
from typing import NoReturn
from unittest import mock

import pytest
from pytest_mock import MockerFixture
from trustme import CA

from anyio import (
Expand Down Expand Up @@ -375,18 +375,16 @@ def serve_sync() -> None:
not hasattr(ssl, "OP_IGNORE_UNEXPECTED_EOF"),
reason="The ssl module does not have the OP_IGNORE_UNEXPECTED_EOF attribute",
)
async def test_default_context_ignore_unexpected_eof_flag_off(
self, mocker: MockerFixture
) -> None:
async def test_default_context_ignore_unexpected_eof_flag_off(self) -> None:
send1, receive1 = create_memory_object_stream[bytes]()
client_stream = StapledObjectStream(send1, receive1)
mocker.patch.object(TLSStream, "_call_sslobject_method")
tls_stream = await TLSStream.wrap(client_stream)
ssl_context = tls_stream.extra(TLSAttribute.ssl_object).context
assert not ssl_context.options & ssl.OP_IGNORE_UNEXPECTED_EOF
with mock.patch.object(TLSStream, "_call_sslobject_method"):
tls_stream = await TLSStream.wrap(client_stream)
ssl_context = tls_stream.extra(TLSAttribute.ssl_object).context
assert not ssl_context.options & ssl.OP_IGNORE_UNEXPECTED_EOF

send1.close()
receive1.close()
send1.close()
receive1.close()

async def test_truststore_ssl(
self, request: pytest.FixtureRequest, server_context: ssl.SSLContext
Expand Down
12 changes: 8 additions & 4 deletions tests/test_eventloop.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
import asyncio
import math
from asyncio import get_running_loop
from collections.abc import Generator
from unittest import mock
from unittest.mock import AsyncMock

import pytest
from pytest import MonkeyPatch
from pytest_mock.plugin import MockerFixture

from anyio import run, sleep_forever, sleep_until

Expand All @@ -16,9 +17,12 @@


@pytest.fixture
def fake_sleep(mocker: MockerFixture) -> AsyncMock:
mocker.patch("anyio._core._eventloop.current_time", return_value=fake_current_time)
return mocker.patch("anyio._core._eventloop.sleep", AsyncMock())
def fake_sleep() -> Generator[AsyncMock, None, None]:
with mock.patch(
"anyio._core._eventloop.current_time", return_value=fake_current_time
):
with mock.patch("anyio._core._eventloop.sleep", AsyncMock()) as v:
yield v


async def test_sleep_until(fake_sleep: AsyncMock) -> None:
Expand Down
48 changes: 48 additions & 0 deletions tests/test_fileio.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,54 @@ async def test_is_symlink(self, tmp_path: pathlib.Path) -> None:
def test_is_relative_to(self, arg: str, result: bool) -> None:
assert Path("/xyz/abc/foo").is_relative_to(arg) == result

@pytest.mark.skipif(
sys.version_info < (3, 14),
reason="Path.copy() is only available on Python 3.14+",
)
async def test_copy(self, tmp_path: pathlib.Path) -> None:
source_path = Path(tmp_path) / "source"
destination_path = Path(tmp_path) / "destination"
await source_path.write_text("hello")
result = await source_path.copy(destination_path) # type: ignore[attr-defined]
assert await result.read_text() == "hello"

@pytest.mark.skipif(
sys.version_info < (3, 14),
reason="Path.copy() is only available on Python 3.14+",
)
async def test_copy_into(self, tmp_path: pathlib.Path) -> None:
source_path = Path(tmp_path) / "source"
destination_path = Path(tmp_path) / "destination"
await destination_path.mkdir()
await source_path.write_text("hello")
result = await source_path.copy_into(destination_path) # type: ignore[attr-defined]
assert await result.read_text() == "hello"

@pytest.mark.skipif(
sys.version_info < (3, 14),
reason="Path.copy() is only available on Python 3.14+",
)
async def test_move(self, tmp_path: pathlib.Path) -> None:
source_path = Path(tmp_path) / "source"
destination_path = Path(tmp_path) / "destination"
await source_path.write_text("hello")
result = await source_path.move(destination_path) # type: ignore[attr-defined]
assert await result.read_text() == "hello"
assert not await source_path.exists()

@pytest.mark.skipif(
sys.version_info < (3, 14),
reason="Path.copy() is only available on Python 3.14+",
)
async def test_move_into(self, tmp_path: pathlib.Path) -> None:
source_path = Path(tmp_path) / "source"
destination_path = Path(tmp_path) / "destination"
await destination_path.mkdir()
await source_path.write_text("hello")
result = await source_path.move_into(destination_path) # type: ignore[attr-defined]
assert await result.read_text() == "hello"
assert not await source_path.exists()

async def test_glob(self, populated_tmpdir: pathlib.Path) -> None:
all_paths = []
async for path in Path(populated_tmpdir).glob("**/*.txt"):
Expand Down
71 changes: 49 additions & 22 deletions tests/test_taskgroups.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
from asyncio import CancelledError
from collections.abc import AsyncGenerator, Coroutine, Generator
from typing import Any, NoReturn, cast
from unittest import mock

import pytest
from exceptiongroup import catch
from pytest import FixtureRequest, MonkeyPatch
from pytest_mock import MockerFixture

import anyio
from anyio import (
Expand Down Expand Up @@ -262,31 +262,43 @@ async def taskfunc() -> None:


@pytest.mark.parametrize("anyio_backend", ["asyncio"])
async def test_cancel_with_nested_task_groups(mocker: MockerFixture) -> None:
async def test_cancel_with_nested_task_groups() -> None:
"""Regression test for #695."""

async def shield_task() -> None:
with CancelScope(shield=True) as scope:
shielded_cancel_spy = mocker.spy(scope, "_deliver_cancellation")
await sleep(0.5)
with mock.patch.object(
scope,
"_deliver_cancellation",
wraps=getattr(scope, "_deliver_cancellation"),
) as shielded_cancel_spy:
await sleep(0.5)

assert len(outer_cancel_spy.call_args_list) < 10
shielded_cancel_spy.assert_not_called()
assert len(outer_cancel_spy.call_args_list) < 10
shielded_cancel_spy.assert_not_called()

async def middle_task() -> None:
try:
async with create_task_group() as tg:
middle_cancel_spy = mocker.spy(tg.cancel_scope, "_deliver_cancellation")
tg.start_soon(shield_task, name="shield task")
with mock.patch.object(
tg.cancel_scope,
"_deliver_cancellation",
wraps=getattr(tg.cancel_scope, "_deliver_cancellation"),
) as middle_cancel_spy:
tg.start_soon(shield_task, name="shield task")
finally:
assert len(middle_cancel_spy.call_args_list) < 10
assert len(outer_cancel_spy.call_args_list) < 10

async with create_task_group() as tg:
outer_cancel_spy = mocker.spy(tg.cancel_scope, "_deliver_cancellation")
tg.start_soon(middle_task, name="middle task")
await wait_all_tasks_blocked()
tg.cancel_scope.cancel()
with mock.patch.object(
tg.cancel_scope,
"_deliver_cancellation",
wraps=getattr(tg.cancel_scope, "_deliver_cancellation"),
) as outer_cancel_spy:
tg.start_soon(middle_task, name="middle task")
await wait_all_tasks_blocked()
tg.cancel_scope.cancel()

assert len(outer_cancel_spy.call_args_list) < 10

Expand Down Expand Up @@ -1602,14 +1614,29 @@ async def in_task_group(task_status: TaskStatus[None]) -> None:
assert not tg.cancel_scope.cancel_called


if sys.version_info <= (3, 11):
if sys.version_info >= (3, 14):

def no_other_refs() -> list[object]:
return [sys._getframe(1)]
else:
async def no_other_refs() -> list[object]:
frame = sys._getframe(1)
coro = get_current_task().coro

def no_other_refs() -> list[object]:
async def get_coro_for_frame(*, task_status: TaskStatus[object]) -> None:
my_coro = coro
while my_coro.cr_frame is not frame:
my_coro = my_coro.cr_await
task_status.started(my_coro)

async with create_task_group() as tg:
return [await tg.start(get_coro_for_frame)]

elif sys.version_info >= (3, 11):

async def no_other_refs() -> list[object]:
return []
else:

async def no_other_refs() -> list[object]:
return [sys._getframe(1)]


@pytest.mark.skipif(
Expand Down Expand Up @@ -1640,7 +1667,7 @@ class _Done(Exception):
exc = e

assert exc is not None
assert gc.get_referrers(exc) == no_other_refs()
assert gc.get_referrers(exc) == await no_other_refs()

async def test_exception_refcycles_errors(self) -> None:
"""Test that TaskGroup deletes self._exceptions, and __aexit__ args"""
Expand All @@ -1657,7 +1684,7 @@ class _Done(Exception):
exc = excs.exceptions[0]

assert isinstance(exc, _Done)
assert gc.get_referrers(exc) == no_other_refs()
assert gc.get_referrers(exc) == await no_other_refs()

async def test_exception_refcycles_parent_task(self) -> None:
"""Test that TaskGroup's cancel_scope deletes self._host_task"""
Expand All @@ -1678,7 +1705,7 @@ async def coro_fn() -> None:
exc = excs.exceptions[0].exceptions[0]

assert isinstance(exc, _Done)
assert gc.get_referrers(exc) == no_other_refs()
assert gc.get_referrers(exc) == await no_other_refs()

async def test_exception_refcycles_propagate_cancellation_error(self) -> None:
"""Test that TaskGroup deletes cancelled_exc"""
Expand All @@ -1695,7 +1722,7 @@ async def test_exception_refcycles_propagate_cancellation_error(self) -> None:
raise

assert isinstance(exc, get_cancelled_exc_class())
assert gc.get_referrers(exc) == no_other_refs()
assert gc.get_referrers(exc) == await no_other_refs()

async def test_exception_refcycles_base_error(self) -> None:
"""
Expand All @@ -1718,7 +1745,7 @@ class MyKeyboardInterrupt(KeyboardInterrupt):
exc = excs.exceptions[0]

assert isinstance(exc, MyKeyboardInterrupt)
assert gc.get_referrers(exc) == no_other_refs()
assert gc.get_referrers(exc) == await no_other_refs()


class TestTaskStatusTyping:
Expand Down

0 comments on commit e8730ae

Please sign in to comment.