diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3c0badce..3c79d4dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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" diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index 4901da4c..245f9eee 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -5,6 +5,8 @@ This library adheres to `Semantic Versioning 2.0 `_. **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`` diff --git a/pyproject.toml b/pyproject.toml index 9fed760f..b8a9ea2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/anyio/_core/_fileio.py b/src/anyio/_core/_fileio.py index ef2930e4..4e34f2ad 100644 --- a/src/anyio/_core/_fileio.py +++ b/src/anyio/_core/_fileio.py @@ -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 @@ -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) @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py index 9d5acbfa..52998044 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,7 +26,7 @@ pytest.mark.skip(reason="uvloop is missing shutdown_default_executor()") ) -pytest_plugins = ["pytester", "pytest_mock"] +pytest_plugins = ["pytester"] @pytest.fixture( diff --git a/tests/streams/test_tls.py b/tests/streams/test_tls.py index d1ac2178..0b5eb95a 100644 --- a/tests/streams/test_tls.py +++ b/tests/streams/test_tls.py @@ -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 ( @@ -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 diff --git a/tests/test_eventloop.py b/tests/test_eventloop.py index 7431acf7..68fa733f 100644 --- a/tests/test_eventloop.py +++ b/tests/test_eventloop.py @@ -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 @@ -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: diff --git a/tests/test_fileio.py b/tests/test_fileio.py index f5d0183b..d7a769bd 100644 --- a/tests/test_fileio.py +++ b/tests/test_fileio.py @@ -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"): diff --git a/tests/test_taskgroups.py b/tests/test_taskgroups.py index d20565c1..6410f5e3 100644 --- a/tests/test_taskgroups.py +++ b/tests/test_taskgroups.py @@ -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 ( @@ -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 @@ -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( @@ -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""" @@ -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""" @@ -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""" @@ -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: """ @@ -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: