diff --git a/.coveragerc b/.coveragerc index d577aa8adf..431a02971b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -21,11 +21,14 @@ exclude_lines = abc.abstractmethod if TYPE_CHECKING: if _t.TYPE_CHECKING: + if t.TYPE_CHECKING: @overload partial_branches = pragma: no branch if not TYPE_CHECKING: if not _t.TYPE_CHECKING: + if not t.TYPE_CHECKING: if .* or not TYPE_CHECKING: if .* or not _t.TYPE_CHECKING: + if .* or not t.TYPE_CHECKING: diff --git a/pyproject.toml b/pyproject.toml index 445c40e28c..73813cd58b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,19 +47,28 @@ disallow_untyped_defs = false # DO NOT use `ignore_errors`; it doesn't apply # downstream and users have to deal with them. [[tool.mypy.overrides]] +# Fully typed, enable stricter checks module = [ - "trio._socket", + "trio._abc", "trio._core._local", - "trio._sync", + "trio._deprecate", + "trio._dtls", "trio._file_io", + "trio._ki", + "trio._socket", + "trio._sync", + "trio._util", ] disallow_incomplete_defs = true disallow_untyped_defs = true +disallow_untyped_decorators = true disallow_any_generics = true disallow_any_decorated = true +disallow_any_unimported = true disallow_subclassing_any = true [[tool.mypy.overrides]] +# Needs to use Any due to some complex introspection. module = [ "trio._path", ] @@ -67,6 +76,7 @@ disallow_incomplete_defs = true disallow_untyped_defs = true #disallow_any_generics = true #disallow_any_decorated = true +disallow_any_unimported = true disallow_subclassing_any = true [tool.pytest.ini_options] diff --git a/trio/__init__.py b/trio/__init__.py index ac0687f529..be7de42cde 100644 --- a/trio/__init__.py +++ b/trio/__init__.py @@ -1,5 +1,6 @@ """Trio - A friendly Python library for async concurrency and I/O """ +from __future__ import annotations # General layout: # @@ -16,7 +17,7 @@ # Uses `from x import y as y` for compatibility with `pyright --verifytypes` (#2625) # must be imported early to avoid circular import -from ._core import TASK_STATUS_IGNORED as TASK_STATUS_IGNORED # isort: skip +from ._core import TASK_STATUS_IGNORED as TASK_STATUS_IGNORED # isort: split # Submodules imported by default from . import abc, from_thread, lowlevel, socket, to_thread @@ -117,7 +118,7 @@ _deprecate.enable_attribute_deprecations(__name__) -__deprecated_attributes__ = { +__deprecated_attributes__: dict[str, _deprecate.DeprecatedAttribute] = { "open_process": _deprecate.DeprecatedAttribute( value=lowlevel.open_process, version="0.20.0", diff --git a/trio/_core/_ki.py b/trio/_core/_ki.py index cc05ef9177..8ae83c287a 100644 --- a/trio/_core/_ki.py +++ b/trio/_core/_ki.py @@ -3,17 +3,21 @@ import inspect import signal import sys +import types +from collections.abc import Callable from functools import wraps -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final, TypeVar import attr from .._util import is_main_thread +RetT = TypeVar("RetT") + if TYPE_CHECKING: - from typing import Any, Callable, TypeVar + from typing_extensions import ParamSpec, TypeGuard - F = TypeVar("F", bound=Callable[..., Any]) + ArgsT = ParamSpec("ArgsT") # In ordinary single-threaded Python code, when you hit control-C, it raises # an exception and automatically does all the regular unwinding stuff. @@ -80,22 +84,22 @@ # We use this special string as a unique key into the frame locals dictionary. # The @ ensures it is not a valid identifier and can't clash with any possible # real local name. See: https://github.com/python-trio/trio/issues/469 -LOCALS_KEY_KI_PROTECTION_ENABLED = "@TRIO_KI_PROTECTION_ENABLED" +LOCALS_KEY_KI_PROTECTION_ENABLED: Final = "@TRIO_KI_PROTECTION_ENABLED" # NB: according to the signal.signal docs, 'frame' can be None on entry to # this function: -def ki_protection_enabled(frame): +def ki_protection_enabled(frame: types.FrameType | None) -> bool: while frame is not None: if LOCALS_KEY_KI_PROTECTION_ENABLED in frame.f_locals: - return frame.f_locals[LOCALS_KEY_KI_PROTECTION_ENABLED] + return bool(frame.f_locals[LOCALS_KEY_KI_PROTECTION_ENABLED]) if frame.f_code.co_name == "__del__": return True frame = frame.f_back return True -def currently_ki_protected(): +def currently_ki_protected() -> bool: r"""Check whether the calling code has :exc:`KeyboardInterrupt` protection enabled. @@ -115,29 +119,35 @@ def currently_ki_protected(): # functions decorated @async_generator are given this magic property that's a # reference to the object itself # see python-trio/async_generator/async_generator/_impl.py -def legacy_isasyncgenfunction(obj): +def legacy_isasyncgenfunction( + obj: object, +) -> TypeGuard[Callable[..., types.AsyncGeneratorType]]: return getattr(obj, "_async_gen_function", None) == id(obj) -def _ki_protection_decorator(enabled): - def decorator(fn): +def _ki_protection_decorator( + enabled: bool, +) -> Callable[[Callable[ArgsT, RetT]], Callable[ArgsT, RetT]]: + # The "ignore[return-value]" below is because the inspect functions cast away the + # original return type of fn, making it just CoroutineType[Any, Any, Any] etc. + def decorator(fn: Callable[ArgsT, RetT]) -> Callable[ArgsT, RetT]: # In some version of Python, isgeneratorfunction returns true for # coroutine functions, so we have to check for coroutine functions # first. if inspect.iscoroutinefunction(fn): @wraps(fn) - def wrapper(*args, **kwargs): + def wrapper(*args: ArgsT.args, **kwargs: ArgsT.kwargs) -> RetT: # See the comment for regular generators below coro = fn(*args, **kwargs) coro.cr_frame.f_locals[LOCALS_KEY_KI_PROTECTION_ENABLED] = enabled - return coro + return coro # type: ignore[return-value] return wrapper elif inspect.isgeneratorfunction(fn): @wraps(fn) - def wrapper(*args, **kwargs): + def wrapper(*args: ArgsT.args, **kwargs: ArgsT.kwargs) -> RetT: # It's important that we inject this directly into the # generator's locals, as opposed to setting it here and then # doing 'yield from'. The reason is, if a generator is @@ -148,23 +158,23 @@ def wrapper(*args, **kwargs): # https://bugs.python.org/issue29590 gen = fn(*args, **kwargs) gen.gi_frame.f_locals[LOCALS_KEY_KI_PROTECTION_ENABLED] = enabled - return gen + return gen # type: ignore[return-value] return wrapper elif inspect.isasyncgenfunction(fn) or legacy_isasyncgenfunction(fn): @wraps(fn) - def wrapper(*args, **kwargs): + def wrapper(*args: ArgsT.args, **kwargs: ArgsT.kwargs) -> RetT: # See the comment for regular generators above agen = fn(*args, **kwargs) agen.ag_frame.f_locals[LOCALS_KEY_KI_PROTECTION_ENABLED] = enabled - return agen + return agen # type: ignore[return-value] return wrapper else: @wraps(fn) - def wrapper(*args, **kwargs): + def wrapper(*args: ArgsT.args, **kwargs: ArgsT.kwargs) -> RetT: locals()[LOCALS_KEY_KI_PROTECTION_ENABLED] = enabled return fn(*args, **kwargs) @@ -173,10 +183,14 @@ def wrapper(*args, **kwargs): return decorator -enable_ki_protection: Callable[[F], F] = _ki_protection_decorator(True) +enable_ki_protection: Callable[ + [Callable[ArgsT, RetT]], Callable[ArgsT, RetT] +] = _ki_protection_decorator(True) enable_ki_protection.__name__ = "enable_ki_protection" -disable_ki_protection: Callable[[F], F] = _ki_protection_decorator(False) +disable_ki_protection: Callable[ + [Callable[ArgsT, RetT]], Callable[ArgsT, RetT] +] = _ki_protection_decorator(False) disable_ki_protection.__name__ = "disable_ki_protection" @@ -184,7 +198,11 @@ def wrapper(*args, **kwargs): class KIManager: handler = attr.ib(default=None) - def install(self, deliver_cb, restrict_keyboard_interrupt_to_checkpoints): + def install( + self, + deliver_cb: Callable[[], object], + restrict_keyboard_interrupt_to_checkpoints: bool, + ) -> None: assert self.handler is None if ( not is_main_thread() @@ -192,7 +210,7 @@ def install(self, deliver_cb, restrict_keyboard_interrupt_to_checkpoints): ): return - def handler(signum, frame): + def handler(signum: int, frame: types.FrameType | None) -> None: assert signum == signal.SIGINT protection_enabled = ki_protection_enabled(frame) if protection_enabled or restrict_keyboard_interrupt_to_checkpoints: diff --git a/trio/_deprecate.py b/trio/_deprecate.py index fe00192583..0a9553b854 100644 --- a/trio/_deprecate.py +++ b/trio/_deprecate.py @@ -1,10 +1,21 @@ +from __future__ import annotations + import sys import warnings +from collections.abc import Callable from functools import wraps from types import ModuleType +from typing import TYPE_CHECKING, ClassVar, TypeVar import attr +if TYPE_CHECKING: + from typing_extensions import ParamSpec + + ArgsT = ParamSpec("ArgsT") + +RetT = TypeVar("RetT") + # We want our warnings to be visible by default (at least for now), but we # also want it to be possible to override that using the -W switch. AFAICT @@ -29,17 +40,24 @@ class TrioDeprecationWarning(FutureWarning): """ -def _url_for_issue(issue): +def _url_for_issue(issue: int) -> str: return f"https://github.com/python-trio/trio/issues/{issue}" -def _stringify(thing): +def _stringify(thing: object) -> str: if hasattr(thing, "__module__") and hasattr(thing, "__qualname__"): return f"{thing.__module__}.{thing.__qualname__}" return str(thing) -def warn_deprecated(thing, version, *, issue, instead, stacklevel=2): +def warn_deprecated( + thing: object, + version: str, + *, + issue: int | None, + instead: object, + stacklevel: int = 2, +) -> None: stacklevel += 1 msg = f"{_stringify(thing)} is deprecated since Trio {version}" if instead is None: @@ -53,12 +71,14 @@ def warn_deprecated(thing, version, *, issue, instead, stacklevel=2): # @deprecated("0.2.0", issue=..., instead=...) # def ... -def deprecated(version, *, thing=None, issue, instead): - def do_wrap(fn): +def deprecated( + version: str, *, thing: object = None, issue: int | None, instead: object +) -> Callable[[Callable[ArgsT, RetT]], Callable[ArgsT, RetT]]: + def do_wrap(fn: Callable[ArgsT, RetT]) -> Callable[ArgsT, RetT]: nonlocal thing @wraps(fn) - def wrapper(*args, **kwargs): + def wrapper(*args: ArgsT.args, **kwargs: ArgsT.kwargs) -> RetT: warn_deprecated(thing, version, instead=instead, issue=issue) return fn(*args, **kwargs) @@ -87,11 +107,17 @@ def wrapper(*args, **kwargs): return do_wrap -def deprecated_alias(old_qualname, new_fn, version, *, issue): +def deprecated_alias( + old_qualname: str, + new_fn: Callable[ArgsT, RetT], + version: str, + *, + issue: int | None, +) -> Callable[ArgsT, RetT]: @deprecated(version, issue=issue, instead=new_fn) @wraps(new_fn, assigned=("__module__", "__annotations__")) - def wrapper(*args, **kwargs): - "Deprecated alias." + def wrapper(*args: ArgsT.args, **kwargs: ArgsT.kwargs) -> RetT: + """Deprecated alias.""" return new_fn(*args, **kwargs) wrapper.__qualname__ = old_qualname @@ -101,16 +127,18 @@ def wrapper(*args, **kwargs): @attr.s(frozen=True) class DeprecatedAttribute: - _not_set = object() + _not_set: ClassVar[object] = object() - value = attr.ib() - version = attr.ib() - issue = attr.ib() - instead = attr.ib(default=_not_set) + value: object = attr.ib() + version: str = attr.ib() + issue: int | None = attr.ib() + instead: object = attr.ib(default=_not_set) class _ModuleWithDeprecations(ModuleType): - def __getattr__(self, name): + __deprecated_attributes__: dict[str, DeprecatedAttribute] + + def __getattr__(self, name: str) -> object: if name in self.__deprecated_attributes__: info = self.__deprecated_attributes__[name] instead = info.instead @@ -124,9 +152,10 @@ def __getattr__(self, name): raise AttributeError(msg.format(self.__name__, name)) -def enable_attribute_deprecations(module_name): +def enable_attribute_deprecations(module_name: str) -> None: module = sys.modules[module_name] module.__class__ = _ModuleWithDeprecations + assert isinstance(module, _ModuleWithDeprecations) # Make sure that this is always defined so that # _ModuleWithDeprecations.__getattr__ can access it without jumping # through hoops or risking infinite recursion. diff --git a/trio/_tests/test_exports.py b/trio/_tests/test_exports.py index b5d0a44088..1b1e8df8da 100644 --- a/trio/_tests/test_exports.py +++ b/trio/_tests/test_exports.py @@ -1,3 +1,5 @@ +import __future__ # Regular import, not special! + import enum import functools import importlib @@ -107,6 +109,11 @@ def no_underscores(symbols): if modname == "trio": runtime_names.discard("tests") + # Ignore any __future__ feature objects, if imported under that name. + for name in __future__.all_feature_names: + if getattr(module, name, None) is getattr(__future__, name): + runtime_names.remove(name) + if tool in ("mypy", "pyright_verifytypes"): # create py.typed file py_typed_path = Path(trio.__file__).parent / "py.typed" diff --git a/trio/_tests/test_util.py b/trio/_tests/test_util.py index a4df6d35b4..1ab6f825de 100644 --- a/trio/_tests/test_util.py +++ b/trio/_tests/test_util.py @@ -1,5 +1,6 @@ import signal import sys +import types import pytest @@ -15,6 +16,7 @@ Final, NoPublicConstructor, coroutine_or_error, + fixup_module_metadata, generic_function, is_main_thread, signal_raise, @@ -192,3 +194,70 @@ class SubClass(SpecialClass): # Private constructor should not raise assert isinstance(SpecialClass._create(), SpecialClass) + + +def test_fixup_module_metadata(): + # Ignores modules not in the trio.X tree. + non_trio_module = types.ModuleType("not_trio") + non_trio_module.some_func = lambda: None + non_trio_module.some_func.__name__ = "some_func" + non_trio_module.some_func.__qualname__ = "some_func" + + fixup_module_metadata(non_trio_module.__name__, vars(non_trio_module)) + + assert non_trio_module.some_func.__name__ == "some_func" + assert non_trio_module.some_func.__qualname__ == "some_func" + + # Bulild up a fake module to test. Just use lambdas since all we care about is the names. + mod = types.ModuleType("trio._somemodule_impl") + mod.some_func = lambda: None + mod.some_func.__name__ = "_something_else" + mod.some_func.__qualname__ = "_something_else" + + # No __module__ means it's unchanged. + mod.not_funclike = types.SimpleNamespace() + mod.not_funclike.__name__ = "not_funclike" + + # Check __qualname__ being absent works. + mod.only_has_name = types.SimpleNamespace() + mod.only_has_name.__module__ = "trio._somemodule_impl" + mod.only_has_name.__name__ = "only_name" + + # Underscored names are unchanged. + mod._private = lambda: None + mod._private.__module__ = "trio._somemodule_impl" + mod._private.__name__ = mod._private.__qualname__ = "_private" + + # We recurse into classes. + mod.SomeClass = type( + "SomeClass", + (), + { + "__init__": lambda self: None, + "method": lambda self: None, + }, + ) + mod.SomeClass.recursion = mod.SomeClass # Reference loop is fine. + + fixup_module_metadata("trio.somemodule", vars(mod)) + assert mod.some_func.__name__ == "some_func" + assert mod.some_func.__module__ == "trio.somemodule" + assert mod.some_func.__qualname__ == "some_func" + + assert mod.not_funclike.__name__ == "not_funclike" + assert mod._private.__name__ == "_private" + assert mod._private.__module__ == "trio._somemodule_impl" + assert mod._private.__qualname__ == "_private" + + assert mod.only_has_name.__name__ == "only_has_name" + assert mod.only_has_name.__module__ == "trio.somemodule" + assert not hasattr(mod.only_has_name, "__qualname__") + + assert mod.SomeClass.method.__name__ == "method" + assert mod.SomeClass.method.__module__ == "trio.somemodule" + assert mod.SomeClass.method.__qualname__ == "SomeClass.method" + # Make coverage happy. + non_trio_module.some_func() + mod.some_func() + mod._private() + mod.SomeClass().method() diff --git a/trio/_tests/verify_types.json b/trio/_tests/verify_types.json index 60132e07fd..4d632d2589 100644 --- a/trio/_tests/verify_types.json +++ b/trio/_tests/verify_types.json @@ -7,11 +7,11 @@ "warningCount": 0 }, "typeCompleteness": { - "completenessScore": 0.9072, + "completenessScore": 0.9104, "exportedSymbolCounts": { - "withAmbiguousType": 1, - "withKnownType": 567, - "withUnknownType": 57 + "withAmbiguousType": 0, + "withKnownType": 569, + "withUnknownType": 56 }, "ignoreUnknownTypesFromImports": true, "missingClassDocStringCount": 1, @@ -46,12 +46,11 @@ ], "otherSymbolCounts": { "withAmbiguousType": 3, - "withKnownType": 574, - "withUnknownType": 76 + "withKnownType": 576, + "withUnknownType": 74 }, "packageName": "trio", "symbols": [ - "trio.__deprecated_attributes__", "trio._core._entry_queue.TrioToken.run_sync_soon", "trio._core._mock_clock.MockClock.jump", "trio._core._run.Nursery.start", @@ -85,10 +84,8 @@ "trio._ssl.SSLStream.transport_stream", "trio._ssl.SSLStream.unwrap", "trio._ssl.SSLStream.wait_send_all_might_not_block", - "trio._subprocess.Process.__aenter__", "trio._subprocess.Process.__init__", "trio._subprocess.Process.__repr__", - "trio._subprocess.Process.aclose", "trio._subprocess.Process.args", "trio._subprocess.Process.encoding", "trio._subprocess.Process.errors", @@ -107,7 +104,6 @@ "trio.lowlevel.current_root_task", "trio.lowlevel.current_statistics", "trio.lowlevel.current_trio_token", - "trio.lowlevel.currently_ki_protected", "trio.lowlevel.notify_closing", "trio.lowlevel.open_process", "trio.lowlevel.permanently_detach_coroutine_object", diff --git a/trio/_util.py b/trio/_util.py index a87f1fc02c..ba56c18385 100644 --- a/trio/_util.py +++ b/trio/_util.py @@ -1,7 +1,7 @@ # Little utilities we use internally from __future__ import annotations -import collections +import collections.abc import inspect import os import signal @@ -9,15 +9,28 @@ import typing as t from abc import ABCMeta from functools import update_wrapper -from types import TracebackType +from types import AsyncGeneratorType, TracebackType import trio CallT = t.TypeVar("CallT", bound=t.Callable[..., t.Any]) +T = t.TypeVar("T") +RetT = t.TypeVar("RetT") + +if t.TYPE_CHECKING: + from typing_extensions import ParamSpec, Self + + ArgsT = ParamSpec("ArgsT") + + +if t.TYPE_CHECKING: + # Don't type check the implementation below, pthread_kill does not exist on Windows. + def signal_raise(signum: int) -> None: + ... # Equivalent to the C function raise(), which Python doesn't wrap -if os.name == "nt": +elif os.name == "nt": # On Windows, os.kill exists but is really weird. # # If you give it CTRL_C_EVENT or CTRL_BREAK_EVENT, it tries to deliver @@ -61,7 +74,7 @@ signal_raise = getattr(_lib, "raise") else: - def signal_raise(signum): + def signal_raise(signum: int) -> None: signal.pthread_kill(threading.get_ident(), signum) @@ -73,7 +86,7 @@ def signal_raise(signum): # Trying to use signal out of the main thread will fail, so we can then # reliably check if this is the main thread without relying on a # potentially modified threading. -def is_main_thread(): +def is_main_thread() -> bool: """Attempt to reliably check if we are in the main thread.""" try: signal.signal(signal.SIGINT, signal.getsignal(signal.SIGINT)) @@ -86,8 +99,11 @@ def is_main_thread(): # Call the function and get the coroutine object, while giving helpful # errors for common mistakes. Returns coroutine object. ###### -def coroutine_or_error(async_fn, *args): - def _return_value_looks_like_wrong_library(value): +# TODO: Use TypeVarTuple here. +def coroutine_or_error( + async_fn: t.Callable[..., t.Awaitable[RetT]], *args: t.Any +) -> t.Awaitable[RetT]: + def _return_value_looks_like_wrong_library(value: object) -> bool: # Returned by legacy @asyncio.coroutine functions, which includes # a surprising proportion of asyncio builtins. if isinstance(value, collections.abc.Generator): @@ -183,11 +199,11 @@ class ConflictDetector: """ - def __init__(self, msg): + def __init__(self, msg: str) -> None: self._msg = msg self._held = False - def __enter__(self): + def __enter__(self) -> None: if self._held: raise trio.BusyResourceError(self._msg) else: @@ -224,10 +240,12 @@ def decorator(func: CallT) -> CallT: return decorator -def fixup_module_metadata(module_name, namespace): - seen_ids = set() +def fixup_module_metadata( + module_name: str, namespace: collections.abc.Mapping[str, object] +) -> None: + seen_ids: set[int] = set() - def fix_one(qualname, name, obj): + def fix_one(qualname: str, name: str, obj: object) -> None: # avoid infinite recursion (relevant when using # typing.Generic, for example) if id(obj) in seen_ids: @@ -242,7 +260,8 @@ def fix_one(qualname, name, obj): # rewriting these. if hasattr(obj, "__name__") and "." not in obj.__name__: obj.__name__ = name - obj.__qualname__ = qualname + if hasattr(obj, "__qualname__"): + obj.__qualname__ = qualname if isinstance(obj, type): for attr_name, attr_value in obj.__dict__.items(): fix_one(objname + "." + attr_name, attr_name, attr_value) @@ -252,7 +271,10 @@ def fix_one(qualname, name, obj): fix_one(objname, objname, obj) -class generic_function: +# We need ParamSpec to type this "properly", but that requires a runtime typing_extensions import +# to use as a class base. This is only used at runtime and isn't correct for type checkers anyway, +# so don't bother. +class generic_function(t.Generic[RetT]): """Decorator that makes a function indexable, to communicate non-inferrable generic type parameters to a static type checker. @@ -269,14 +291,14 @@ def open_memory_channel(max_buffer_size: int) -> Tuple[ but at least it becomes possible to write those. """ - def __init__(self, fn): + def __init__(self, fn: t.Callable[..., RetT]) -> None: update_wrapper(self, fn) self._fn = fn - def __call__(self, *args, **kwargs): + def __call__(self, *args: t.Any, **kwargs: t.Any) -> RetT: return self._fn(*args, **kwargs) - def __getitem__(self, _): + def __getitem__(self, subscript: object) -> Self: return self @@ -296,7 +318,10 @@ class SomeClass(metaclass=Final): """ def __new__( - cls, name: str, bases: tuple[type, ...], cls_namespace: dict[str, object] + cls, + name: str, + bases: tuple[type, ...], + cls_namespace: dict[str, object], ) -> Final: for base in bases: if isinstance(base, Final): @@ -307,9 +332,6 @@ def __new__( return super().__new__(cls, name, bases, cls_namespace) -T = t.TypeVar("T") - - class NoPublicConstructor(Final): """Metaclass that enforces a class to be final (i.e., subclass not allowed) and ensures a private constructor. @@ -338,7 +360,7 @@ def _create(cls: t.Type[T], *args: object, **kwargs: object) -> T: return super().__call__(*args, **kwargs) # type: ignore -def name_asyncgen(agen): +def name_asyncgen(agen: AsyncGeneratorType[object, t.NoReturn]) -> str: """Return the fully-qualified name of the async generator function that produced the async generator iterator *agen*. """ diff --git a/trio/tests.py b/trio/tests.py index 573a076da8..4ffb583a3a 100644 --- a/trio/tests.py +++ b/trio/tests.py @@ -9,7 +9,7 @@ "trio.tests", "0.22.1", instead="trio._tests", - issue="https://github.com/python-trio/trio/issues/274", + issue=274, ) @@ -23,7 +23,7 @@ def __getattr__(self, attr: str) -> Any: f"trio.tests.{attr}", "0.22.1", instead=f"trio._tests.{attr}", - issue="https://github.com/python-trio/trio/issues/274", + issue=274, ) # needed to access e.g. trio._tests.tools, although pytest doesn't need it