diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..a30293ca --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @asvetlov @seifertm @Tinche diff --git a/.github/actionlint-matcher.json b/.github/actionlint-matcher.json new file mode 100644 index 00000000..a99709f7 --- /dev/null +++ b/.github/actionlint-matcher.json @@ -0,0 +1,17 @@ +{ + "problemMatcher": [ + { + "owner": "actionlint", + "pattern": [ + { + "code": 5, + "column": 3, + "file": 1, + "line": 2, + "message": 4, + "regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*: (?:\\x1b\\[\\d+m)*(.+?)(?:\\x1b\\[\\d+m)* \\[(.+?)\\]$" + } + ] + } + ] +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..28b158af --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +--- +version: 2 +updates: +- package-ecosystem: pip + directory: /dependencies/default + schedule: + interval: weekly + open-pull-requests-limit: 10 + target-branch: master +- package-ecosystem: github-actions + directory: / + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3b249ffb..5c5876b8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,66 +3,130 @@ name: CI on: push: - branches: ["master"] + branches: [master] + tags: [v*] pull_request: - branches: ["master"] + branches: [master] workflow_dispatch: jobs: - tests: - name: "Python ${{ matrix.python-version }}" - runs-on: "ubuntu-latest" + lint: + name: Run linters + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + prerelease: ${{ steps.version.outputs.prerelease }} + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Install GitHub matcher for ActionLint checker + run: | + echo "::add-matcher::.github/actionlint-matcher.json" + - 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: Get version info + id: version + run: tox -e version-info + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: dist + path: dist + + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest env: - USING_COVERAGE: "3.6,3.7,3.8,3.9,3.10" + USING_COVERAGE: 3.7,3.8,3.9,3.10,3.11 strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10-dev"] + python-version: ['3.7', '3.8', '3.9', '3.10', 3.11-dev] steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v2" - with: - python-version: "${{ matrix.python-version }}" - - name: "Install dependencies" - run: | - set -xe - python -VV - python -m site - python -m pip install --upgrade pip wheel - python -m pip install --upgrade coverage[toml] virtualenv tox tox-gh-actions - - name: "Run tox targets for ${{ matrix.python-version }}" - run: "python -m tox" - - # We always use a modern Python version for combining coverage to prevent - # parsing errors in older versions for modern code. - - uses: "actions/setup-python@v2" - with: - python-version: "3.9" + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + set -xe + python -VV + python -m site + python -m pip install --upgrade pip + python -m pip install --upgrade coverage[toml] virtualenv tox tox-gh-actions + - name: Run tox targets for ${{ matrix.python-version }} + run: python -m tox - - name: "Upload coverage to Codecov" - if: "contains(env.USING_COVERAGE, matrix.python-version)" - uses: "codecov/codecov-action@v1" - with: - fail_ci_if_error: true - - package: - name: "Build & verify package" - runs-on: "ubuntu-latest" + - name: Prepare coverage artifact + if: ${{ contains(env.USING_COVERAGE, matrix.python-version) }} + uses: aio-libs/prepare-coverage@v21.9.1 + check: + name: Check + if: always() + needs: [lint, test] + runs-on: ubuntu-latest steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v2" - with: - python-version: "3.9" + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} + - name: Upload coverage + uses: aio-libs/upload-coverage@v21.9.4 - - name: "Install poetry, check-wheel-content, and twine" - run: "python -m pip install wheel twine check-wheel-contents" - - name: "Build package" - run: "python setup.py sdist bdist_wheel" - - 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/*" + deploy: + name: Deploy + environment: release + # Run only on pushing a tag + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + needs: [lint, check] + runs-on: ubuntu-latest + steps: + - name: Install pandoc + run: | + 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: + name: dist + path: dist + - name: Collected dists + run: | + tree dist + - name: Convert README.rst to Markdown + run: | + pandoc -s -o README.md README.rst + - name: PyPI upload + uses: pypa/gh-action-pypi-publish@v1.5.1 + with: + packages_dir: dist + password: ${{ secrets.PYPI_API_TOKEN }} + - name: GitHub Release + uses: ncipollo/release-action@v1 + with: + name: pytest-asyncio ${{ needs.lint.outputs.version }} + artifacts: dist/* + bodyFile: README.md + prerelease: ${{ needs.lint.outputs.prerelease }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 09758085..7dd9b771 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,7 @@ htmlcov/ .tox/ .coverage .coverage.* -.cache +.pytest_cache nosetests.xml coverage.xml *,cover @@ -58,4 +58,12 @@ docs/_build/ target/ .venv* -.idea \ No newline at end of file +.idea +.vscode + +# pyenv +.python-version + + +# generated by setuptools_scm +pytest_asyncio/_version.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..8c15003b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,67 @@ +--- +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: + - id: check-merge-conflict + exclude: rst$ +- repo: https://github.com/asottile/yesqa + rev: v1.3.0 + hooks: + - id: yesqa +- repo: https://github.com/Zac-HD/shed + rev: 0.6.0 # 0.7 does not support Python 3.7 + hooks: + - id: shed + args: + - --refactor + - --py37-plus + types_or: + - python + - markdown + - rst +- repo: https://github.com/jumanjihouse/pre-commit-hook-yamlfmt + rev: 0.1.1 + hooks: + - id: yamlfmt + args: [--mapping, '2', --sequence, '2', --offset, '0'] +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: fix-encoding-pragma + args: [--remove] + - id: check-case-conflict + - id: check-json + - id: check-xml + - id: check-yaml + - id: debug-statements +- repo: https://gitlab.com/pycqa/flake8 + rev: 3.9.2 + hooks: + - id: flake8 + language_version: python3 +- repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.9.0 + hooks: + - id: python-use-type-annotations +- repo: https://github.com/rhysd/actionlint + rev: v1.6.8 + hooks: + - id: actionlint-docker + args: + - -ignore + - 'SC2155:' + - -ignore + - 'SC2086:' + - -ignore + - 'SC1004:' +- repo: https://github.com/sirosen/check-jsonschema + rev: 0.9.1 + hooks: + - id: check-github-actions +ci: + skip: + - actionlint-docker + - check-github-actions diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 00000000..5c84b46e --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,179 @@ +========= +Changelog +========= + +0.20.1 (22-10-21) +================= +- Fixes an issue that warned about using an old version of pytest, even though the most recent version was installed. `#430 `_ + +0.20.0 (22-10-21) +================= +- BREAKING: Removed *legacy* mode. If you're upgrading from v0.19 and you haven't configured ``asyncio_mode = legacy``, you can upgrade without taking any additional action. If you're upgrading from an earlier version or you have explicitly enabled *legacy* mode, you need to switch to *auto* or *strict* mode before upgrading to this version. +- Deprecate use of pytest v6. +- Fixed an issue which prevented fixture setup from being cached. `#404 `_ + +0.19.0 (22-07-13) +================= +- BREAKING: The default ``asyncio_mode`` is now *strict*. `#293 `_ +- Removes `setup.py` since all relevant configuration is present `setup.cfg`. Users requiring an editable installation of pytest-asyncio need to use pip v21.1 or newer. `#283 `_ +- Declare support for Python 3.11. + +0.18.3 (22-03-25) +================= +- Adds `pytest-trio `_ to the test dependencies +- Fixes a bug that caused pytest-asyncio to try to set up async pytest_trio fixtures in strict mode. `#298 `_ + +0.18.2 (22-03-03) +================= +- Fix asyncio auto mode not marking static methods. `#295 `_ +- Fix a compatibility issue with Hypothesis 6.39.0. `#302 `_ + +0.18.1 (22-02-10) +================= +- Fixes a regression that prevented async fixtures from working in synchronous tests. `#286 `_ + +0.18.0 (22-02-07) +================= + +- Raise a warning if @pytest.mark.asyncio is applied to non-async function. `#275 `_ +- Support parametrized ``event_loop`` fixture. `#278 `_ + +0.17.2 (22-01-17) +================= + +- Require ``typing-extensions`` on Python<3.8 only. `#269 `_ +- Fix a regression in tests collection introduced by 0.17.1, the plugin works fine with non-python tests again. `#267 `_ + + +0.17.1 (22-01-16) +================= +- Fixes a bug that prevents async Hypothesis tests from working without explicit ``asyncio`` marker when ``--asyncio-mode=auto`` is set. `#258 `_ +- Fixed a bug that closes the default event loop if the loop doesn't exist `#257 `_ +- Added type annotations. `#198 `_ +- Show asyncio mode in pytest report headers. `#266 `_ +- Relax ``asyncio_mode`` type definition; it allows to support pytest 6.1+. `#262 `_ + +0.17.0 (22-01-13) +================= +- `pytest-asyncio` no longer alters existing event loop policies. `#168 `_, `#188 `_ +- Drop support for Python 3.6 +- Fixed an issue when pytest-asyncio was used in combination with `flaky` or inherited asynchronous Hypothesis tests. `#178 `_ `#231 `_ +- Added `flaky `_ to test dependencies +- Added ``unused_udp_port`` and ``unused_udp_port_factory`` fixtures (similar to ``unused_tcp_port`` and ``unused_tcp_port_factory`` counterparts. `#99 `_ +- Added the plugin modes: *strict*, *auto*, and *legacy*. See `documentation `_ for details. `#125 `_ +- Correctly process ``KeyboardInterrupt`` during async fixture setup phase `#219 `_ + +0.16.0 (2021-10-16) +=================== +- Add support for Python 3.10 + +0.15.1 (2021-04-22) +=================== +- Hotfix for errors while closing event loops while replacing them. + `#209 `_ + `#210 `_ + +0.15.0 (2021-04-19) +=================== +- Add support for Python 3.9 +- Abandon support for Python 3.5. If you still require support for Python 3.5, please use pytest-asyncio v0.14 or earlier. +- Set ``unused_tcp_port_factory`` fixture scope to 'session'. + `#163 `_ +- Properly close event loops when replacing them. + `#208 `_ + +0.14.0 (2020-06-24) +=================== +- Fix `#162 `_, and ``event_loop`` fixture behavior now is coherent on all scopes. + `#164 `_ + +0.12.0 (2020-05-04) +=================== +- Run the event loop fixture as soon as possible. This helps with fixtures that have an implicit dependency on the event loop. + `#156 `_ + +0.11.0 (2020-04-20) +=================== +- Test on 3.8, drop 3.3 and 3.4. Stick to 0.10 for these versions. + `#152 `_ +- Use the new Pytest 5.4.0 Function API. We therefore depend on pytest >= 5.4.0. + `#142 `_ +- Better ``pytest.skip`` support. + `#126 `_ + +0.10.0 (2019-01-08) +==================== +- ``pytest-asyncio`` integrates with `Hypothesis `_ + to support ``@given`` on async test functions using ``asyncio``. + `#102 `_ +- Pytest 4.1 support. + `#105 `_ + +0.9.0 (2018-07-28) +================== +- Python 3.7 support. +- Remove ``event_loop_process_pool`` fixture and + ``pytest.mark.asyncio_process_pool`` marker (see + https://bugs.python.org/issue34075 for deprecation and removal details) + +0.8.0 (2017-09-23) +================== +- Improve integration with other packages (like aiohttp) with more careful event loop handling. + `#64 `_ + +0.7.0 (2017-09-08) +================== +- Python versions pre-3.6 can use the async_generator library for async fixtures. + `#62 ` + +0.6.0 (2017-05-28) +================== +- Support for Python versions pre-3.5 has been dropped. +- ``pytestmark`` now works on both module and class level. +- The ``forbid_global_loop`` parameter has been removed. +- Support for async and async gen fixtures has been added. + `#45 `_ +- The deprecation warning regarding ``asyncio.async()`` has been fixed. + `#51 `_ + +0.5.0 (2016-09-07) +================== +- Introduced a changelog. + `#31 `_ +- The ``event_loop`` fixture is again responsible for closing itself. + This makes the fixture slightly harder to correctly override, but enables + other fixtures to depend on it correctly. + `#30 `_ +- Deal with the event loop policy by wrapping a special pytest hook, + ``pytest_fixture_setup``. This allows setting the policy before fixtures + dependent on the ``event_loop`` fixture run, thus allowing them to take + advantage of the ``forbid_global_loop`` parameter. As a consequence of this, + we now depend on pytest 3.0. + `#29 `_ + +0.4.1 (2016-06-01) +================== +- Fix a bug preventing the propagation of exceptions from the plugin. + `#25 `_ + +0.4.0 (2016-05-30) +================== +- Make ``event_loop`` fixtures simpler to override by closing them in the + plugin, instead of directly in the fixture. + `#21 `_ +- Introduce the ``forbid_global_loop`` parameter. + `#21 `_ + +0.3.0 (2015-12-19) +================== +- Support for Python 3.5 ``async``/``await`` syntax. + `#17 `_ + +0.2.0 (2015-08-01) +================== +- ``unused_tcp_port_factory`` fixture. + `#10 `_ + +0.1.1 (2015-04-23) +================== +Initial release. diff --git a/LICENSE b/LICENSE index e06d2081..5c304d1a 100644 --- a/LICENSE +++ b/LICENSE @@ -199,4 +199,3 @@ Apache License WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..fdf813e9 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include CHANGELOG.rst + +recursive-exclude .github * +exclude .gitignore +exclude .pre-commit-config.yaml diff --git a/Makefile b/Makefile index 8cf88841..2b0216f9 100644 --- a/Makefile +++ b/Makefile @@ -20,9 +20,20 @@ clean-test: ## remove test and coverage artifacts rm -f .coverage rm -fr htmlcov/ -lint: ## check style with flake8 - flake8 pytest_asyncio tests - black --check --verbose pytest_asyncio tests +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: - pytest tests + coverage run -m pytest tests + coverage xml + coverage report + +install: + pip install -U pre-commit + pre-commit install diff --git a/README.rst b/README.rst index 7e6c975d..d62087bb 100644 --- a/README.rst +++ b/README.rst @@ -3,10 +3,10 @@ pytest-asyncio: pytest support for asyncio .. image:: https://img.shields.io/pypi/v/pytest-asyncio.svg :target: https://pypi.python.org/pypi/pytest-asyncio -.. image:: https://travis-ci.org/pytest-dev/pytest-asyncio.svg?branch=master - :target: https://travis-ci.org/pytest-dev/pytest-asyncio +.. image:: https://github.com/pytest-dev/pytest-asyncio/workflows/CI/badge.svg + :target: https://github.com/pytest-dev/pytest-asyncio/actions?workflow=CI .. image:: https://codecov.io/gh/pytest-dev/pytest-asyncio/branch/master/graph/badge.svg - :target: https://codecov.io/gh/pytest-dev/pytest-asyncio + :target: https://codecov.io/gh/pytest-dev/pytest-asyncio .. image:: https://img.shields.io/pypi/pyversions/pytest-asyncio.svg :target: https://github.com/pytest-dev/pytest-asyncio :alt: Supported Python versions @@ -25,7 +25,7 @@ provides useful fixtures and markers to make testing easier. @pytest.mark.asyncio async def test_some_asyncio_code(): res = await library.do_something() - assert b'expected result' == res + assert b"expected result" == res pytest-asyncio has been strongly influenced by pytest-tornado_. @@ -35,10 +35,13 @@ Features -------- - fixtures for creating and injecting versions of the asyncio event loop -- fixtures for injecting unused tcp ports +- fixtures for injecting unused tcp/udp ports - pytest markers for treating tests as asyncio coroutines - easy testing with non-default event loops - support for `async def` fixtures and async generator fixtures +- support *auto* mode to handle all async fixtures and tests automatically by asyncio; + provide *strict* mode if a test suite should work with different async frameworks + simultaneously, e.g. ``asyncio`` and ``trio``. Installation ------------ @@ -51,47 +54,97 @@ To install pytest-asyncio, simply: This is enough for pytest to pick up pytest-asyncio. +Modes +----- + +Pytest-asyncio provides two modes: *auto* and *strict* with *strict* mode being the default. + +The mode can be set by ``asyncio_mode`` configuration option in `configuration file +`_: + +.. code-block:: ini + + # pytest.ini + [pytest] + asyncio_mode = auto + +The value can be overridden by command-line option for ``pytest`` invocation: + +.. code-block:: bash + + $ pytest tests --asyncio-mode=strict + +Auto mode +~~~~~~~~~ + +When the mode is auto, all discovered *async* tests are considered *asyncio-driven* even +if they have no ``@pytest.mark.asyncio`` marker. + +All async fixtures are considered *asyncio-driven* as well, even if they are decorated +with a regular ``@pytest.fixture`` decorator instead of dedicated +``@pytest_asyncio.fixture`` counterpart. + +*asyncio-driven* means that tests and fixtures are executed by ``pytest-asyncio`` +plugin. + +This mode requires the simplest tests and fixtures configuration and is +recommended for default usage *unless* the same project and its test suite should +execute tests from different async frameworks, e.g. ``asyncio`` and ``trio``. In this +case, auto-handling can break tests designed for other framework; please use *strict* +mode instead. + +Strict mode +~~~~~~~~~~~ + +Strict mode enforces ``@pytest.mark.asyncio`` and ``@pytest_asyncio.fixture`` usage. +Without these markers, tests and fixtures are not considered as *asyncio-driven*, other +pytest plugin can handle them. + +Please use this mode if multiple async frameworks should be combined in the same test +suite. + +This mode is used by default for the sake of project inter-compatibility. + + Fixtures -------- ``event_loop`` ~~~~~~~~~~~~~~ -Creates and injects a new instance of the default asyncio event loop. By -default, the loop will be closed at the end of the test (i.e. the default -fixture scope is ``function``). +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. Note that just using the ``event_loop`` fixture won't make your test function a coroutine. You'll need to interact with the event loop directly, using methods like ``event_loop.run_until_complete``. See the ``pytest.mark.asyncio`` marker for treating test functions like coroutines. -Simply using this fixture will not set the generated event loop as the -default asyncio event loop, or change the asyncio event loop policy in any way. -Use ``pytest.mark.asyncio`` for this purpose. - .. code-block:: python def test_http_client(event_loop): - url = 'http://httpbin.org/get' + url = "http://httpbin.org/get" resp = event_loop.run_until_complete(http_client(url)) - assert b'HTTP/1.1 200 OK' in resp + assert b"HTTP/1.1 200 OK" in resp -This fixture can be easily overridden in any of the standard pytest locations -(e.g. directly in the test file, or in ``conftest.py``) to use a non-default -event loop. This will take effect even if you're using the -``pytest.mark.asyncio`` marker and not the ``event_loop`` fixture directly. +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 + @pytest.fixture(scope="session") def event_loop(): - loop = MyCustomLoop() + policy = asyncio.get_event_loop_policy() + loop = policy.new_event_loop() yield loop loop.close() -If the ``pytest.mark.asyncio`` marker is applied, a pytest hook will -ensure the produced loop is set as the default global loop. -Fixtures depending on the ``event_loop`` fixture can expect the policy to be properly modified when they run. +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`` marker is applied to a test function, the ``event_loop`` +fixture will be requested automatically by the test function. ``unused_tcp_port`` ~~~~~~~~~~~~~~~~~~~ @@ -109,18 +162,27 @@ when several unused TCP ports are required in a test. port1, port2 = unused_tcp_port_factory(), unused_tcp_port_factory() ... +``unused_udp_port`` and ``unused_udp_port_factory`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Work just like their TCP counterparts but return unused UDP ports. + + Async fixtures ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Asynchronous fixtures are defined just like ordinary pytest fixtures, except they should be coroutines or asynchronous generators. +Asynchronous fixtures are defined just like ordinary pytest fixtures, except they should be decorated with ``@pytest_asyncio.fixture``. .. code-block:: python3 - @pytest.fixture + import pytest_asyncio + + + @pytest_asyncio.fixture async def async_gen_fixture(): await asyncio.sleep(0.1) - yield 'a value' + yield "a value" + - @pytest.fixture(scope='module') + @pytest_asyncio.fixture(scope="module") async def async_fixture(): return await asyncio.sleep(0.1) @@ -129,6 +191,9 @@ 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. + Markers ------- @@ -139,7 +204,7 @@ Mark your test coroutine with this marker and pytest will execute it as an asyncio task using the event loop provided by the ``event_loop`` fixture. See the introductory section for an example. -The event loop used can be overriden by overriding the ``event_loop`` fixture +The event loop used can be overridden by overriding the ``event_loop`` fixture (see above). In order to make your test code a little more concise, the pytest |pytestmark|_ @@ -150,138 +215,30 @@ Only test coroutines will be affected (by default, coroutines prefixed by .. 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) -.. |pytestmark| replace:: ``pytestmark`` -.. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules - -Changelog ---------- -0.16.0 (2021-10-16) -~~~~~~~~~~~~~~~~~~~ -- Add support for Python 3.10 +In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted, the marker is added +automatically to *async* test functions. -0.15.1 (2021-04-22) -~~~~~~~~~~~~~~~~~~~ -- Hotfix for errors while closing event loops while replacing them. - `#209 `_ - `#210 `_ - -0.15.0 (2021-04-19) -~~~~~~~~~~~~~~~~~~~ -- Add support for Python 3.9 -- Abandon support for Python 3.5. If you still require support for Python 3.5, please use pytest-asyncio v0.14 or earlier. -- Set ``unused_tcp_port_factory`` fixture scope to 'session'. - `#163 `_ -- Properly close event loops when replacing them. - `#208 `_ - -0.14.0 (2020-06-24) -~~~~~~~~~~~~~~~~~~~ -- Fix `#162 `_, and ``event_loop`` fixture behavior now is coherent on all scopes. - `#164 `_ -0.12.0 (2020-05-04) -~~~~~~~~~~~~~~~~~~~ -- Run the event loop fixture as soon as possible. This helps with fixtures that have an implicit dependency on the event loop. - `#156 `_ +.. |pytestmark| replace:: ``pytestmark`` +.. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules -0.11.0 (2020-04-20) -~~~~~~~~~~~~~~~~~~~ -- Test on 3.8, drop 3.3 and 3.4. Stick to 0.10 for these versions. - `#152 `_ -- Use the new Pytest 5.4.0 Function API. We therefore depend on pytest >= 5.4.0. - `#142 `_ -- Better ``pytest.skip`` support. - `#126 `_ - -0.10.0 (2019-01-08) -~~~~~~~~~~~~~~~~~~~~ -- ``pytest-asyncio`` integrates with `Hypothesis `_ - to support ``@given`` on async test functions using ``asyncio``. - `#102 `_ -- Pytest 4.1 support. - `#105 `_ - -0.9.0 (2018-07-28) -~~~~~~~~~~~~~~~~~~ -- Python 3.7 support. -- Remove ``event_loop_process_pool`` fixture and - ``pytest.mark.asyncio_process_pool`` marker (see - https://bugs.python.org/issue34075 for deprecation and removal details) - -0.8.0 (2017-09-23) -~~~~~~~~~~~~~~~~~~ -- Improve integration with other packages (like aiohttp) with more careful event loop handling. - `#64 `_ - -0.7.0 (2017-09-08) -~~~~~~~~~~~~~~~~~~ -- Python versions pre-3.6 can use the async_generator library for async fixtures. - `#62 ` - - -0.6.0 (2017-05-28) -~~~~~~~~~~~~~~~~~~ -- Support for Python versions pre-3.5 has been dropped. -- ``pytestmark`` now works on both module and class level. -- The ``forbid_global_loop`` parameter has been removed. -- Support for async and async gen fixtures has been added. - `#45 `_ -- The deprecation warning regarding ``asyncio.async()`` has been fixed. - `#51 `_ - -0.5.0 (2016-09-07) -~~~~~~~~~~~~~~~~~~ -- Introduced a changelog. - `#31 `_ -- The ``event_loop`` fixture is again responsible for closing itself. - This makes the fixture slightly harder to correctly override, but enables - other fixtures to depend on it correctly. - `#30 `_ -- Deal with the event loop policy by wrapping a special pytest hook, - ``pytest_fixture_setup``. This allows setting the policy before fixtures - dependent on the ``event_loop`` fixture run, thus allowing them to take - advantage of the ``forbid_global_loop`` parameter. As a consequence of this, - we now depend on pytest 3.0. - `#29 `_ - - -0.4.1 (2016-06-01) -~~~~~~~~~~~~~~~~~~ -- Fix a bug preventing the propagation of exceptions from the plugin. - `#25 `_ - -0.4.0 (2016-05-30) -~~~~~~~~~~~~~~~~~~ -- Make ``event_loop`` fixtures simpler to override by closing them in the - plugin, instead of directly in the fixture. - `#21 `_ -- Introduce the ``forbid_global_loop`` parameter. - `#21 `_ - -0.3.0 (2015-12-19) -~~~~~~~~~~~~~~~~~~ -- Support for Python 3.5 ``async``/``await`` syntax. - `#17 `_ - -0.2.0 (2015-08-01) -~~~~~~~~~~~~~~~~~~ -- ``unused_tcp_port_factory`` fixture. - `#10 `_ - - -0.1.1 (2015-04-23) -~~~~~~~~~~~~~~~~~~ -Initial release. +Note about unittest +------------------- +Test classes subclassing the standard `unittest `__ library are not supported, users +are recommended to use `unittest.IsolatedAsyncioTestCase `__ +or an async framework such as `asynctest `__. Contributing ------------ diff --git a/dependencies/default/constraints.txt b/dependencies/default/constraints.txt new file mode 100644 index 00000000..cdaa6a6f --- /dev/null +++ b/dependencies/default/constraints.txt @@ -0,0 +1,24 @@ +async-generator==1.10 +attrs==22.1.0 +coverage==6.5.0 +flaky==3.7.0 +hypothesis==6.56.3 +idna==3.4 +importlib-metadata==5.0.0 +iniconfig==1.1.1 +mypy==0.982 +mypy-extensions==0.4.3 +outcome==1.2.0 +packaging==21.3 +pluggy==1.0.0 +py==1.11.0 +pyparsing==3.0.9 +pytest==7.1.3 +pytest-trio==0.7.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.9.0 diff --git a/dependencies/default/requirements.txt b/dependencies/default/requirements.txt new file mode 100644 index 00000000..01b2484e --- /dev/null +++ b/dependencies/default/requirements.txt @@ -0,0 +1,4 @@ +# Always adjust install_requires in setup.cfg and pytest-min-requirements.txt +# when changing runtime dependencies +pytest >= 6.1.0 +typing-extensions >= 3.7.2; python_version < "3.8" diff --git a/dependencies/pytest-min/constraints.txt b/dependencies/pytest-min/constraints.txt new file mode 100644 index 00000000..33f7948f --- /dev/null +++ b/dependencies/pytest-min/constraints.txt @@ -0,0 +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 +iniconfig==1.1.1 +mypy==0.942 +mypy-extensions==0.4.3 +outcome==1.1.0 +packaging==21.3 +pluggy==0.13.1 +py==1.11.0 +pyparsing==3.0.8 +pytest==6.1.0 +pytest-trio==0.7.0 +sniffio==1.2.0 +sortedcontainers==2.4.0 +toml==0.10.2 +tomli==2.0.1 +trio==0.20.0 +typing_extensions==4.2.0 diff --git a/dependencies/pytest-min/requirements.txt b/dependencies/pytest-min/requirements.txt new file mode 100644 index 00000000..4fc6ef2f --- /dev/null +++ b/dependencies/pytest-min/requirements.txt @@ -0,0 +1,4 @@ +# Always adjust install_requires in setup.cfg and requirements.txt +# when changing minimum version dependencies +pytest == 6.1.0 +typing-extensions >= 3.7.2; python_version < "3.8" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..81540a53 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[build-system] +requires = [ + "setuptools>=51.0", + "wheel>=0.36", + "setuptools_scm[toml]>=6.2" +] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +write_to = "pytest_asyncio/_version.py" diff --git a/pytest_asyncio/__init__.py b/pytest_asyncio/__init__.py index b16159e7..1bc2811d 100644 --- a/pytest_asyncio/__init__.py +++ b/pytest_asyncio/__init__.py @@ -1,2 +1,5 @@ """The main point for importing pytest-asyncio items.""" -__version__ = "0.16.0" +from ._version import version as __version__ # noqa +from .plugin import fixture + +__all__ = ("fixture",) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 7665ff4d..3b7b2304 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -1,30 +1,168 @@ """pytest-asyncio implementation.""" import asyncio import contextlib +import enum import functools import inspect import socket +import sys +import warnings +from typing import ( + Any, + AsyncIterator, + Awaitable, + Callable, + Dict, + Iterable, + Iterator, + List, + Optional, + Set, + TypeVar, + Union, + cast, + overload, +) import pytest +from pytest import Function, Session, Item + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + +_R = TypeVar("_R") + +_ScopeName = Literal["session", "package", "module", "class", "function"] +_T = TypeVar("_T") + +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 +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): + AUTO = "auto" + STRICT = "strict" + + +ASYNCIO_MODE_HELP = """\ +'auto' - for automatically handling all async functions by the plugin +'strict' - for autoprocessing disabling (useful if different async frameworks \ +should be tested together, e.g. \ +both pytest-asyncio and pytest-trio are used in the same project) +""" + + +def pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager) -> None: + group = parser.getgroup("asyncio") + group.addoption( + "--asyncio-mode", + dest="asyncio_mode", + default=None, + metavar="MODE", + help=ASYNCIO_MODE_HELP, + ) + parser.addini( + "asyncio_mode", + help="default value for --asyncio-mode", + default="strict", + ) -try: - from _pytest.python import transfer_markers -except ImportError: # Pytest 4.1.0 removes the transfer_marker api (#104) - def transfer_markers(*args, **kwargs): # noqa - """Noop when over pytest 4.1.0""" - pass +@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: + _make_asyncio_fixture_function(fixture_function) + return pytest.fixture(fixture_function, **kwargs) + + else: + + @functools.wraps(fixture) + def inner(fixture_function: FixtureFunction) -> FixtureFunction: + return fixture(fixture_function, **kwargs) + + return inner + + +def _is_asyncio_fixture_function(obj: Any) -> bool: + obj = getattr(obj, "__func__", obj) # instance method maybe? + return getattr(obj, "_force_asyncio_fixture", False) + + +def _make_asyncio_fixture_function(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: Any) -> bool: + """Check to see if an object is really an asyncio coroutine.""" + return asyncio.iscoroutinefunction(obj) -from inspect import isasyncgenfunction +def _is_coroutine_or_asyncgen(obj: Any) -> bool: + return _is_coroutine(obj) or inspect.isasyncgenfunction(obj) -def _is_coroutine(obj): - """Check to see if an object is really an asyncio coroutine.""" - return asyncio.iscoroutinefunction(obj) or inspect.isgeneratorfunction(obj) +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", @@ -33,59 +171,201 @@ def pytest_configure(config): "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.mark.tryfirst -def pytest_pycollect_makeitem(collector, name, obj): +def pytest_report_header(config: Config) -> List[str]: + """Add asyncio config to pytest header.""" + mode = _get_asyncio_mode(config) + return [f"asyncio: mode={mode}"] + + +def _preprocess_async_fixtures( + config: Config, + processed_fixturedefs: Set[FixtureDef], +) -> None: + asyncio_mode = _get_asyncio_mode(config) + fixturemanager = config.pluginmanager.get_plugin("funcmanage") + for fixtures in fixturemanager._arg2fixturedefs.values(): + for fixturedef in fixtures: + func = fixturedef.func + if fixturedef in processed_fixturedefs or not _is_coroutine_or_asyncgen( + func + ): + continue + if not _is_asyncio_fixture_function(func) and asyncio_mode == Mode.STRICT: + # Ignore async fixtures without explicit asyncio mark in strict mode + # This applies to pytest_trio fixtures, for example + continue + _make_asyncio_fixture_function(func) + _inject_fixture_argnames(fixturedef) + _synchronize_async_fixture(fixturedef) + assert _is_asyncio_fixture_function(fixturedef.func) + processed_fixturedefs.add(fixturedef) + + +def _inject_fixture_argnames(fixturedef: FixtureDef) -> None: + """ + Ensures that `request` and `event_loop` are arguments of the specified fixture. + """ + to_add = [] + for name in ("request", "event_loop"): + if name not in fixturedef.argnames: + to_add.append(name) + if to_add: + fixturedef.argnames += tuple(to_add) + + +def _synchronize_async_fixture(fixturedef: FixtureDef) -> None: + """ + Wraps the fixture function of an async fixture in a synchronous function. + """ + func = fixturedef.func + if inspect.isasyncgenfunction(func): + fixturedef.func = _wrap_asyncgen(func) + elif inspect.iscoroutinefunction(func): + fixturedef.func = _wrap_async(func) + + +def _add_kwargs( + func: Callable[..., Any], + kwargs: Dict[str, Any], + event_loop: asyncio.AbstractEventLoop, + request: SubRequest, +) -> Dict[str, Any]: + sig = inspect.signature(func) + ret = kwargs.copy() + if "request" in sig.parameters: + ret["request"] = request + if "event_loop" in sig.parameters: + ret["event_loop"] = event_loop + return ret + + +def _wrap_asyncgen(func: Callable[..., AsyncIterator[_R]]) -> Callable[..., _R]: + @functools.wraps(func) + def _asyncgen_fixture_wrapper( + event_loop: asyncio.AbstractEventLoop, request: SubRequest, **kwargs: Any + ) -> _R: + gen_obj = func(**_add_kwargs(func, kwargs, event_loop, request)) + + async def setup() -> _R: + res = await gen_obj.__anext__() + return res + + def finalizer() -> None: + """Yield again, to finalize.""" + + async def async_finalizer() -> None: + try: + await gen_obj.__anext__() + except StopAsyncIteration: + pass + else: + msg = "Async generator fixture didn't stop." + msg += "Yield only once." + raise ValueError(msg) + + event_loop.run_until_complete(async_finalizer()) + + result = event_loop.run_until_complete(setup()) + request.addfinalizer(finalizer) + return result + + return _asyncgen_fixture_wrapper + + +def _wrap_async(func: Callable[..., Awaitable[_R]]) -> Callable[..., _R]: + @functools.wraps(func) + def _async_fixture_wrapper( + event_loop: asyncio.AbstractEventLoop, request: SubRequest, **kwargs: Any + ) -> _R: + async def setup() -> _R: + res = await func(**_add_kwargs(func, kwargs, event_loop, request)) + return res + + return event_loop.run_until_complete(setup()) + + return _async_fixture_wrapper + + +_HOLDER: Set[FixtureDef] = set() + + +@pytest.mark.tryfirst +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 collector.funcnamefilter(name) and _is_coroutine(obj): - item = pytest.Function.from_parent(collector, name=name) + if not collector.funcnamefilter(name): + return None + _preprocess_async_fixtures(collector.config, _HOLDER) + return None - # Due to how pytest test collection works, module-level pytestmarks - # are applied after the collection step. Since this is the collection - # step, we look ourselves. - transfer_markers(obj, item.cls, item.module) - item = pytest.Function.from_parent(collector, name=name) # To reload keywords. - if "asyncio" in item.keywords: - return list(collector._genfunctions(name, obj)) +def pytest_collection_modifyitems( + session: Session, config: Config, items: List[Item] +) -> None: + """ + Marks collected async test items as `asyncio` tests. + The mark is only applied in `AUTO` mode. It is applied to: -class FixtureStripper: - """Include additional Fixture, and then strip them""" + - coroutines + - staticmethods wrapping coroutines + - Hypothesis tests wrapping coroutines - REQUEST = "request" - EVENT_LOOP = "event_loop" + """ + if _get_asyncio_mode(config) != Mode.AUTO: + return + function_items = (item for item in items if isinstance(item, Function)) + for function_item in function_items: + function = function_item.obj + if isinstance(function, staticmethod): + # staticmethods need to be unwrapped. + function = function.__func__ + if ( + _is_coroutine(function) + or _is_hypothesis_test(function) + and _hypothesis_test_wraps_coroutine(function) + ): + function_item.add_marker("asyncio") - def __init__(self, fixturedef): - self.fixturedef = fixturedef - self.to_strip = set() - def add(self, name): - """Add fixture name to fixturedef - and record in to_strip list (If not previously included)""" - if name in self.fixturedef.argnames: - return - self.fixturedef.argnames += (name,) - self.to_strip.add(name) - - def get_and_strip_from(self, name, data_dict): - """Strip name from data, and return value""" - result = data_dict[name] - if name in self.to_strip: - del data_dict[name] - return result +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, request): +def pytest_fixture_post_finalizer(fixturedef: FixtureDef, request: SubRequest) -> None: """Called after fixture teardown""" if fixturedef.argname == "event_loop": - # Set empty loop policy, so that subsequent get_event_loop() provides a new loop - asyncio.set_event_loop_policy(None) + 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, 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 @@ -101,119 +381,96 @@ def pytest_fixture_setup(fixturedef, request): policy.set_event_loop(loop) return - if isasyncgenfunction(fixturedef.func): - # This is an async generator function. Wrap it accordingly. - generator = fixturedef.func - - fixture_stripper = FixtureStripper(fixturedef) - fixture_stripper.add(FixtureStripper.EVENT_LOOP) - fixture_stripper.add(FixtureStripper.REQUEST) - - def wrapper(*args, **kwargs): - loop = fixture_stripper.get_and_strip_from( - FixtureStripper.EVENT_LOOP, kwargs - ) - request = fixture_stripper.get_and_strip_from( - FixtureStripper.REQUEST, kwargs - ) - - gen_obj = generator(*args, **kwargs) - - async def setup(): - res = await gen_obj.__anext__() - return res - - def finalizer(): - """Yield again, to finalize.""" - - async def async_finalizer(): - try: - await gen_obj.__anext__() - except StopAsyncIteration: - pass - else: - msg = "Async generator fixture didn't stop." - msg += "Yield only once." - raise ValueError(msg) - - loop.run_until_complete(async_finalizer()) - - request.addfinalizer(finalizer) - return loop.run_until_complete(setup()) - - fixturedef.func = wrapper - elif inspect.iscoroutinefunction(fixturedef.func): - coro = fixturedef.func - - fixture_stripper = FixtureStripper(fixturedef) - fixture_stripper.add(FixtureStripper.EVENT_LOOP) - - def wrapper(*args, **kwargs): - loop = fixture_stripper.get_and_strip_from( - FixtureStripper.EVENT_LOOP, kwargs - ) - - async def setup(): - res = await coro(*args, **kwargs) - return res - - return loop.run_until_complete(setup()) - - fixturedef.func = wrapper yield @pytest.hookimpl(tryfirst=True, hookwrapper=True) -def pytest_pyfunc_call(pyfuncitem): +def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Optional[object]: """ - Run asyncio marked test functions in an event loop instead of a normal - function call. + Pytest hook called before a test case is run. + + Wraps marked tests in a synchronous function + where the wrapped test coroutine is executed in an event loop. """ - if "asyncio" in pyfuncitem.keywords: - if getattr(pyfuncitem.obj, "is_hypothesis_test", False): + marker = pyfuncitem.get_closest_marker("asyncio") + if marker is not None: + 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, 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, + pyfuncitem.obj, + _loop=loop, ) yield -def wrap_in_sync(func, _loop): +def _is_hypothesis_test(function: Any) -> bool: + return getattr(function, "is_hypothesis_test", False) + + +def wrap_in_sync( + pyfuncitem: pytest.Function, + func: Callable[..., Awaitable[Any]], + _loop: asyncio.AbstractEventLoop, +): """Return a sync wrapper around an async function executing it in the current event loop.""" - @functools.wraps(func) - def inner(**kwargs): - coro = func(**kwargs) - if coro is not None: - task = asyncio.ensure_future(coro, loop=_loop) - try: - _loop.run_until_complete(task) - except BaseException: - # run_until_complete doesn't get the result from exceptions - # that are not subclasses of `Exception`. Consume all - # exceptions to prevent asyncio's warning from logging. - if task.done() and not task.cancelled(): - task.exception() - raise + # 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 + raw_func = getattr(func, "_raw_test_func", None) + if raw_func is not None: + func = raw_func + @functools.wraps(func) + def inner(*args, **kwargs): + coro = func(*args, **kwargs) + if not inspect.isawaitable(coro): + pyfuncitem.warn( + pytest.PytestWarning( + f"The test {pyfuncitem} is marked with '@pytest.mark.asyncio' " + "but it is not an async function. " + "Please remove asyncio marker. " + "If the test is not marked explicitly, " + "check for global markers applied via 'pytestmark'." + ) + ) + return + task = asyncio.ensure_future(coro, loop=_loop) + try: + _loop.run_until_complete(task) + except BaseException: + # run_until_complete doesn't get the result from exceptions + # that are not subclasses of `Exception`. Consume all + # exceptions to prevent asyncio's warning from logging. + if task.done() and not task.cancelled(): + task.exception() + raise + + inner._raw_test_func = func # type: ignore[attr-defined] return inner -def pytest_runtest_setup(item): - if "asyncio" in item.keywords: - # 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 ( - item.get_closest_marker("asyncio") is not None - and not getattr(item.obj, "hypothesis", False) - and getattr(item.obj, "is_hypothesis_test", False) +def pytest_runtest_setup(item: pytest.Item) -> None: + marker = item.get_closest_marker("asyncio") + if marker is None: + return + fixturenames = item.fixturenames # type: ignore[attr-defined] + # inject an event loop fixture for all async tests + if "event_loop" in fixturenames: + fixturenames.remove("event_loop") + fixturenames.insert(0, "event_loop") + obj = getattr(item, "obj", None) + if not getattr(obj, "hypothesis", False) and getattr( + obj, "is_hypothesis_test", False ): pytest.fail( "test function `%r` is using Hypothesis, but pytest-asyncio " @@ -222,36 +479,60 @@ 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_tcp_port(): - """Find an unused localhost TCP port from 1024-65535 and return it.""" - with contextlib.closing(socket.socket()) as sock: +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)) return sock.getsockname()[1] @pytest.fixture -def unused_tcp_port(): - return _unused_tcp_port() +def unused_tcp_port() -> int: + return _unused_port(socket.SOCK_STREAM) + + +@pytest.fixture +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() def factory(): """Return an unused port.""" - port = _unused_tcp_port() + port = _unused_port(socket.SOCK_STREAM) + + while port in produced: + port = _unused_port(socket.SOCK_STREAM) + + produced.add(port) + + return port + + return factory + + +@pytest.fixture(scope="session") +def unused_udp_port_factory() -> Callable[[], int]: + """A factory function, producing different unused UDP ports.""" + produced = set() + + def factory(): + """Return an unused port.""" + port = _unused_port(socket.SOCK_DGRAM) while port in produced: - port = _unused_tcp_port() + port = _unused_port(socket.SOCK_DGRAM) produced.add(port) 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 01610865..b1f1e82d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,60 @@ +[metadata] +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 +description = Pytest support for asyncio +long_description = file: README.rst +long_description_content_type = text/x-rst +author = Tin Tvrtković +author_email = tinchester@gmail.com +license = Apache 2.0 +license_file = LICENSE +classifiers = + Development Status :: 4 - Beta + + Intended Audience :: Developers + + License :: OSI Approved :: Apache Software License + + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + + Topic :: Software Development :: Testing + + Framework :: AsyncIO + Framework :: Pytest + Typing :: Typed + +[options] +python_requires = >=3.7 +packages = find: +include_package_data = True + +# Always adjust requirements.txt and pytest-min-requirements.txt when changing runtime dependencies +install_requires = + pytest >= 6.1.0 + typing-extensions >= 3.7.2; python_version < "3.8" + +[options.extras_require] +testing = + coverage >= 6.2 + hypothesis >= 5.7.1 + flaky >= 3.5.0 + mypy >= 0.931 + pytest-trio >= 0.7.0 + +[options.entry_points] +pytest11 = + asyncio = pytest_asyncio.plugin + [coverage:run] source = pytest_asyncio +branch = true [coverage:report] show_missing = true @@ -7,11 +62,9 @@ show_missing = true [tool:pytest] addopts = -rsx --tb=short testpaths = tests +asyncio_mode = auto +junit_family=xunit2 filterwarnings = error -[metadata] -# ensure LICENSE is included in wheel metadata -license_file = LICENSE - [flake8] -ignore = E203, E501, W503 +max-line-length = 88 diff --git a/setup.py b/setup.py deleted file mode 100644 index e15080fe..00000000 --- a/setup.py +++ /dev/null @@ -1,53 +0,0 @@ -import re -from pathlib import Path - -from setuptools import setup, find_packages - - -def find_version(): - version_file = ( - Path(__file__) - .parent.joinpath("pytest_asyncio", "__init__.py") - .read_text() - ) - version_match = re.search( - r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M - ) - if version_match: - return version_match.group(1) - - raise RuntimeError("Unable to find version string.") - - -setup( - name="pytest-asyncio", - version=find_version(), - packages=find_packages(), - url="https://github.com/pytest-dev/pytest-asyncio", - license="Apache 2.0", - author="Tin Tvrtković", - author_email="tinchester@gmail.com", - description="Pytest support for asyncio.", - long_description=Path(__file__).parent.joinpath("README.rst").read_text(), - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Topic :: Software Development :: Testing", - "Framework :: Pytest", - ], - python_requires=">= 3.6", - install_requires=["pytest >= 5.4.0"], - extras_require={ - "testing": [ - "coverage", - "hypothesis >= 5.7.1", - ], - }, - entry_points={"pytest11": ["asyncio = pytest_asyncio.plugin"]}, -) diff --git a/tests/async_fixtures/test_async_fixtures_with_finalizer.py b/tests/async_fixtures/test_async_fixtures_with_finalizer.py index c90a0124..2e72d5de 100644 --- a/tests/async_fixtures/test_async_fixtures_with_finalizer.py +++ b/tests/async_fixtures/test_async_fixtures_with_finalizer.py @@ -46,7 +46,8 @@ def port_finalizer(finalizer): async def port_afinalizer(): # await task using current loop retrieved from the event loop policy # RuntimeError is raised if task is created on a different loop. - # This can happen when pytest_fixture_setup does not set up the loop correctly, + # This can happen when pytest_fixture_setup + # does not set up the loop correctly, # for example when policy.set_event_loop() is called with a wrong argument await finalizer diff --git a/tests/async_fixtures/test_parametrized_loop.py b/tests/async_fixtures/test_parametrized_loop.py new file mode 100644 index 00000000..2fb8befa --- /dev/null +++ b/tests/async_fixtures/test_parametrized_loop.py @@ -0,0 +1,31 @@ +import asyncio + +import pytest + +TESTS_COUNT = 0 + + +def teardown_module(): + # parametrized 2 * 2 times: 2 for 'event_loop' and 2 for 'fix' + assert TESTS_COUNT == 4 + + +@pytest.fixture(scope="module", params=[1, 2]) +def event_loop(request): + request.param + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(params=["a", "b"]) +async def fix(request): + await asyncio.sleep(0) + return request.param + + +@pytest.mark.asyncio +async def test_parametrized_loop(fix): + await asyncio.sleep(0) + global TESTS_COUNT + TESTS_COUNT += 1 diff --git a/tests/conftest.py b/tests/conftest.py index 03fd33f2..4aa8c89a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,8 @@ import pytest +pytest_plugins = "pytester" + @pytest.fixture def dependent_fixture(event_loop): diff --git a/tests/hypothesis/test_base.py b/tests/hypothesis/test_base.py new file mode 100644 index 00000000..e6da3427 --- /dev/null +++ b/tests/hypothesis/test_base.py @@ -0,0 +1,88 @@ +"""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() + + +@given(st.integers()) +@pytest.mark.asyncio +async def test_mark_inner(n): + assert isinstance(n, int) + + +@pytest.mark.asyncio +@given(st.integers()) +async def test_mark_outer(n): + assert isinstance(n, int) + + +@pytest.mark.parametrize("y", [1, 2]) +@given(x=st.none()) +@pytest.mark.asyncio +async def test_mark_and_parametrize(x, y): + assert x is None + 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_async_auto_marked(testdir): + testdir.makepyfile( + dedent( + """\ + import asyncio + import pytest + from hypothesis import given + import hypothesis.strategies as st + + pytest_plugins = 'pytest_asyncio' + + @given(n=st.integers()) + async def test_hypothesis(n: int): + assert isinstance(n, int) + """ + ) + ) + result = testdir.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) + + +def test_sync_not_auto_marked(testdir): + """Assert that synchronous Hypothesis functions are not marked with asyncio""" + testdir.makepyfile( + dedent( + """\ + import asyncio + import pytest + from hypothesis import given + import hypothesis.strategies as st + + pytest_plugins = 'pytest_asyncio' + + @given(n=st.integers()) + def test_hypothesis(request, n: int): + markers = [marker.name for marker in request.node.own_markers] + assert "asyncio" not in markers + assert isinstance(n, int) + """ + ) + ) + result = testdir.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) diff --git a/tests/hypothesis/test_inherited_test.py b/tests/hypothesis/test_inherited_test.py new file mode 100644 index 00000000..a7762264 --- /dev/null +++ b/tests/hypothesis/test_inherited_test.py @@ -0,0 +1,20 @@ +import hypothesis.strategies as st +import pytest +from hypothesis import given + + +class BaseClass: + @pytest.mark.asyncio + @given(value=st.integers()) + async def test_hypothesis(self, value: int) -> None: + pass + + +class TestOne(BaseClass): + """During the first execution the Hypothesis test + is wrapped in a synchronous function.""" + + +class TestTwo(BaseClass): + """Execute the test a second time to ensure that + the test receives a fresh event loop.""" diff --git a/tests/sessionloop/conftest.py b/tests/loop_fixture_scope/conftest.py similarity index 62% rename from tests/sessionloop/conftest.py rename to tests/loop_fixture_scope/conftest.py index 6c657688..223160c2 100644 --- a/tests/sessionloop/conftest.py +++ b/tests/loop_fixture_scope/conftest.py @@ -3,16 +3,14 @@ import pytest -class CustomSelectorLoopSession(asyncio.SelectorEventLoop): +class CustomSelectorLoop(asyncio.SelectorEventLoop): """A subclass with no overrides, just to test for presence.""" - pass +loop = CustomSelectorLoop() -loop = CustomSelectorLoopSession() - -@pytest.fixture(scope="package") +@pytest.fixture(scope="module") def event_loop(): """Create an instance of the default event loop for each test case.""" yield loop diff --git a/tests/sessionloop/test_session_loops.py b/tests/loop_fixture_scope/test_loop_fixture_scope.py similarity index 79% rename from tests/sessionloop/test_session_loops.py rename to tests/loop_fixture_scope/test_loop_fixture_scope.py index acb67165..679ab48f 100644 --- a/tests/sessionloop/test_session_loops.py +++ b/tests/loop_fixture_scope/test_loop_fixture_scope.py @@ -1,4 +1,4 @@ -"""Unit tests for overriding the event loop with a session scoped one.""" +"""Unit tests for overriding the event loop with a larger scoped one.""" import asyncio import pytest @@ -8,7 +8,7 @@ async def test_for_custom_loop(): """This test should be executed using the custom loop.""" await asyncio.sleep(0.01) - assert type(asyncio.get_event_loop()).__name__ == "CustomSelectorLoopSession" + assert type(asyncio.get_event_loop()).__name__ == "CustomSelectorLoop" @pytest.mark.asyncio diff --git a/tests/modes/test_auto_mode.py b/tests/modes/test_auto_mode.py new file mode 100644 index 00000000..fc4d2df0 --- /dev/null +++ b/tests/modes/test_auto_mode.py @@ -0,0 +1,139 @@ +from textwrap import dedent + + +def test_auto_mode_cmdline(testdir): + testdir.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + result = testdir.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) + + +def test_auto_mode_cfg(testdir): + testdir.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + testdir.makefile(".ini", pytest="[pytest]\nasyncio_mode = auto\n") + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_auto_mode_async_fixture(testdir): + testdir.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.fixture + async def fixture_a(): + await asyncio.sleep(0) + return 1 + + async def test_a(fixture_a): + await asyncio.sleep(0) + assert fixture_a == 1 + """ + ) + ) + result = testdir.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) + + +def test_auto_mode_method_fixture(testdir): + testdir.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + + class TestA: + + @pytest.fixture + async def fixture_a(self): + await asyncio.sleep(0) + return 1 + + async def test_a(self, fixture_a): + await asyncio.sleep(0) + assert fixture_a == 1 + """ + ) + ) + result = testdir.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) + + +def test_auto_mode_static_method(testdir): + testdir.makepyfile( + dedent( + """\ + import asyncio + + pytest_plugins = 'pytest_asyncio' + + + class TestA: + + @staticmethod + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + result = testdir.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) + + +def test_auto_mode_static_method_fixture(testdir): + testdir.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + + class TestA: + + @staticmethod + @pytest.fixture + async def fixture_a(): + await asyncio.sleep(0) + return 1 + + @staticmethod + async def test_a(fixture_a): + await asyncio.sleep(0) + assert fixture_a == 1 + """ + ) + ) + result = testdir.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) diff --git a/tests/modes/test_strict_mode.py b/tests/modes/test_strict_mode.py new file mode 100644 index 00000000..3b6487c7 --- /dev/null +++ b/tests/modes/test_strict_mode.py @@ -0,0 +1,68 @@ +from textwrap import dedent + + +def test_strict_mode_cmdline(testdir): + testdir.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + result = testdir.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +def test_strict_mode_cfg(testdir): + testdir.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + testdir.makefile(".ini", pytest="[pytest]\nasyncio_mode = strict\n") + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_strict_mode_method_fixture(testdir): + testdir.makepyfile( + dedent( + """\ + import asyncio + import pytest + import pytest_asyncio + + pytest_plugins = 'pytest_asyncio' + + class TestA: + + @pytest_asyncio.fixture + async def fixture_a(self): + await asyncio.sleep(0) + return 1 + + @pytest.mark.asyncio + async def test_a(self, fixture_a): + await asyncio.sleep(0) + assert fixture_a == 1 + """ + ) + ) + result = testdir.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) diff --git a/tests/multiloop/conftest.py b/tests/multiloop/conftest.py index 9c74a509..ebcb627a 100644 --- a/tests/multiloop/conftest.py +++ b/tests/multiloop/conftest.py @@ -6,8 +6,6 @@ class CustomSelectorLoop(asyncio.SelectorEventLoop): """A subclass with no overrides, just to test for presence.""" - pass - @pytest.fixture def event_loop(): diff --git a/tests/respect_event_loop_policy/conftest.py b/tests/respect_event_loop_policy/conftest.py new file mode 100644 index 00000000..2c5cef24 --- /dev/null +++ b/tests/respect_event_loop_policy/conftest.py @@ -0,0 +1,16 @@ +"""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 new file mode 100644 index 00000000..610b3388 --- /dev/null +++ b/tests/respect_event_loop_policy/test_respects_event_loop_policy.py @@ -0,0 +1,17 @@ +"""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_asyncio_fixture.py b/tests/test_asyncio_fixture.py new file mode 100644 index 00000000..2577cba0 --- /dev/null +++ b/tests/test_asyncio_fixture.py @@ -0,0 +1,64 @@ +import asyncio +from textwrap import dedent + +import pytest + +import pytest_asyncio + + +@pytest_asyncio.fixture +async def fixture_bare(): + await asyncio.sleep(0) + return 1 + + +@pytest.mark.asyncio +async def test_bare_fixture(fixture_bare): + await asyncio.sleep(0) + assert fixture_bare == 1 + + +@pytest_asyncio.fixture(name="new_fixture_name") +async def fixture_with_name(request): + await asyncio.sleep(0) + return request.fixturename + + +@pytest.mark.asyncio +async def test_fixture_with_name(new_fixture_name): + await asyncio.sleep(0) + assert new_fixture_name == "new_fixture_name" + + +@pytest_asyncio.fixture(params=[2, 4]) +async def fixture_with_params(request): + await asyncio.sleep(0) + return request.param + + +@pytest.mark.asyncio +async def test_fixture_with_params(fixture_with_params): + await asyncio.sleep(0) + assert fixture_with_params % 2 == 0 + + +@pytest.mark.parametrize("mode", ("auto", "strict")) +def test_sync_function_uses_async_fixture(testdir, mode): + testdir.makepyfile( + dedent( + """\ + import pytest_asyncio + + pytest_plugins = 'pytest_asyncio' + + @pytest_asyncio.fixture + async def always_true(): + return True + + def test_sync_function_uses_async_fixture(always_true): + assert always_true is True + """ + ) + ) + result = testdir.runpytest(f"--asyncio-mode={mode}") + result.assert_outcomes(passed=1) diff --git a/tests/test_dependent_fixtures.py b/tests/test_dependent_fixtures.py index 2876255b..dc70fe9c 100644 --- a/tests/test_dependent_fixtures.py +++ b/tests/test_dependent_fixtures.py @@ -1,4 +1,5 @@ import asyncio + import pytest diff --git a/tests/test_event_loop_scope.py b/tests/test_event_loop_scope.py index 8ae4eb1e..21fd6415 100644 --- a/tests/test_event_loop_scope.py +++ b/tests/test_event_loop_scope.py @@ -29,3 +29,9 @@ def test_3(): 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_flaky_integration.py b/tests/test_flaky_integration.py new file mode 100644 index 00000000..54c9d2ea --- /dev/null +++ b/tests/test_flaky_integration.py @@ -0,0 +1,43 @@ +"""Tests for the Flaky integration, which retries failed tests. +""" +from textwrap import dedent + + +def test_auto_mode_cmdline(testdir): + testdir.makepyfile( + dedent( + """\ + import asyncio + import flaky + import pytest + + _threshold = -1 + + @flaky.flaky(3, 2) + @pytest.mark.asyncio + async def test_asyncio_flaky_thing_that_fails_then_succeeds(): + global _threshold + await asyncio.sleep(0.1) + _threshold += 1 + assert _threshold != 1 + """ + ) + ) + # runpytest_subprocess() is required to don't pollute the output + # with flaky restart information + result = testdir.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines( + [ + "===Flaky Test Report===", + "test_asyncio_flaky_thing_that_fails_then_succeeds passed 1 " + "out of the required 2 times. Running test again until it passes 2 times.", + "test_asyncio_flaky_thing_that_fails_then_succeeds failed " + "(1 runs remaining out of 3).", + " ", + " assert 1 != 1", + "test_asyncio_flaky_thing_that_fails_then_succeeds passed 2 " + "out of the required 2 times. Success!", + "===End Flaky Test Report===", + ] + ) diff --git a/tests/test_hypothesis_integration.py b/tests/test_hypothesis_integration.py deleted file mode 100644 index 39cb6075..00000000 --- a/tests/test_hypothesis_integration.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Tests for the Hypothesis integration, which wraps async functions in a -sync shim for Hypothesis. -""" -import asyncio - -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() - - -@given(st.integers()) -@pytest.mark.asyncio -async def test_mark_inner(n): - assert isinstance(n, int) - - -@pytest.mark.asyncio -@given(st.integers()) -async def test_mark_outer(n): - assert isinstance(n, int) - - -@pytest.mark.parametrize("y", [1, 2]) -@given(x=st.none()) -@pytest.mark.asyncio -async def test_mark_and_parametrize(x, y): - assert x is None - 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() diff --git a/tests/test_pytest_min_version_warning.py b/tests/test_pytest_min_version_warning.py new file mode 100644 index 00000000..11de6800 --- /dev/null +++ b/tests/test_pytest_min_version_warning.py @@ -0,0 +1,26 @@ +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/tests/test_simple.py b/tests/test_simple.py index 854faaf3..dc68d61e 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -1,7 +1,9 @@ """Quick'n'dirty unit tests for provided fixtures and markers.""" import asyncio +from textwrap import dedent import pytest + import pytest_asyncio.plugin @@ -25,14 +27,14 @@ async def test_asyncio_marker(): @pytest.mark.xfail(reason="need a failure", strict=True) @pytest.mark.asyncio -def test_asyncio_marker_fail(): - assert False +async def test_asyncio_marker_fail(): + raise AssertionError @pytest.mark.asyncio -def test_asyncio_marker_with_default_param(a_param=None): +async def test_asyncio_marker_with_default_param(a_param=None): """Test the asyncio pytest marker.""" - yield # sleep(0) + await asyncio.sleep(0) @pytest.mark.asyncio @@ -51,6 +53,33 @@ async def closer(_, writer): await server1.wait_closed() +@pytest.mark.asyncio +async def test_unused_udp_port_fixture(unused_udp_port, event_loop): + """Test the unused TCP port fixture.""" + + class Closer: + def connection_made(self, transport): + pass + + def connection_lost(self, *arg, **kwd): + pass + + transport1, _ = await event_loop.create_datagram_endpoint( + Closer, + local_addr=("127.0.0.1", unused_udp_port), + reuse_port=False, + ) + + with pytest.raises(IOError): + await event_loop.create_datagram_endpoint( + Closer, + local_addr=("127.0.0.1", unused_udp_port), + reuse_port=False, + ) + + transport1.abort() + + @pytest.mark.asyncio async def test_unused_port_factory_fixture(unused_tcp_port_factory, event_loop): """Test the unused TCP port factory fixture.""" @@ -80,11 +109,57 @@ async def closer(_, writer): await server3.wait_closed() +@pytest.mark.asyncio +async def test_unused_udp_port_factory_fixture(unused_udp_port_factory, event_loop): + """Test the unused UDP port factory fixture.""" + + class Closer: + def connection_made(self, transport): + pass + + def connection_lost(self, *arg, **kwd): + pass + + port1, port2, port3 = ( + unused_udp_port_factory(), + unused_udp_port_factory(), + unused_udp_port_factory(), + ) + + transport1, _ = await event_loop.create_datagram_endpoint( + Closer, + local_addr=("127.0.0.1", port1), + reuse_port=False, + ) + transport2, _ = await event_loop.create_datagram_endpoint( + Closer, + local_addr=("127.0.0.1", port2), + reuse_port=False, + ) + transport3, _ = await event_loop.create_datagram_endpoint( + Closer, + local_addr=("127.0.0.1", port3), + reuse_port=False, + ) + + for port in port1, port2, port3: + with pytest.raises(IOError): + await event_loop.create_datagram_endpoint( + Closer, + local_addr=("127.0.0.1", port), + reuse_port=False, + ) + + transport1.abort() + transport2.abort() + transport3.abort() + + def test_unused_port_factory_duplicate(unused_tcp_port_factory, monkeypatch): """Test correct avoidance of duplicate ports.""" counter = 0 - def mock_unused_tcp_port(): + def mock_unused_tcp_port(_ignored): """Force some duplicate ports.""" nonlocal counter counter += 1 @@ -93,24 +168,45 @@ def mock_unused_tcp_port(): else: return 10000 + counter - monkeypatch.setattr(pytest_asyncio.plugin, "_unused_tcp_port", mock_unused_tcp_port) + monkeypatch.setattr(pytest_asyncio.plugin, "_unused_port", mock_unused_tcp_port) assert unused_tcp_port_factory() == 10000 assert unused_tcp_port_factory() > 10000 +def test_unused_udp_port_factory_duplicate(unused_udp_port_factory, monkeypatch): + """Test correct avoidance of duplicate UDP ports.""" + counter = 0 + + def mock_unused_udp_port(_ignored): + """Force some duplicate ports.""" + nonlocal counter + counter += 1 + if counter < 5: + return 10000 + else: + return 10000 + counter + + monkeypatch.setattr(pytest_asyncio.plugin, "_unused_port", mock_unused_udp_port) + + assert unused_udp_port_factory() == 10000 + assert unused_udp_port_factory() > 10000 + + class TestMarkerInClassBasedTests: """Test that asyncio marked functions work for methods of test classes.""" @pytest.mark.asyncio async def test_asyncio_marker_with_explicit_loop_fixture(self, event_loop): - """Test the "asyncio" marker works on a method in a class-based test with explicit loop fixture.""" + """Test the "asyncio" marker works on a method in + a class-based test with explicit loop fixture.""" ret = await async_coro() assert ret == "ok" @pytest.mark.asyncio async def test_asyncio_marker_with_implicit_loop_fixture(self): - """Test the "asyncio" marker works on a method in a class-based test with implicit loop fixture.""" + """Test the "asyncio" marker works on a method in + a class-based test with implicit loop fixture.""" ret = await async_coro() assert ret == "ok" @@ -145,3 +241,35 @@ async def test_no_warning_on_skip(): def test_async_close_loop(event_loop): event_loop.close() return "ok" + + +def test_warn_asyncio_marker_for_regular_func(testdir): + testdir.makepyfile( + dedent( + """\ + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + def test_a(): + pass + """ + ) + ) + testdir.makefile( + ".ini", + pytest=dedent( + """\ + [pytest] + asyncio_mode = strict + filterwarnings = + default + """ + ), + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines( + ["*is marked with '@pytest.mark.asyncio' but it is not an async function.*"] + ) diff --git a/tests/test_subprocess.py b/tests/test_subprocess.py index 88ea29ab..79c5109d 100644 --- a/tests/test_subprocess.py +++ b/tests/test_subprocess.py @@ -1,5 +1,4 @@ """Tests for using subprocesses in tests.""" -import asyncio import asyncio.subprocess import sys @@ -16,17 +15,20 @@ def event_loop(): loop.close() -@pytest.mark.asyncio(forbid_global_loop=False) -async def test_subprocess(event_loop): - """Starting a subprocess should be possible.""" - proc = await asyncio.subprocess.create_subprocess_exec( - sys.executable, "--version", stdout=asyncio.subprocess.PIPE - ) - await proc.communicate() +@pytest.mark.skipif( + sys.version_info < (3, 8), + reason=""" + When run with Python 3.7 asyncio.subprocess.create_subprocess_exec seems to be + affected by an issue that prevents correct cleanup. Tests using pytest-trio + will report that signal handling is already performed by another library and + fail. [1] This is possibly a bug in CPython 3.7, so we ignore this test for + that Python version. - -@pytest.mark.asyncio(forbid_global_loop=True) -async def test_subprocess_forbid(event_loop): + [1] https://github.com/python-trio/pytest-trio/issues/126 + """, +) +@pytest.mark.asyncio +async def test_subprocess(event_loop): """Starting a subprocess should be possible.""" proc = await asyncio.subprocess.create_subprocess_exec( sys.executable, "--version", stdout=asyncio.subprocess.PIPE diff --git a/tests/trio/test_fixtures.py b/tests/trio/test_fixtures.py new file mode 100644 index 00000000..42b28437 --- /dev/null +++ b/tests/trio/test_fixtures.py @@ -0,0 +1,25 @@ +from textwrap import dedent + + +def test_strict_mode_ignores_trio_fixtures(testdir): + testdir.makepyfile( + dedent( + """\ + import pytest + import pytest_asyncio + import pytest_trio + + pytest_plugins = ["pytest_asyncio", "pytest_trio"] + + @pytest_trio.trio_fixture + async def any_fixture(): + return True + + @pytest.mark.trio + async def test_anything(any_fixture): + pass + """ + ) + ) + result = testdir.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) diff --git a/tools/get-version.py b/tools/get-version.py new file mode 100644 index 00000000..e988a32c --- /dev/null +++ b/tools/get-version.py @@ -0,0 +1,17 @@ +import json +import sys +from importlib import metadata + +from packaging.version import parse as parse_version + + +def main(): + version_string = metadata.version("pytest-asyncio") + version = parse_version(version_string) + print(f"::set-output name=version::{version}") + prerelease = json.dumps(version.is_prerelease) + print(f"::set-output name=prerelease::{prerelease}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tox.ini b/tox.ini index ef60cba0..1d8994ae 100644 --- a/tox.ini +++ b/tox.ini @@ -1,21 +1,37 @@ [tox] minversion = 3.14.0 -envlist = py36, py37, py38, py39, py310, lint -skip_missing_interpreters = true +envlist = py37, py38, py39, py310, py311, lint, version-info, pytest-min +isolated_build = true +passenv = + CI [testenv] extras = testing -commands = coverage run -m pytest {posargs} +deps = + --requirement dependencies/default/requirements.txt + --constraint dependencies/default/constraints.txt +commands = make test +allowlist_externals = + make + +[testenv:pytest-min] +extras = testing +deps = + --requirement dependencies/pytest-min/requirements.txt + --constraint dependencies/pytest-min/constraints.txt +commands = make test +allowlist_externals = + make [testenv:lint] -skip_install = true -basepython = python3.9 -extras = tests +basepython = python3.10 +extras = testing deps = - flake8 - black + pre-commit == 2.16.0 commands = make lint +allowlist_externals = + make [testenv:coverage-report] deps = coverage @@ -24,11 +40,17 @@ commands = coverage combine coverage report +[testenv:version-info] +deps = + packaging == 21.3 +commands = + python ./tools/get-version.py + [gh-actions] python = - 3.6: py36 - 3.7: py37 + 3.7: py37, pytest-min 3.8: py38 - 3.9: py39, lint + 3.9: py39 3.10: py310 + 3.11-dev: py311 pypy3: pypy3