diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 80d181ef..a4e8580c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,6 +9,9 @@ on: branches: [master] workflow_dispatch: +env: + PYTHON_LATEST: 3.11 + jobs: lint: name: Run linters @@ -22,25 +25,29 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v4 with: - python-version: '3.10' + python-version: ${{ env.PYTHON_LATEST }} - name: Install GitHub matcher for ActionLint checker run: | echo "::add-matcher::.github/actionlint-matcher.json" + - name: Install pre-commit + run: python -m pip install pre-commit + - name: Run pre-commit checks + run: pre-commit run --all-files --show-diff-on-failure - name: Install check-wheel-content, and twine run: python -m pip install build check-wheel-contents tox twine - name: Build package run: python -m build - - name: Run tox for linter - run: python -m tox -e lint - name: List result run: ls -l dist - name: Check wheel contents run: check-wheel-contents dist/*.whl - name: Check long_description run: python -m twine check dist/* + - name: Install pytest-asyncio + run: pip install . - name: Get version info id: version - run: tox -e version-info + run: python ./tools/get-version.py >> $GITHUB_OUTPUT - name: Upload artifacts uses: actions/upload-artifact@v3 with: @@ -50,18 +57,19 @@ jobs: test: name: Python ${{ matrix.python-version }} runs-on: ubuntu-latest - env: - USING_COVERAGE: 3.7,3.8,3.9,3.10,3.11 strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', 3.11-dev] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', 3.12-dev] steps: - uses: actions/checkout@v3 - with: - fetch-depth: 0 - uses: actions/setup-python@v4 + if: "!endsWith(matrix.python-version, '-dev')" + with: + python-version: ${{ matrix.python-version }} + - uses: deadsnakes/action@v3.0.0 + if: endsWith(matrix.python-version, '-dev') with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -74,9 +82,11 @@ jobs: - name: Run tox targets for ${{ matrix.python-version }} run: python -m tox - - name: Prepare coverage artifact - if: ${{ contains(env.USING_COVERAGE, matrix.python-version) }} - uses: aio-libs/prepare-coverage@v21.9.1 + - name: Store coverage data + uses: actions/upload-artifact@v3 + with: + name: coverage-per-interpreter + path: .coverage.* check: name: Check @@ -88,8 +98,27 @@ jobs: uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} - - name: Upload coverage - uses: aio-libs/upload-coverage@v21.9.4 + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: ${{ env.PYTHON_LATEST }} + - name: Install Coverage.py + run: | + set -xe + python -m pip install --upgrade coverage[toml] + - name: Download coverage data for all test runs + uses: actions/download-artifact@v3 + with: + name: coverage-per-interpreter + - name: Combine coverage data and create report + run: | + coverage combine + coverage xml + - name: Upload coverage report + uses: codecov/codecov-action@v3 + with: + files: coverage.xml + fail_ci_if_error: true deploy: name: Deploy @@ -104,8 +133,6 @@ jobs: sudo apt-get install -y pandoc - name: Checkout uses: actions/checkout@v3 - with: - fetch-depth: 0 - name: Download distributions uses: actions/download-artifact@v3 with: @@ -118,7 +145,7 @@ jobs: run: | pandoc -s -o README.md README.rst - name: PyPI upload - uses: pypa/gh-action-pypi-publish@v1.5.2 + uses: pypa/gh-action-pypi-publish@v1.8.1 with: packages_dir: dist password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d8ee693e..fc81f2f5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,6 +36,11 @@ repos: - id: check-xml - id: check-yaml - id: debug-statements +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.991 + hooks: + - id: mypy + exclude: ^(docs|tests)/.* - repo: https://github.com/pycqa/flake8 rev: 5.0.4 hooks: diff --git a/MANIFEST.in b/MANIFEST.in index fdf813e9..6bd245e1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,3 @@ -include CHANGELOG.rst - recursive-exclude .github * exclude .gitignore exclude .pre-commit-config.yaml diff --git a/Makefile b/Makefile index 2b0216f9..8bc58c49 100644 --- a/Makefile +++ b/Makefile @@ -20,19 +20,8 @@ clean-test: ## remove test and coverage artifacts rm -f .coverage rm -fr htmlcov/ -lint: -# CI env-var is set by GitHub actions -ifdef CI - python -m pre_commit run --all-files --show-diff-on-failure -else - python -m pre_commit run --all-files -endif - python -m mypy pytest_asyncio --show-error-codes - test: - coverage run -m pytest tests - coverage xml - coverage report + coverage run --parallel-mode --omit */_version.py -m pytest tests install: pip install -U pre-commit diff --git a/README.rst b/README.rst index 81984e88..9eba8d23 100644 --- a/README.rst +++ b/README.rst @@ -13,7 +13,7 @@ pytest-asyncio .. image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/ambv/black -pytest-asyncio is a `pytest `_ plugin. It facilitates testing of code that uses the `asyncio `_ library. +`pytest-asyncio `_ is a `pytest `_ plugin. It facilitates testing of code that uses the `asyncio `_ library. Specifically, pytest-asyncio provides support for coroutines as test functions. This allows users to *await* code inside their tests. For example, the following code is executed as a test item by pytest: @@ -24,6 +24,7 @@ Specifically, pytest-asyncio provides support for coroutines as test functions. res = await library.do_something() assert b"expected result" == res +More details can be found in the `documentation `_. Note that test classes subclassing the standard `unittest `__ library are not supported. Users are advised to use `unittest.IsolatedAsyncioTestCase `__ diff --git a/dependencies/default/constraints.txt b/dependencies/default/constraints.txt index c89233f0..11d6bd3d 100644 --- a/dependencies/default/constraints.txt +++ b/dependencies/default/constraints.txt @@ -1,24 +1,24 @@ async-generator==1.10 -attrs==22.1.0 -coverage==6.5.0 -exceptiongroup==1.0.4 +attrs==22.2.0 +coverage==7.2.1 +exceptiongroup==1.1.1 flaky==3.7.0 -hypothesis==6.58.1 +hypothesis==6.68.2 idna==3.4 -importlib-metadata==5.1.0 -iniconfig==1.1.1 -mypy==0.991 -mypy-extensions==0.4.3 +importlib-metadata==6.0.0 +iniconfig==2.0.0 +mypy==1.1.1 +mypy-extensions==1.0.0 outcome==1.2.0 -packaging==21.3 +packaging==23.0 pluggy==1.0.0 pyparsing==3.0.9 -pytest==7.2.0 +pytest==7.2.2 pytest-trio==0.8.0 sniffio==1.3.0 sortedcontainers==2.4.0 tomli==2.0.1 trio==0.22.0 typed-ast==1.5.4 -typing_extensions==4.4.0 -zipp==3.11.0 +typing_extensions==4.5.0 +zipp==3.15.0 diff --git a/dependencies/default/requirements.txt b/dependencies/default/requirements.txt index 01b2484e..a0009a85 100644 --- a/dependencies/default/requirements.txt +++ b/dependencies/default/requirements.txt @@ -1,4 +1,4 @@ # Always adjust install_requires in setup.cfg and pytest-min-requirements.txt # when changing runtime dependencies -pytest >= 6.1.0 +pytest >= 7.0.0 typing-extensions >= 3.7.2; python_version < "3.8" diff --git a/dependencies/pytest-min/constraints.txt b/dependencies/pytest-min/constraints.txt index 33f7948f..1f82dbaf 100644 --- a/dependencies/pytest-min/constraints.txt +++ b/dependencies/pytest-min/constraints.txt @@ -1,22 +1,22 @@ -async-generator==1.10 -attrs==21.4.0 -coverage==6.3.2 -flaky==3.7.0 -hypothesis==6.43.3 -idna==3.3 +argcomplete==2.0.0 +attrs==22.1.0 +certifi==2022.9.24 +charset-normalizer==2.1.1 +elementpath==3.0.2 +exceptiongroup==1.0.0rc9 +hypothesis==6.56.3 +idna==3.4 iniconfig==1.1.1 -mypy==0.942 -mypy-extensions==0.4.3 -outcome==1.1.0 +mock==4.0.3 +nose==1.3.7 packaging==21.3 -pluggy==0.13.1 +pluggy==1.0.0 py==1.11.0 -pyparsing==3.0.8 -pytest==6.1.0 -pytest-trio==0.7.0 -sniffio==1.2.0 +Pygments==2.13.0 +pyparsing==3.0.9 +pytest==7.0.0 +requests==2.28.1 sortedcontainers==2.4.0 -toml==0.10.2 tomli==2.0.1 -trio==0.20.0 -typing_extensions==4.2.0 +urllib3==1.26.12 +xmlschema==2.1.1 diff --git a/dependencies/pytest-min/requirements.txt b/dependencies/pytest-min/requirements.txt index 4fc6ef2f..4152d2f8 100644 --- a/dependencies/pytest-min/requirements.txt +++ b/dependencies/pytest-min/requirements.txt @@ -1,4 +1,4 @@ # Always adjust install_requires in setup.cfg and requirements.txt # when changing minimum version dependencies -pytest == 6.1.0 +pytest[testing] == 7.0.0 typing-extensions >= 3.7.2; python_version < "3.8" diff --git a/docs/source/index.rst b/docs/source/index.rst index 153fe9e8..618e6e6f 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -7,7 +7,7 @@ Welcome to pytest-asyncio! :hidden: concepts - reference + reference/index support pytest-asyncio is a `pytest `_ plugin. It facilitates testing of code that uses the `asyncio `_ library. diff --git a/docs/source/reference.rst b/docs/source/reference.rst deleted file mode 100644 index 2fa77ff4..00000000 --- a/docs/source/reference.rst +++ /dev/null @@ -1,145 +0,0 @@ -========= -Reference -========= - -Configuration -============= - -The pytest-asyncio mode can be set by the ``asyncio_mode`` configuration option in the `configuration file -`_: - -.. code-block:: ini - - # pytest.ini - [pytest] - asyncio_mode = auto - -The value can also be set via the ``--asyncio-mode`` command-line option: - -.. code-block:: bash - - $ pytest tests --asyncio-mode=strict - - -If the asyncio mode is set in both the pytest configuration file and the command-line option, the command-line option takes precedence. If no asyncio mode is specified, the mode defaults to `strict`. - -Fixtures -======== - -``event_loop`` --------------- -Creates a new asyncio event loop based on the current event loop policy. The new loop -is available as the return value of this fixture or via `asyncio.get_running_loop `__. -The event loop is closed when the fixture scope ends. The fixture scope defaults -to ``function`` scope. - -.. code-block:: python - - def test_http_client(event_loop): - url = "http://httpbin.org/get" - resp = event_loop.run_until_complete(http_client(url)) - assert b"HTTP/1.1 200 OK" in resp - -Note that, when using the ``event_loop`` fixture, you need to interact with the event loop using methods like ``event_loop.run_until_complete``. If you want to *await* code inside your test function, you need to write a coroutine and use it as a test function. The `asyncio <#pytest-mark-asyncio>`__ marker -is used to mark coroutines that should be treated as test functions. - -The ``event_loop`` fixture can be overridden in any of the standard pytest locations, -e.g. directly in the test file, or in ``conftest.py``. This allows redefining the -fixture scope, for example: - -.. code-block:: python - - @pytest.fixture(scope="session") - def event_loop(): - policy = asyncio.get_event_loop_policy() - loop = policy.new_event_loop() - yield loop - loop.close() - -If you need to change the type of the event loop, prefer setting a custom event loop policy over redefining the ``event_loop`` fixture. - -If the ``pytest.mark.asyncio`` decorator is applied to a test function, the ``event_loop`` -fixture will be requested automatically by the test function. - -``unused_tcp_port`` -------------------- -Finds and yields a single unused TCP port on the localhost interface. Useful for -binding temporary test servers. - -``unused_tcp_port_factory`` ---------------------------- -A callable which returns a different unused TCP port each invocation. Useful -when several unused TCP ports are required in a test. - -.. code-block:: python - - def a_test(unused_tcp_port_factory): - port1, port2 = unused_tcp_port_factory(), unused_tcp_port_factory() - ... - -``unused_udp_port`` and ``unused_udp_port_factory`` ---------------------------------------------------- -Works just like their TCP counterparts but returns unused UDP ports. - - -Markers -======= - -``pytest.mark.asyncio`` ------------------------ -A coroutine or async generator with this marker will be treated as a test function by pytest. The marked function will be executed as an -asyncio task in the event loop provided by the ``event_loop`` fixture. - -In order to make your test code a little more concise, the pytest |pytestmark|_ -feature can be used to mark entire modules or classes with this marker. -Only test coroutines will be affected (by default, coroutines prefixed by -``test_``), so, for example, fixtures are safe to define. - -.. code-block:: python - - import asyncio - - import pytest - - # All test coroutines will be treated as marked. - pytestmark = pytest.mark.asyncio - - - async def test_example(event_loop): - """No marker!""" - await asyncio.sleep(0, loop=event_loop) - -In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted, the marker is added -automatically to *async* test functions. - - -.. |pytestmark| replace:: ``pytestmark`` -.. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules - - -Decorators -========== -Asynchronous fixtures are defined just like ordinary pytest fixtures, except they should be decorated with ``@pytest_asyncio.fixture``. - -.. code-block:: python3 - - import pytest_asyncio - - - @pytest_asyncio.fixture - async def async_gen_fixture(): - await asyncio.sleep(0.1) - yield "a value" - - - @pytest_asyncio.fixture(scope="module") - async def async_fixture(): - return await asyncio.sleep(0.1) - -All scopes are supported, but if you use a non-function scope you will need -to redefine the ``event_loop`` fixture to have the same or broader scope. -Async fixtures need the event loop, and so must have the same or narrower scope -than the ``event_loop`` fixture. - -*auto* mode automatically converts async fixtures declared with the -standard ``@pytest.fixture`` decorator to *asyncio-driven* versions. diff --git a/CHANGELOG.rst b/docs/source/reference/changelog.rst similarity index 96% rename from CHANGELOG.rst rename to docs/source/reference/changelog.rst index e6f80383..06d4f108 100644 --- a/CHANGELOG.rst +++ b/docs/source/reference/changelog.rst @@ -2,6 +2,14 @@ Changelog ========= +0.21.0 (23-03-19) +================= +- Drop compatibility with pytest 6.1. Pytest-asyncio now depends on pytest 7.0 or newer. +- pytest-asyncio cleans up any stale event loops when setting up and tearing down the + event_loop fixture. This behavior has been deprecated and pytest-asyncio emits a + DeprecationWarning when tearing down the event_loop fixture and current event loop + has not been closed. + 0.20.3 (22-12-08) ================= - Prevent DeprecationWarning to bubble up on CPython 3.10.9 and 3.11.1. diff --git a/docs/source/reference/configuration.rst b/docs/source/reference/configuration.rst new file mode 100644 index 00000000..5d840c47 --- /dev/null +++ b/docs/source/reference/configuration.rst @@ -0,0 +1,21 @@ +============= +Configuration +============= + +The pytest-asyncio mode can be set by the ``asyncio_mode`` configuration option in the `configuration file +`_: + +.. code-block:: ini + + # pytest.ini + [pytest] + asyncio_mode = auto + +The value can also be set via the ``--asyncio-mode`` command-line option: + +.. code-block:: bash + + $ pytest tests --asyncio-mode=strict + + +If the asyncio mode is set in both the pytest configuration file and the command-line option, the command-line option takes precedence. If no asyncio mode is specified, the mode defaults to `strict`. diff --git a/docs/source/reference/decorators.rst b/docs/source/reference/decorators.rst new file mode 100644 index 00000000..977ed6b8 --- /dev/null +++ b/docs/source/reference/decorators.rst @@ -0,0 +1,27 @@ +========== +Decorators +========== +Asynchronous fixtures are defined just like ordinary pytest fixtures, except they should be decorated with ``@pytest_asyncio.fixture``. + +.. code-block:: python3 + + import pytest_asyncio + + + @pytest_asyncio.fixture + async def async_gen_fixture(): + await asyncio.sleep(0.1) + yield "a value" + + + @pytest_asyncio.fixture(scope="module") + async def async_fixture(): + return await asyncio.sleep(0.1) + +All scopes are supported, but if you use a non-function scope you will need +to redefine the ``event_loop`` fixture to have the same or broader scope. +Async fixtures need the event loop, and so must have the same or narrower scope +than the ``event_loop`` fixture. + +*auto* mode automatically converts async fixtures declared with the +standard ``@pytest.fixture`` decorator to *asyncio-driven* versions. diff --git a/docs/source/reference/fixtures.rst b/docs/source/reference/fixtures.rst new file mode 100644 index 00000000..fdd3e034 --- /dev/null +++ b/docs/source/reference/fixtures.rst @@ -0,0 +1,58 @@ +======== +Fixtures +======== + +``event_loop`` +============== +Creates a new asyncio event loop based on the current event loop policy. The new loop +is available as the return value of this fixture or via `asyncio.get_running_loop `__. +The event loop is closed when the fixture scope ends. The fixture scope defaults +to ``function`` scope. + +.. code-block:: python + + def test_http_client(event_loop): + url = "http://httpbin.org/get" + resp = event_loop.run_until_complete(http_client(url)) + assert b"HTTP/1.1 200 OK" in resp + +Note that, when using the ``event_loop`` fixture, you need to interact with the event loop using methods like ``event_loop.run_until_complete``. If you want to *await* code inside your test function, you need to write a coroutine and use it as a test function. The `asyncio <#pytest-mark-asyncio>`__ marker +is used to mark coroutines that should be treated as test functions. + +The ``event_loop`` fixture can be overridden in any of the standard pytest locations, +e.g. directly in the test file, or in ``conftest.py``. This allows redefining the +fixture scope, for example: + +.. code-block:: python + + @pytest.fixture(scope="session") + def event_loop(): + policy = asyncio.get_event_loop_policy() + loop = policy.new_event_loop() + yield loop + loop.close() + +If you need to change the type of the event loop, prefer setting a custom event loop policy over redefining the ``event_loop`` fixture. + +If the ``pytest.mark.asyncio`` decorator is applied to a test function, the ``event_loop`` +fixture will be requested automatically by the test function. + +``unused_tcp_port`` +=================== +Finds and yields a single unused TCP port on the localhost interface. Useful for +binding temporary test servers. + +``unused_tcp_port_factory`` +=========================== +A callable which returns a different unused TCP port each invocation. Useful +when several unused TCP ports are required in a test. + +.. code-block:: python + + def a_test(unused_tcp_port_factory): + port1, port2 = unused_tcp_port_factory(), unused_tcp_port_factory() + ... + +``unused_udp_port`` and ``unused_udp_port_factory`` +=================================================== +Works just like their TCP counterparts but returns unused UDP ports. diff --git a/docs/source/reference/index.rst b/docs/source/reference/index.rst new file mode 100644 index 00000000..c07d0e19 --- /dev/null +++ b/docs/source/reference/index.rst @@ -0,0 +1,15 @@ +========= +Reference +========= + +.. toctree:: + :hidden: + + configuration + fixtures + markers + decorators + changelog + +This section of the documentation provides descriptions of the individual parts provided by pytest-asyncio. +The reference section also provides a chronological list of changes for each release. diff --git a/docs/source/reference/markers.rst b/docs/source/reference/markers.rst new file mode 100644 index 00000000..eb89592c --- /dev/null +++ b/docs/source/reference/markers.rst @@ -0,0 +1,34 @@ +======= +Markers +======= + +``pytest.mark.asyncio`` +======================= +A coroutine or async generator with this marker will be treated as a test function by pytest. The marked function will be executed as an +asyncio task in the event loop provided by the ``event_loop`` fixture. + +In order to make your test code a little more concise, the pytest |pytestmark|_ +feature can be used to mark entire modules or classes with this marker. +Only test coroutines will be affected (by default, coroutines prefixed by +``test_``), so, for example, fixtures are safe to define. + +.. code-block:: python + + import asyncio + + import pytest + + # All test coroutines will be treated as marked. + pytestmark = pytest.mark.asyncio + + + async def test_example(event_loop): + """No marker!""" + await asyncio.sleep(0, loop=event_loop) + +In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted, the marker is added +automatically to *async* test functions. + + +.. |pytestmark| replace:: ``pytestmark`` +.. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 403d814f..c0aa4261 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -7,6 +7,7 @@ import socket import sys import warnings +from textwrap import dedent from typing import ( Any, AsyncIterator, @@ -25,7 +26,15 @@ ) import pytest -from pytest import Function, Item, Session +from pytest import ( + Config, + FixtureRequest, + Function, + Item, + Parser, + PytestPluginManager, + Session, +) if sys.version_info >= (3, 8): from typing import Literal @@ -46,11 +55,9 @@ FixtureFunction = Union[SimpleFixtureFunction, FactoryFixtureFunction] 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 +# https://github.com/pytest-dev/pytest/pull/9510 +FixtureDef = Any +SubRequest = Any class Mode(str, enum.Enum): @@ -169,14 +176,6 @@ def pytest_configure(config: Config) -> None: "run using an asyncio event loop", ) - if getattr(pytest, "version_tuple", (0, 0, 0)) < (7,): - warnings.warn( - "You're using an outdated version of pytest. Newer releases of " - "pytest-asyncio will not be compatible with this pytest version. " - "Please update pytest to version 7 or later.", - DeprecationWarning, - ) - @pytest.hookimpl(tryfirst=True) def pytest_report_header(config: Config) -> List[str]: @@ -372,29 +371,22 @@ def _hypothesis_test_wraps_coroutine(function: Any) -> bool: return _is_coroutine(function.hypothesis.inner_test) -@pytest.hookimpl(trylast=True) -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() - try: - loop = policy.get_event_loop() - except RuntimeError: - loop = None - if loop is not None: - # Clean up existing loop to avoid ResourceWarnings - loop.close() - new_loop = policy.new_event_loop() # Replace existing event loop - # Ensure subsequent calls to get_event_loop() succeed - policy.set_event_loop(new_loop) - - @pytest.hookimpl(hookwrapper=True) 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": + # The use of a fixture finalizer is preferred over the + # pytest_fixture_post_finalizer hook. The fixture finalizer is invoked once + # for each fixture, whereas the hook may be invoked multiple times for + # any specific fixture. + # see https://github.com/pytest-dev/pytest/issues/5848 + _add_finalizers( + fixturedef, + _close_event_loop, + _provide_clean_event_loop, + ) outcome = yield loop = outcome.get_result() policy = asyncio.get_event_loop_policy() @@ -405,7 +397,9 @@ def pytest_fixture_setup( if old_loop is not loop: old_loop.close() except RuntimeError: - # Swallow this, since it's probably bad event loop hygiene. + # Either the current event loop has been set to None + # or the loop policy doesn't specify to create new loops + # or we're not in the main thread pass policy.set_event_loop(loop) return @@ -413,6 +407,59 @@ def pytest_fixture_setup( yield +def _add_finalizers(fixturedef: FixtureDef, *finalizers: Callable[[], object]) -> None: + """ + Regsiters the specified fixture finalizers in the fixture. + + Finalizers need to specified in the exact order in which they should be invoked. + + :param fixturedef: Fixture definition which finalizers should be added to + :param finalizers: Finalizers to be added + """ + for finalizer in reversed(finalizers): + fixturedef.addfinalizer(finalizer) + + +_UNCLOSED_EVENT_LOOP_WARNING = dedent( + """\ + pytest-asyncio detected an unclosed event loop when tearing down the event_loop + fixture: %r + pytest-asyncio will close the event loop for you, but future versions of the + library will no longer do so. In order to ensure compatibility with future + versions, please make sure that: + 1. Any custom "event_loop" fixture properly closes the loop after yielding it + 2. Your code does not modify the event loop in async fixtures or tests + """ +) + + +def _close_event_loop() -> None: + policy = asyncio.get_event_loop_policy() + try: + loop = policy.get_event_loop() + except RuntimeError: + loop = None + if loop is not None: + if not loop.is_closed(): + warnings.warn( + _UNCLOSED_EVENT_LOOP_WARNING % loop, + DeprecationWarning, + ) + loop.close() + + +def _provide_clean_event_loop() -> None: + # At this point, the event loop for the current thread is closed. + # When a user calls asyncio.get_event_loop(), they will get a closed loop. + # In order to avoid this side effect from pytest-asyncio, we need to replace + # the current loop with a fresh one. + # Note that we cannot set the loop to None, because get_event_loop only creates + # a new loop, when set_event_loop has not been called. + policy = asyncio.get_event_loop_policy() + new_loop = policy.new_event_loop() + policy.set_event_loop(new_loop) + + @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Optional[object]: """ @@ -508,7 +555,7 @@ def pytest_runtest_setup(item: pytest.Item) -> None: @pytest.fixture -def event_loop(request: "pytest.FixtureRequest") -> Iterator[asyncio.AbstractEventLoop]: +def event_loop(request: 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 diff --git a/setup.cfg b/setup.cfg index 04ea3d90..ee57438c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,10 @@ name = pytest-asyncio version = attr: pytest_asyncio.__version__ url = https://github.com/pytest-dev/pytest-asyncio project_urls = - GitHub = https://github.com/pytest-dev/pytest-asyncio + Documentation = https://pytest-asyncio.readthedocs.io + Changelog = https://pytest-asyncio.readthedocs.io/en/latest/reference/changelog.html + Source Code = https://github.com/pytest-dev/pytest-asyncio + Bug Tracker = https://github.com/pytest-dev/pytest-asyncio/issues description = Pytest support for asyncio long_description = file: README.rst long_description_content_type = text/x-rst @@ -37,7 +40,7 @@ include_package_data = True # Always adjust requirements.txt and pytest-min-requirements.txt when changing runtime dependencies install_requires = - pytest >= 6.1.0 + pytest >= 7.0.0 typing-extensions >= 3.7.2; python_version < "3.8" [options.extras_require] diff --git a/tests/async_fixtures/test_async_fixtures_scope.py b/tests/async_fixtures/test_async_fixtures_scope.py index b150f8a8..079a981a 100644 --- a/tests/async_fixtures/test_async_fixtures_scope.py +++ b/tests/async_fixtures/test_async_fixtures_scope.py @@ -10,7 +10,9 @@ @pytest.fixture(scope="module") def event_loop(): """A module-scoped event loop.""" - return asyncio.new_event_loop() + loop = asyncio.new_event_loop() + yield loop + loop.close() @pytest.fixture(scope="module") diff --git a/tests/hypothesis/test_base.py b/tests/hypothesis/test_base.py index e6da3427..aef20d79 100644 --- a/tests/hypothesis/test_base.py +++ b/tests/hypothesis/test_base.py @@ -1,18 +1,11 @@ """Tests for the Hypothesis integration, which wraps async functions in a sync shim for Hypothesis. """ -import asyncio from textwrap import dedent import pytest from hypothesis import given, strategies as st - - -@pytest.fixture(scope="module") -def event_loop(): - loop = asyncio.get_event_loop_policy().new_event_loop() - yield loop - loop.close() +from pytest import Pytester @given(st.integers()) @@ -35,16 +28,38 @@ async def test_mark_and_parametrize(x, y): assert y in (1, 2) -@given(st.integers()) -@pytest.mark.asyncio -async def test_can_use_fixture_provided_event_loop(event_loop, n): - semaphore = asyncio.Semaphore(value=0) - event_loop.call_soon(semaphore.release) - await semaphore.acquire() +def test_can_use_explicit_event_loop_fixture(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + from hypothesis import given + import hypothesis.strategies as st + + pytest_plugins = 'pytest_asyncio' + + @pytest.fixture(scope="module") + def event_loop(): + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + @given(st.integers()) + @pytest.mark.asyncio + async def test_explicit_fixture_request(event_loop, n): + semaphore = asyncio.Semaphore(value=0) + event_loop.call_soon(semaphore.release) + await semaphore.acquire() + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) -def test_async_auto_marked(testdir): - testdir.makepyfile( +def test_async_auto_marked(pytester: Pytester): + pytester.makepyfile( dedent( """\ import asyncio @@ -60,13 +75,13 @@ async def test_hypothesis(n: int): """ ) ) - result = testdir.runpytest("--asyncio-mode=auto") + result = pytester.runpytest("--asyncio-mode=auto") result.assert_outcomes(passed=1) -def test_sync_not_auto_marked(testdir): +def test_sync_not_auto_marked(pytester: Pytester): """Assert that synchronous Hypothesis functions are not marked with asyncio""" - testdir.makepyfile( + pytester.makepyfile( dedent( """\ import asyncio @@ -84,5 +99,5 @@ def test_hypothesis(request, n: int): """ ) ) - result = testdir.runpytest("--asyncio-mode=auto") + result = pytester.runpytest("--asyncio-mode=auto") result.assert_outcomes(passed=1) diff --git a/tests/respect_event_loop_policy/conftest.py b/tests/respect_event_loop_policy/conftest.py deleted file mode 100644 index 2c5cef24..00000000 --- a/tests/respect_event_loop_policy/conftest.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Defines and sets a custom event loop policy""" -import asyncio -from asyncio import DefaultEventLoopPolicy, SelectorEventLoop - - -class TestEventLoop(SelectorEventLoop): - pass - - -class TestEventLoopPolicy(DefaultEventLoopPolicy): - def new_event_loop(self): - return TestEventLoop() - - -# This statement represents a code which sets a custom event loop policy -asyncio.set_event_loop_policy(TestEventLoopPolicy()) diff --git a/tests/respect_event_loop_policy/test_respects_event_loop_policy.py b/tests/respect_event_loop_policy/test_respects_event_loop_policy.py deleted file mode 100644 index 610b3388..00000000 --- a/tests/respect_event_loop_policy/test_respects_event_loop_policy.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Tests that any externally provided event loop policy remains unaltered.""" -import asyncio - -import pytest - - -@pytest.mark.asyncio -async def test_uses_loop_provided_by_custom_policy(): - """Asserts that test cases use the event loop - provided by the custom event loop policy""" - assert type(asyncio.get_event_loop()).__name__ == "TestEventLoop" - - -@pytest.mark.asyncio -async def test_custom_policy_is_not_overwritten(): - """Asserts that any custom event loop policy stays the same across test cases""" - assert type(asyncio.get_event_loop()).__name__ == "TestEventLoop" diff --git a/tests/test_event_loop_fixture.py b/tests/test_event_loop_fixture.py new file mode 100644 index 00000000..aaf591c9 --- /dev/null +++ b/tests/test_event_loop_fixture.py @@ -0,0 +1,53 @@ +from textwrap import dedent + +from pytest import Pytester + + +def test_event_loop_fixture_respects_event_loop_policy(pytester: Pytester): + pytester.makeconftest( + dedent( + """\ + '''Defines and sets a custom event loop policy''' + import asyncio + from asyncio import DefaultEventLoopPolicy, SelectorEventLoop + + class TestEventLoop(SelectorEventLoop): + pass + + class TestEventLoopPolicy(DefaultEventLoopPolicy): + def new_event_loop(self): + return TestEventLoop() + + # This statement represents a code which sets a custom event loop policy + asyncio.set_event_loop_policy(TestEventLoopPolicy()) + """ + ) + ) + pytester.makepyfile( + dedent( + """\ + '''Tests that any externally provided event loop policy remains unaltered''' + import asyncio + + import pytest + + + @pytest.mark.asyncio + async def test_uses_loop_provided_by_custom_policy(): + '''Asserts that test cases use the event loop + provided by the custom event loop policy''' + assert type(asyncio.get_event_loop()).__name__ == "TestEventLoop" + + + @pytest.mark.asyncio + async def test_custom_policy_is_not_overwritten(): + ''' + Asserts that any custom event loop policy stays the same + across test cases. + ''' + assert type(asyncio.get_event_loop()).__name__ == "TestEventLoop" + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=2) diff --git a/tests/test_event_loop_fixture_finalizer.py b/tests/test_event_loop_fixture_finalizer.py new file mode 100644 index 00000000..b676df2d --- /dev/null +++ b/tests/test_event_loop_fixture_finalizer.py @@ -0,0 +1,137 @@ +from textwrap import dedent + +from pytest import Pytester + + +def test_event_loop_fixture_finalizer_returns_fresh_loop_after_test(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + + loop = asyncio.get_event_loop_policy().get_event_loop() + + @pytest.mark.asyncio + async def test_1(): + # This async test runs in its own event loop + global loop + running_loop = asyncio.get_event_loop_policy().get_event_loop() + # Make sure this test case received a different loop + assert running_loop is not loop + + def test_2(): + # Code outside of pytest-asyncio should not receive a "used" event loop + current_loop = asyncio.get_event_loop_policy().get_event_loop() + assert not current_loop.is_running() + assert not current_loop.is_closed() + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_event_loop_fixture_finalizer_handles_loop_set_to_none_sync( + pytester: Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + + def test_sync(event_loop): + asyncio.get_event_loop_policy().set_event_loop(None) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +def test_event_loop_fixture_finalizer_handles_loop_set_to_none_async_without_fixture( + pytester: Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + @pytest.mark.asyncio + async def test_async_without_explicit_fixture_request(): + asyncio.get_event_loop_policy().set_event_loop(None) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +def test_event_loop_fixture_finalizer_handles_loop_set_to_none_async_with_fixture( + pytester: Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + @pytest.mark.asyncio + async def test_async_with_explicit_fixture_request(event_loop): + asyncio.get_event_loop_policy().set_event_loop(None) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +def test_event_loop_fixture_finalizer_raises_warning_when_fixture_leaves_loop_unclosed( + pytester: Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.fixture + def event_loop(): + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + + @pytest.mark.asyncio + async def test_ends_with_unclosed_loop(): + pass + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict", "-W", "default") + result.assert_outcomes(passed=1, warnings=1) + result.stdout.fnmatch_lines("*unclosed event loop*") + + +def test_event_loop_fixture_finalizer_raises_warning_when_test_leaves_loop_unclosed( + pytester: Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + async def test_ends_with_unclosed_loop(): + asyncio.set_event_loop(asyncio.new_event_loop()) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict", "-W", "default") + result.assert_outcomes(passed=1, warnings=1) + result.stdout.fnmatch_lines("*unclosed event loop*") diff --git a/tests/test_event_loop_scope.py b/tests/test_event_loop_scope.py deleted file mode 100644 index 21fd6415..00000000 --- a/tests/test_event_loop_scope.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Test the event loop fixture provides a separate loop for each test. - -These tests need to be run together. -""" -import asyncio - -import pytest - -loop: asyncio.AbstractEventLoop - - -def test_1(): - global loop - # The main thread should have a default event loop. - loop = asyncio.get_event_loop_policy().get_event_loop() - - -@pytest.mark.asyncio -async def test_2(): - global loop - running_loop = asyncio.get_event_loop_policy().get_event_loop() - # Make sure this test case received a different loop - assert running_loop is not loop - loop = running_loop # Store the loop reference for later - - -def test_3(): - global loop - current_loop = asyncio.get_event_loop_policy().get_event_loop() - # Now the event loop from test_2 should have been cleaned up - assert loop is not current_loop - - -def test_4(event_loop): - # If a test sets the loop to None -- pytest_fixture_post_finalizer() - # still should work - asyncio.get_event_loop_policy().set_event_loop(None) diff --git a/tests/test_pytest_min_version_warning.py b/tests/test_pytest_min_version_warning.py deleted file mode 100644 index 5f7bd72f..00000000 --- a/tests/test_pytest_min_version_warning.py +++ /dev/null @@ -1,26 +0,0 @@ -from textwrap import dedent - -import pytest - - -@pytest.mark.skipif( - pytest.__version__ < "7.0.0", - reason="The warning shouldn't be present when run with recent pytest versions", -) -@pytest.mark.parametrize("mode", ("auto", "strict")) -def test_pytest_min_version_warning_is_not_triggered_for_pytest_7(testdir, mode): - testdir.makepyfile( - dedent( - """\ - import pytest - - pytest_plugins = 'pytest_asyncio' - - @pytest.mark.asyncio - async def test_triggers_pytest_warning(): - pass - """ - ) - ) - result = testdir.runpytest(f"--asyncio-mode={mode}") - result.assert_outcomes(passed=1, warnings=0) diff --git a/tools/get-version.py b/tools/get-version.py index e988a32c..c29081b9 100644 --- a/tools/get-version.py +++ b/tools/get-version.py @@ -8,9 +8,9 @@ def main(): version_string = metadata.version("pytest-asyncio") version = parse_version(version_string) - print(f"::set-output name=version::{version}") + print(f"version={version}") prerelease = json.dumps(version.is_prerelease) - print(f"::set-output name=prerelease::{prerelease}") + print(f"prerelease={prerelease}") if __name__ == "__main__": diff --git a/tox.ini b/tox.ini index 1d8994ae..4987355b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] minversion = 3.14.0 -envlist = py37, py38, py39, py310, py311, lint, version-info, pytest-min +envlist = py37, py38, py39, py310, py311, py312, pytest-min isolated_build = true passenv = CI @@ -23,34 +23,12 @@ commands = make test allowlist_externals = make -[testenv:lint] -basepython = python3.10 -extras = testing -deps = - pre-commit == 2.16.0 -commands = - make lint -allowlist_externals = - make - -[testenv:coverage-report] -deps = coverage -skip_install = true -commands = - coverage combine - coverage report - -[testenv:version-info] -deps = - packaging == 21.3 -commands = - python ./tools/get-version.py - [gh-actions] python = 3.7: py37, pytest-min 3.8: py38 3.9: py39 3.10: py310 - 3.11-dev: py311 + 3.11: py311 + 3.12: py312 pypy3: pypy3