diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0747e6d358..4267bf3d2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches-ignore: - "dependabot/**" pull_request: + merge_group: concurrency: group: ${{ github.ref }}-${{ github.workflow }}-${{ github.event_name }}${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && format('-{0}', github.sha) || '' }} @@ -189,7 +190,8 @@ jobs: # can't use setup-python because that python doesn't seem to work; # `python3-dev` (rather than `python:alpine`) for some ctypes reason, # `nodejs` for pyright (`node-env` pulls in nodejs but that takes a while and can time out the test). - run: apk update && apk add python3-dev bash nodejs + # `perl` for a platform independent `sed -i` alternative + run: apk update && apk add python3-dev bash nodejs perl - name: Enter virtual environment run: python -m venv .venv - name: Run tests diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 38f8b73c95..ba16bc4d93 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.0 + rev: v0.8.1 hooks: - id: ruff types: [file] diff --git a/ci.sh b/ci.sh index ef3dee55ca..83ec65748b 100755 --- a/ci.sh +++ b/ci.sh @@ -116,13 +116,13 @@ else echo "::group::Setup for tests" # We run the tests from inside an empty directory, to make sure Python - # doesn't pick up any .py files from our working dir. Might have been - # pre-created by some of the code above. + # doesn't pick up any .py files from our working dir. Might have already + # been created by a previous run. mkdir empty || true cd empty INSTALLDIR=$(python -c "import os, trio; print(os.path.dirname(trio.__file__))") - cp ../pyproject.toml "$INSTALLDIR" + cp ../pyproject.toml "$INSTALLDIR" # TODO: remove this # get mypy tests a nice cache MYPYPATH=".." mypy --config-file= --cache-dir=./.mypy_cache -c "import trio" >/dev/null 2>/dev/null || true @@ -130,9 +130,15 @@ else # support subprocess spawning with coverage.py echo "import coverage; coverage.process_startup()" | tee -a "$INSTALLDIR/../sitecustomize.py" + perl -i -pe 's/-p trio\._tests\.pytest_plugin//' "$INSTALLDIR/pyproject.toml" + echo "::endgroup::" echo "::group:: Run Tests" - if COVERAGE_PROCESS_START=$(pwd)/../pyproject.toml coverage run --rcfile=../pyproject.toml -m pytest -ra --junitxml=../test-results.xml --run-slow "${INSTALLDIR}" --verbose --durations=10 $flags; then + if PYTHONPATH=../tests COVERAGE_PROCESS_START=$(pwd)/../pyproject.toml \ + coverage run --rcfile=../pyproject.toml -m \ + pytest -ra --junitxml=../test-results.xml \ + -p _trio_check_attrs_aliases --verbose --durations=10 \ + -p trio._tests.pytest_plugin --run-slow $flags "${INSTALLDIR}"; then PASSED=true else PASSED=false diff --git a/docs-requirements.txt b/docs-requirements.txt index 9698e57851..03cefbc9a4 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -24,7 +24,7 @@ colorama==0.4.6 ; sys_platform == 'win32' or platform_system == 'Windows' # via # click # sphinx -cryptography==43.0.3 +cryptography==44.0.0 # via pyopenssl docutils==0.21.2 # via @@ -49,13 +49,13 @@ markupsafe==3.0.2 # via jinja2 outcome==1.3.0.post0 # via -r docs-requirements.in -packaging==24.1 +packaging==24.2 # via sphinx pycparser==2.22 ; platform_python_implementation != 'PyPy' or os_name == 'nt' # via cffi pygments==2.18.0 # via sphinx -pyopenssl==24.2.1 +pyopenssl==24.3.0 # via -r docs-requirements.in requests==2.32.3 # via sphinx @@ -77,9 +77,9 @@ sphinx==8.1.3 # sphinxcontrib-trio sphinx-codeautolink==0.15.2 # via -r docs-requirements.in -sphinx-hoverxref==1.4.1 +sphinx-hoverxref==1.4.2 # via -r docs-requirements.in -sphinx-rtd-theme==3.0.1 +sphinx-rtd-theme==3.0.2 # via -r docs-requirements.in sphinxcontrib-applehelp==2.0.0 # via sphinx diff --git a/newsfragments/3114.bugfix.rst b/newsfragments/3114.bugfix.rst new file mode 100644 index 0000000000..2f07712199 --- /dev/null +++ b/newsfragments/3114.bugfix.rst @@ -0,0 +1 @@ +Ensure that Pyright recognizes our underscore prefixed attributes for attrs classes. diff --git a/pyproject.toml b/pyproject.toml index a9785c0be0..06445a1010 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -203,7 +203,7 @@ reportUnnecessaryTypeIgnoreComment = true typeCheckingMode = "strict" [tool.pytest.ini_options] -addopts = ["--strict-markers", "--strict-config", "-p trio._tests.pytest_plugin"] +addopts = ["--strict-markers", "--strict-config", "-p trio._tests.pytest_plugin", "--import-mode=importlib"] faulthandler_timeout = 60 filterwarnings = [ "error", diff --git a/src/trio/_core/_concat_tb.py b/src/trio/_core/_concat_tb.py index 82e5251372..a1469618e1 100644 --- a/src/trio/_core/_concat_tb.py +++ b/src/trio/_core/_concat_tb.py @@ -1,8 +1,10 @@ from __future__ import annotations -from types import TracebackType from typing import TYPE_CHECKING, ClassVar, cast +if TYPE_CHECKING: + from types import TracebackType + ################################################################ # concat_tb ################################################################ @@ -107,7 +109,7 @@ def controller( # type: ignore[no-any-unimported] return operation.delegate() # type: ignore[no-any-return] return cast( - TracebackType, + "TracebackType", tputil.make_proxy(controller, type(base_tb), base_tb), ) # Returns proxy to traceback diff --git a/src/trio/_core/_io_windows.py b/src/trio/_core/_io_windows.py index c4325a2860..1874f5c791 100644 --- a/src/trio/_core/_io_windows.py +++ b/src/trio/_core/_io_windows.py @@ -700,7 +700,7 @@ def _refresh_afd(self, base_handle: Handle) -> None: lpOverlapped = ffi.new("LPOVERLAPPED") - poll_info = cast(_AFDPollInfo, ffi.new("AFD_POLL_INFO *")) + poll_info = cast("_AFDPollInfo", ffi.new("AFD_POLL_INFO *")) poll_info.Timeout = 2**63 - 1 # INT64_MAX poll_info.NumberOfHandles = 1 poll_info.Exclusive = 0 @@ -713,9 +713,9 @@ def _refresh_afd(self, base_handle: Handle) -> None: kernel32.DeviceIoControl( afd_group.handle, IoControlCodes.IOCTL_AFD_POLL, - cast(CType, poll_info), + cast("CType", poll_info), ffi.sizeof("AFD_POLL_INFO"), - cast(CType, poll_info), + cast("CType", poll_info), ffi.sizeof("AFD_POLL_INFO"), ffi.NULL, lpOverlapped, @@ -937,13 +937,13 @@ async def _perform_overlapped( # operation will not be cancellable, depending on how Windows is # feeling today. So we need to check for cancellation manually. await _core.checkpoint_if_cancelled() - lpOverlapped = cast(_Overlapped, ffi.new("LPOVERLAPPED")) + lpOverlapped = cast("_Overlapped", ffi.new("LPOVERLAPPED")) try: submit_fn(lpOverlapped) except OSError as exc: if exc.winerror != ErrorCodes.ERROR_IO_PENDING: raise - await self.wait_overlapped(handle, cast(CData, lpOverlapped)) + await self.wait_overlapped(handle, cast("CData", lpOverlapped)) return lpOverlapped @_public diff --git a/src/trio/_core/_local.py b/src/trio/_core/_local.py index 53cbfc135e..fff1234f59 100644 --- a/src/trio/_core/_local.py +++ b/src/trio/_core/_local.py @@ -38,13 +38,13 @@ class RunVar(Generic[T]): """ - _name: str - _default: T | type[_NoValue] = _NoValue + _name: str = attrs.field(alias="name") + _default: T | type[_NoValue] = attrs.field(default=_NoValue, alias="default") def get(self, default: T | type[_NoValue] = _NoValue) -> T: """Gets the value of this :class:`RunVar` for the current run call.""" try: - return cast(T, _run.GLOBAL_RUN_CONTEXT.runner._locals[self]) + return cast("T", _run.GLOBAL_RUN_CONTEXT.runner._locals[self]) except AttributeError: raise RuntimeError("Cannot be used outside of a run context") from None except KeyError: diff --git a/src/trio/_core/_run.py b/src/trio/_core/_run.py index 2bfe3254c4..bfb38f480f 100644 --- a/src/trio/_core/_run.py +++ b/src/trio/_core/_run.py @@ -543,9 +543,13 @@ class CancelScope: cancelled_caught: bool = attrs.field(default=False, init=False) # Constructor arguments: - _relative_deadline: float = attrs.field(default=inf, kw_only=True) - _deadline: float = attrs.field(default=inf, kw_only=True) - _shield: bool = attrs.field(default=False, kw_only=True) + _relative_deadline: float = attrs.field( + default=inf, + kw_only=True, + alias="relative_deadline", + ) + _deadline: float = attrs.field(default=inf, kw_only=True, alias="deadline") + _shield: bool = attrs.field(default=False, kw_only=True, alias="shield") def __attrs_post_init__(self) -> None: if isnan(self._deadline): @@ -941,7 +945,7 @@ def started(self: _TaskStatus[StatusT], value: StatusT) -> None: ... def started(self, value: StatusT | None = None) -> None: if self._value is not _NoStatus: raise RuntimeError("called 'started' twice on the same task status") - self._value = cast(StatusT, value) # If None, StatusT == None + self._value = cast("StatusT", value) # If None, StatusT == None # If the old nursery is cancelled, then quietly quit now; the child # will eventually exit on its own, and we don't want to risk moving @@ -2407,7 +2411,7 @@ def run( # Inlined copy of runner.main_task_outcome.unwrap() to avoid # cluttering every single Trio traceback with an extra frame. if isinstance(runner.main_task_outcome, Value): - return cast(RetT, runner.main_task_outcome.value) + return cast("RetT", runner.main_task_outcome.value) elif isinstance(runner.main_task_outcome, Error): raise runner.main_task_outcome.error else: # pragma: no cover @@ -2531,7 +2535,7 @@ def my_done_callback(run_outcome): # this time, so it shouldn't be possible to get an exception here, # except for a TrioInternalError. next_send = cast( - EventResult, + "EventResult", None, ) # First iteration must be `None`, every iteration after that is EventResult for _tick in range(5): # expected need is 2 iterations + leave some wiggle room diff --git a/src/trio/_core/_tests/test_guest_mode.py b/src/trio/_core/_tests/test_guest_mode.py index 1a4f87464b..526932b949 100644 --- a/src/trio/_core/_tests/test_guest_mode.py +++ b/src/trio/_core/_tests/test_guest_mode.py @@ -467,7 +467,7 @@ def trio_done_callback(main_outcome: Outcome[T]) -> None: trio_done_fut.set_result(main_outcome) if pass_not_threadsafe: - run_sync_soon_not_threadsafe = cast(InHost, loop.call_soon) + run_sync_soon_not_threadsafe = cast("InHost", loop.call_soon) trio.lowlevel.start_guest_run( trio_fn, diff --git a/src/trio/_core/_tests/tutil.py b/src/trio/_core/_tests/tutil.py index 81370ed76e..063fa1dd80 100644 --- a/src/trio/_core/_tests/tutil.py +++ b/src/trio/_core/_tests/tutil.py @@ -12,7 +12,7 @@ import pytest -# See trio/_tests/conftest.py for the other half of this +# See trio/_tests/pytest_plugin.py for the other half of this from trio._tests.pytest_plugin import RUN_SLOW if TYPE_CHECKING: diff --git a/src/trio/_core/_traps.py b/src/trio/_core/_traps.py index bef77b7688..1ddd5628ba 100644 --- a/src/trio/_core/_traps.py +++ b/src/trio/_core/_traps.py @@ -4,7 +4,6 @@ import enum import types -from collections.abc import Awaitable # Jedi gets mad in test_static_tool_sees_class_members if we use collections Callable from typing import TYPE_CHECKING, Any, Callable, NoReturn, Union, cast @@ -15,7 +14,7 @@ from . import _run if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Awaitable, Generator from typing_extensions import TypeAlias @@ -67,7 +66,7 @@ def _real_async_yield( # Real yield value is from trio's main loop, but type checkers can't # understand that, so we cast it to make type checkers understand. _async_yield = cast( - Callable[[MessageType], Awaitable[outcome.Outcome[object]]], + "Callable[[MessageType], Awaitable[outcome.Outcome[object]]]", _real_async_yield, ) diff --git a/src/trio/_core/_windows_cffi.py b/src/trio/_core/_windows_cffi.py index 453b4beda3..575fcb5601 100644 --- a/src/trio/_core/_windows_cffi.py +++ b/src/trio/_core/_windows_cffi.py @@ -395,9 +395,9 @@ class _Overlapped(Protocol): hEvent: Handle -kernel32 = cast(_Kernel32, ffi.dlopen("kernel32.dll")) -ntdll = cast(_Nt, ffi.dlopen("ntdll.dll")) -ws2_32 = cast(_Ws2, ffi.dlopen("ws2_32.dll")) +kernel32 = cast("_Kernel32", ffi.dlopen("kernel32.dll")) +ntdll = cast("_Nt", ffi.dlopen("ntdll.dll")) +ws2_32 = cast("_Ws2", ffi.dlopen("ws2_32.dll")) ################################################################ # Magic numbers diff --git a/src/trio/_tests/test_exports.py b/src/trio/_tests/test_exports.py index e262be1b12..153d4a2a85 100644 --- a/src/trio/_tests/test_exports.py +++ b/src/trio/_tests/test_exports.py @@ -19,11 +19,10 @@ import trio import trio.testing -from trio._tests.pytest_plugin import skip_if_optional_else_raise +from trio._tests.pytest_plugin import RUN_SLOW, skip_if_optional_else_raise from .. import _core, _util from .._core._tests.tutil import slow -from .pytest_plugin import RUN_SLOW if TYPE_CHECKING: from collections.abc import Iterable, Iterator @@ -572,3 +571,37 @@ def test_classes_are_final() -> None: continue assert class_is_final(class_) + + +# Plugin might not be running, especially if running from an installed version. +@pytest.mark.skipif( + not hasattr(attrs.field, "trio_modded"), + reason="Pytest plugin not installed.", +) +def test_pyright_recognizes_init_attributes() -> None: + """Check whether we provide `alias` for all underscore prefixed attributes. + + Attrs always sets the `alias` attribute on fields, so a pytest plugin is used + to monkeypatch `field()` to record whether an alias was defined in the metadata. + See `_trio_check_attrs_aliases`. + """ + for module in PUBLIC_MODULES: + for class_ in module.__dict__.values(): + if not attrs.has(class_): + continue + if isinstance(class_, _util.NoPublicConstructor): + continue + + attributes = [ + attr + for attr in attrs.fields(class_) + if attr.init + if attr.alias + not in ( + attr.name, + # trio_original_args may not be present in autoattribs + attr.metadata.get("trio_original_args", {}).get("alias"), + ) + ] + + assert attributes == [], class_ diff --git a/src/trio/_tests/test_highlevel_open_tcp_listeners.py b/src/trio/_tests/test_highlevel_open_tcp_listeners.py index 61abd43f02..e78e4414d2 100644 --- a/src/trio/_tests/test_highlevel_open_tcp_listeners.py +++ b/src/trio/_tests/test_highlevel_open_tcp_listeners.py @@ -314,7 +314,7 @@ async def handler(stream: SendStream) -> None: # nursery.start is incorrectly typed, awaiting #2773 value = await nursery.start(serve_tcp, handler, 0) assert isinstance(value, list) - listeners = cast(list[SocketListener], value) + listeners = cast("list[SocketListener]", value) stream = await open_stream_to_socket_listener(listeners[0]) async with stream: assert await stream.receive_some(1) == b"x" diff --git a/src/trio/_tests/test_highlevel_serve_listeners.py b/src/trio/_tests/test_highlevel_serve_listeners.py index 013d130780..9268555b32 100644 --- a/src/trio/_tests/test_highlevel_serve_listeners.py +++ b/src/trio/_tests/test_highlevel_serve_listeners.py @@ -102,7 +102,7 @@ async def do_tests(parent_nursery: Nursery) -> None: listeners, ) assert isinstance(value, list) - l2 = cast(list[MemoryListener], value) + l2 = cast("list[MemoryListener]", value) assert l2 == listeners # This is just split into another function because gh-136 isn't # implemented yet diff --git a/src/trio/_tests/test_highlevel_ssl_helpers.py b/src/trio/_tests/test_highlevel_ssl_helpers.py index 68aa478465..e42f311981 100644 --- a/src/trio/_tests/test_highlevel_ssl_helpers.py +++ b/src/trio/_tests/test_highlevel_ssl_helpers.py @@ -10,13 +10,11 @@ import trio.testing from trio.socket import AF_INET, IPPROTO_TCP, SOCK_STREAM -from .._highlevel_socket import SocketListener from .._highlevel_ssl_helpers import ( open_ssl_over_tcp_listeners, open_ssl_over_tcp_stream, serve_ssl_over_tcp, ) -from .._ssl import SSLListener # using noqa because linters don't understand how pytest fixtures work. from .test_ssl import SERVER_CTX, client_ctx # noqa: F401 @@ -27,6 +25,9 @@ from trio.abc import Stream + from .._highlevel_socket import SocketListener + from .._ssl import SSLListener + async def echo_handler(stream: Stream) -> None: async with stream: @@ -92,7 +93,7 @@ async def test_open_ssl_over_tcp_stream_and_everything_else( ), ) assert isinstance(value, list) - res = cast(list[SSLListener[SocketListener]], value) # type: ignore[type-var] + res = cast("list[SSLListener[SocketListener]]", value) # type: ignore[type-var] (listener,) = res async with listener: # listener.transport_listener is of type Listener[Stream] diff --git a/src/trio/_tests/test_socket.py b/src/trio/_tests/test_socket.py index dc4bb467a4..8226d2e385 100644 --- a/src/trio/_tests/test_socket.py +++ b/src/trio/_tests/test_socket.py @@ -655,7 +655,7 @@ async def res( local=local, # noqa: B023 # local is not bound in function definition ) assert isinstance(value, tuple) - return cast(tuple[Union[str, int], ...], value) + return cast("tuple[Union[str, int], ...]", value) assert_eq(await res((addrs.arbitrary, "http")), (addrs.arbitrary, 80)) if v6: diff --git a/src/trio/_tests/test_testing_raisesgroup.py b/src/trio/_tests/test_testing_raisesgroup.py index 10bfb24507..8babd04705 100644 --- a/src/trio/_tests/test_testing_raisesgroup.py +++ b/src/trio/_tests/test_testing_raisesgroup.py @@ -271,7 +271,7 @@ def test_catch_unwrapped_exceptions() -> None: with pytest.raises( AssertionError, match=wrap_escape( - "Raised exception group did not match: ExceptionGroup('', [ValueError()]) is not of type 'ValueError'\n" + "Raised exception group did not match: ExceptionGroup('bar', [ValueError()]) is not of type 'ValueError'\n" "Did you mean to use `flatten_subgroups=True`?", ), ): diff --git a/src/trio/testing/_raises_group.py b/src/trio/testing/_raises_group.py index 6a9d2d9a9d..c03e3f6623 100644 --- a/src/trio/testing/_raises_group.py +++ b/src/trio/testing/_raises_group.py @@ -318,7 +318,7 @@ def matches( # If exception_type is None check() accepts BaseException. # If non-none, we have done an isinstance check above. - self.fail_reason = _check_check(self.check, cast(MatchE, exception), _depth) + self.fail_reason = _check_check(self.check, cast("MatchE", exception), _depth) return self.fail_reason is None def __str__(self) -> str: @@ -799,7 +799,7 @@ def __exit__( ), "Internal error - should have been constructed in __enter__" group_str = ( - "[group]" + "(group)" if self.allow_unwrapped and not issubclass(exc_type, BaseExceptionGroup) else "group" ) diff --git a/test-requirements.txt b/test-requirements.txt index efe920af0b..490b41ea5d 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -34,7 +34,7 @@ colorama==0.4.6 ; (implementation_name != 'cpython' and sys_platform == 'win32') # pylint # pytest # sphinx -coverage==7.6.4 +coverage==7.6.8 # via -r test-requirements.in cryptography==43.0.3 # via @@ -63,7 +63,7 @@ iniconfig==2.0.0 # via pytest isort==5.13.2 # via pylint -jedi==0.19.1 ; implementation_name == 'cpython' +jedi==0.19.2 ; implementation_name == 'cpython' # via -r test-requirements.in jinja2==3.1.4 # via sphinx @@ -80,11 +80,11 @@ mypy-extensions==1.0.0 # mypy nodeenv==1.9.1 # via pyright -orjson==3.10.10 ; implementation_name == 'cpython' +orjson==3.10.12 ; implementation_name == 'cpython' # via -r test-requirements.in outcome==1.3.0.post0 # via -r test-requirements.in -packaging==24.1 +packaging==24.2 # via # black # pytest @@ -107,13 +107,13 @@ pylint==3.3.1 # via -r test-requirements.in pyopenssl==24.2.1 # via -r test-requirements.in -pyright==1.1.387 +pyright==1.1.389 # via -r test-requirements.in pytest==8.3.3 # via -r test-requirements.in requests==2.32.3 # via sphinx -ruff==0.8.0 +ruff==0.8.1 # via -r test-requirements.in sniffio==1.3.1 # via -r test-requirements.in @@ -135,7 +135,7 @@ sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx -tomli==2.0.2 ; python_full_version < '3.11' +tomli==2.2.1 ; python_full_version < '3.11' # via # black # mypy @@ -150,11 +150,11 @@ types-cffi==1.16.0.20240331 # via # -r test-requirements.in # types-pyopenssl -types-docutils==0.21.0.20241005 +types-docutils==0.21.0.20241128 # via -r test-requirements.in types-pyopenssl==24.1.0.20240722 # via -r test-requirements.in -types-setuptools==75.2.0.20241025 +types-setuptools==75.6.0.20241126 # via types-cffi typing-extensions==4.12.2 # via @@ -166,7 +166,7 @@ typing-extensions==4.12.2 # pyright urllib3==2.2.3 # via requests -uv==0.4.29 +uv==0.5.5 # via -r test-requirements.in -zipp==3.20.2 ; python_full_version < '3.10' +zipp==3.21.0 ; python_full_version < '3.10' # via importlib-metadata diff --git a/tests/_trio_check_attrs_aliases.py b/tests/_trio_check_attrs_aliases.py new file mode 100644 index 0000000000..b4a339dabc --- /dev/null +++ b/tests/_trio_check_attrs_aliases.py @@ -0,0 +1,22 @@ +"""Plugins are executed by Pytest before test modules. + +We use this to monkeypatch attrs.field(), so that we can detect if aliases are used for test_exports. +""" + +from typing import Any + +import attrs + +orig_field = attrs.field + + +def field(**kwargs: Any) -> Any: + original_args = kwargs.copy() + metadata = kwargs.setdefault("metadata", {}) + metadata["trio_original_args"] = original_args + return orig_field(**kwargs) + + +# Mark it as being ours, so the test knows it can actually run. +field.trio_modded = True # type: ignore +attrs.field = field