From 8c6f4e422e445780a249e14d6f8a01e65a57afd9 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Fri, 14 Jan 2022 14:16:50 +0200 Subject: [PATCH 1/6] Provide typing info --- Makefile | 1 + pytest_asyncio/plugin.py | 157 +++++++++++++++++++++++++++++---------- pytest_asyncio/py.typed | 0 setup.cfg | 3 + 4 files changed, 122 insertions(+), 39 deletions(-) create mode 100644 pytest_asyncio/py.typed diff --git a/Makefile b/Makefile index 0817a0e7..232f2434 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,7 @@ ifdef CI else pre-commit run --all-files endif + mypy pytest_asyncio --show-error-codes test: coverage run -m pytest tests diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 04b5e139..41a18774 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -6,8 +6,36 @@ import inspect import socket import warnings +from typing import ( + Any, + Awaitable, + Callable, + Dict, + Iterable, + Iterator, + List, + Optional, + Set, + TypeVar, + Union, + cast, + overload, +) import pytest +from typing_extensions import Literal + +_ScopeName = Literal["session", "package", "module", "class", "function"] +_T = TypeVar("_T") + +FixtureFunction = TypeVar("FixtureFunction", bound=Callable[..., object]) +FixtureFunctionMarker = Callable[[FixtureFunction], FixtureFunction] + +Config = Any # pytest < 7.0 +PytestPluginManager = Any # pytest < 7.0 +FixtureDef = Any # pytest < 7.0 +Parser = Any # pytest < 7.0 +SubRequest = Any # pytest < 7.0 class Mode(str, enum.Enum): @@ -41,7 +69,7 @@ class Mode(str, enum.Enum): """ -def pytest_addoption(parser, pluginmanager): +def pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager) -> None: group = parser.getgroup("asyncio") group.addoption( "--asyncio-mode", @@ -58,7 +86,45 @@ def pytest_addoption(parser, pluginmanager): ) -def fixture(fixture_function=None, **kwargs): +@overload +def fixture( + fixture_function: FixtureFunction, + *, + scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = ..., + params: Optional[Iterable[object]] = ..., + autouse: bool = ..., + ids: Optional[ + Union[ + Iterable[Union[None, str, float, int, bool]], + Callable[[Any], Optional[object]], + ] + ] = ..., + name: Optional[str] = ..., +) -> FixtureFunction: + ... + + +@overload +def fixture( + fixture_function: None = ..., + *, + scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = ..., + params: Optional[Iterable[object]] = ..., + autouse: bool = ..., + ids: Optional[ + Union[ + Iterable[Union[None, str, float, int, bool]], + Callable[[Any], Optional[object]], + ] + ] = ..., + name: Optional[str] = None, +) -> FixtureFunctionMarker: + ... + + +def fixture( + fixture_function: Optional[FixtureFunction] = None, **kwargs: Any +) -> Union[FixtureFunction, FixtureFunctionMarker]: if fixture_function is not None: _set_explicit_asyncio_mark(fixture_function) return pytest.fixture(fixture_function, **kwargs) @@ -66,41 +132,41 @@ def fixture(fixture_function=None, **kwargs): else: @functools.wraps(fixture) - def inner(fixture_function): + def inner(fixture_function: FixtureFunction) -> FixtureFunction: return fixture(fixture_function, **kwargs) return inner -def _has_explicit_asyncio_mark(obj): +def _has_explicit_asyncio_mark(obj: Any) -> bool: obj = getattr(obj, "__func__", obj) # instance method maybe? return getattr(obj, "_force_asyncio_fixture", False) -def _set_explicit_asyncio_mark(obj): +def _set_explicit_asyncio_mark(obj: Any) -> None: if hasattr(obj, "__func__"): # instance method, check the function object obj = obj.__func__ obj._force_asyncio_fixture = True -def _is_coroutine(obj): +def _is_coroutine(obj: Any) -> bool: """Check to see if an object is really an asyncio coroutine.""" return asyncio.iscoroutinefunction(obj) or inspect.isgeneratorfunction(obj) -def _is_coroutine_or_asyncgen(obj): +def _is_coroutine_or_asyncgen(obj: Any) -> bool: return _is_coroutine(obj) or inspect.isasyncgenfunction(obj) -def _get_asyncio_mode(config): +def _get_asyncio_mode(config: Config) -> Mode: val = config.getoption("asyncio_mode") if val is None: val = config.getini("asyncio_mode") return Mode(val) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: """Inject documentation.""" config.addinivalue_line( "markers", @@ -113,10 +179,14 @@ def pytest_configure(config): @pytest.mark.tryfirst -def pytest_pycollect_makeitem(collector, name, obj): +def pytest_pycollect_makeitem( + collector: Union[pytest.Module, pytest.Class], name: str, obj: object +) -> Union[ + None, pytest.Item, pytest.Collector, List[Union[pytest.Item, pytest.Collector]] +]: """A pytest hook to collect asyncio coroutines.""" if not collector.funcnamefilter(name): - return + return None if ( _is_coroutine(obj) or _is_hypothesis_test(obj) @@ -131,10 +201,11 @@ def pytest_pycollect_makeitem(collector, name, obj): ret = list(collector._genfunctions(name, obj)) for elem in ret: elem.add_marker("asyncio") - return ret + return ret # type: ignore[return-value] + return None -def _hypothesis_test_wraps_coroutine(function): +def _hypothesis_test_wraps_coroutine(function: Any) -> bool: return _is_coroutine(function.hypothesis.inner_test) @@ -144,11 +215,11 @@ class FixtureStripper: REQUEST = "request" EVENT_LOOP = "event_loop" - def __init__(self, fixturedef): + def __init__(self, fixturedef: FixtureDef) -> None: self.fixturedef = fixturedef - self.to_strip = set() + self.to_strip: Set[str] = set() - def add(self, name): + def add(self, name: str) -> None: """Add fixture name to fixturedef and record in to_strip list (If not previously included)""" if name in self.fixturedef.argnames: @@ -156,7 +227,7 @@ def add(self, name): self.fixturedef.argnames += (name,) self.to_strip.add(name) - def get_and_strip_from(self, name, data_dict): + def get_and_strip_from(self, name: str, data_dict: Dict[str, _T]) -> _T: """Strip name from data, and return value""" result = data_dict[name] if name in self.to_strip: @@ -165,7 +236,7 @@ def get_and_strip_from(self, name, data_dict): @pytest.hookimpl(trylast=True) -def pytest_fixture_post_finalizer(fixturedef, request): +def pytest_fixture_post_finalizer(fixturedef: FixtureDef, request: SubRequest) -> None: """Called after fixture teardown""" if fixturedef.argname == "event_loop": policy = asyncio.get_event_loop_policy() @@ -177,7 +248,9 @@ def pytest_fixture_post_finalizer(fixturedef, request): @pytest.hookimpl(hookwrapper=True) -def pytest_fixture_setup(fixturedef, request): +def pytest_fixture_setup( + fixturedef: FixtureDef, request: SubRequest +) -> Optional[object]: """Adjust the event loop policy when an event loop is produced.""" if fixturedef.argname == "event_loop": outcome = yield @@ -290,7 +363,7 @@ async def setup(): @pytest.hookimpl(tryfirst=True, hookwrapper=True) -def pytest_pyfunc_call(pyfuncitem): +def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Optional[object]: """ Pytest hook called before a test case is run. @@ -298,31 +371,35 @@ def pytest_pyfunc_call(pyfuncitem): where the wrapped test coroutine is executed in an event loop. """ if "asyncio" in pyfuncitem.keywords: + funcargs: Dict[str, object] = pyfuncitem.funcargs # type: ignore[name-defined] + loop = cast(asyncio.AbstractEventLoop, funcargs["event_loop"]) if _is_hypothesis_test(pyfuncitem.obj): pyfuncitem.obj.hypothesis.inner_test = wrap_in_sync( pyfuncitem.obj.hypothesis.inner_test, - _loop=pyfuncitem.funcargs["event_loop"], + _loop=loop, ) else: pyfuncitem.obj = wrap_in_sync( - pyfuncitem.obj, _loop=pyfuncitem.funcargs["event_loop"] + pyfuncitem.obj, + _loop=loop, ) yield -def _is_hypothesis_test(function) -> bool: +def _is_hypothesis_test(function: Any) -> bool: return getattr(function, "is_hypothesis_test", False) -def wrap_in_sync(func, _loop): +def wrap_in_sync(func: Callable[..., Awaitable[Any]], _loop: asyncio.AbstractEventLoop): """Return a sync wrapper around an async function executing it in the current event loop.""" # if the function is already wrapped, we rewrap using the original one # not using __wrapped__ because the original function may already be # a wrapped one - if hasattr(func, "_raw_test_func"): - func = func._raw_test_func + raw_func = getattr(func, "_raw_test_func", None) + if raw_func is not None: + func = raw_func @functools.wraps(func) def inner(**kwargs): @@ -339,20 +416,22 @@ def inner(**kwargs): task.exception() raise - inner._raw_test_func = func + inner._raw_test_func = func # type: ignore[attr-defined] return inner -def pytest_runtest_setup(item): +def pytest_runtest_setup(item: pytest.Item) -> None: if "asyncio" in item.keywords: + fixturenames = item.fixturenames # type: ignore[attr-defined] # inject an event loop fixture for all async tests - if "event_loop" in item.fixturenames: - item.fixturenames.remove("event_loop") - item.fixturenames.insert(0, "event_loop") + if "event_loop" in fixturenames: + fixturenames.remove("event_loop") + fixturenames.insert(0, "event_loop") + obj = item.obj # type: ignore[attr-defined] if ( item.get_closest_marker("asyncio") is not None - and not getattr(item.obj, "hypothesis", False) - and getattr(item.obj, "is_hypothesis_test", False) + and not getattr(obj, "hypothesis", False) + and getattr(obj, "is_hypothesis_test", False) ): pytest.fail( "test function `%r` is using Hypothesis, but pytest-asyncio " @@ -361,14 +440,14 @@ def pytest_runtest_setup(item): @pytest.fixture -def event_loop(request): +def event_loop(request: pytest.FixtureRequest) -> Iterator[asyncio.AbstractEventLoop]: """Create an instance of the default event loop for each test case.""" loop = asyncio.get_event_loop_policy().new_event_loop() yield loop loop.close() -def _unused_port(socket_type): +def _unused_port(socket_type: int) -> int: """Find an unused localhost port from 1024-65535 and return it.""" with contextlib.closing(socket.socket(type=socket_type)) as sock: sock.bind(("127.0.0.1", 0)) @@ -376,17 +455,17 @@ def _unused_port(socket_type): @pytest.fixture -def unused_tcp_port(): +def unused_tcp_port() -> int: return _unused_port(socket.SOCK_STREAM) @pytest.fixture -def unused_udp_port(): +def unused_udp_port() -> int: return _unused_port(socket.SOCK_DGRAM) @pytest.fixture(scope="session") -def unused_tcp_port_factory(): +def unused_tcp_port_factory() -> Callable[[], int]: """A factory function, producing different unused TCP ports.""" produced = set() @@ -405,7 +484,7 @@ def factory(): @pytest.fixture(scope="session") -def unused_udp_port_factory(): +def unused_udp_port_factory() -> Callable[[], int]: """A factory function, producing different unused UDP ports.""" produced = set() diff --git a/pytest_asyncio/py.typed b/pytest_asyncio/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/setup.cfg b/setup.cfg index 7be86f36..1b3f97be 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,6 +27,7 @@ classifiers = Framework :: AsyncIO Framework :: Pytest + Typing :: Typed [options] python_requires = >=3.7 @@ -38,12 +39,14 @@ setup_requires = install_requires = pytest >= 5.4.0 + typing-extensions >= 4.0 [options.extras_require] testing = coverage==6.2 hypothesis >= 5.7.1 flaky >= 3.5.0 + mypy == 0.931 [options.entry_points] pytest11 = From 02c136a513e12e4a1d09e4b3af08f852a4a67115 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Fri, 14 Jan 2022 14:20:08 +0200 Subject: [PATCH 2/6] Update README --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index 9d2257a5..a4730415 100644 --- a/README.rst +++ b/README.rst @@ -259,6 +259,7 @@ Changelog 0.17.1 (UNRELEASED) ~~~~~~~~~~~~~~~~~~~ - Fixes a bug that prevents async Hypothesis tests from working without explicit ``asyncio`` marker when ``--asyncio-mode=auto`` is set. `#258 `_ +- Added type annotations. `#198 `_ 0.17.0 (22-01-13) ~~~~~~~~~~~~~~~~~~~ From b2f176b75726009395a04e4cc4cfb6f564aeff4a Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Fri, 14 Jan 2022 14:36:42 +0200 Subject: [PATCH 3/6] Fix notes --- pytest_asyncio/plugin.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 41a18774..7f66670f 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -8,6 +8,7 @@ import warnings from typing import ( Any, + AsyncIterator, Awaitable, Callable, Dict, @@ -25,10 +26,18 @@ import pytest from typing_extensions import Literal +_R = TypeVar("_R") + _ScopeName = Literal["session", "package", "module", "class", "function"] _T = TypeVar("_T") -FixtureFunction = TypeVar("FixtureFunction", bound=Callable[..., object]) +SimpleFixtureFunction = TypeVar( + "SimpleFixtureFunction", bound=Callable[..., Awaitable[_R]] +) +FactoryFixtureFunction = TypeVar( + "FactoryFixtureFunction", bound=Callable[..., AsyncIterator[_R]] +) +FixtureFunction = Union[SimpleFixtureFunction, FactoryFixtureFunction] FixtureFunctionMarker = Callable[[FixtureFunction], FixtureFunction] Config = Any # pytest < 7.0 From 096e289fbd680e03ba4447d46e2f719670b31980 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Fri, 14 Jan 2022 14:40:36 +0200 Subject: [PATCH 4/6] Fix tox --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 0092b03e..6a51e803 100644 --- a/tox.ini +++ b/tox.ini @@ -14,6 +14,7 @@ allowlist_externals = [testenv:lint] skip_install = true basepython = python3.9 +extras = testing deps = pre-commit == 2.16.0 commands = From 6003eeda941061de67edf3c2cae84d554f3708f1 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Fri, 14 Jan 2022 14:45:49 +0200 Subject: [PATCH 5/6] Fix errors --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 6a51e803..a5477455 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,7 @@ allowlist_externals = [testenv:lint] skip_install = true -basepython = python3.9 +basepython = python3.10 extras = testing deps = pre-commit == 2.16.0 From 14e16bebb9cd117c5147afbf442ed1957d5d067c Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Fri, 14 Jan 2022 14:47:19 +0200 Subject: [PATCH 6/6] fix --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 232f2434..2b0216f9 100644 --- a/Makefile +++ b/Makefile @@ -23,11 +23,11 @@ clean-test: ## remove test and coverage artifacts lint: # CI env-var is set by GitHub actions ifdef CI - pre-commit run --all-files --show-diff-on-failure + python -m pre_commit run --all-files --show-diff-on-failure else - pre-commit run --all-files + python -m pre_commit run --all-files endif - mypy pytest_asyncio --show-error-codes + python -m mypy pytest_asyncio --show-error-codes test: coverage run -m pytest tests