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..c99eadff --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +--- +version: 2 +updates: + - package-ecosystem: pip + directory: / + schedule: + interval: daily + 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..1a0c9031 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,67 +2,131 @@ name: CI on: - push: - branches: ["master"] - pull_request: - branches: ["master"] - workflow_dispatch: + push: + branches: [master] + tags: [v*] + pull_request: + branches: [master] + workflow_dispatch: jobs: - tests: - name: "Python ${{ matrix.python-version }}" - runs-on: "ubuntu-latest" - env: - USING_COVERAGE: "3.6,3.7,3.8,3.9,3.10" + lint: + name: Run linters + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + prerelease: ${{ steps.version.outputs.prerelease }} + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - uses: actions/setup-python@v2 + with: + python-version: '3.9' + - 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@v2 + with: + name: dist + path: dist - strategy: - matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10-dev"] + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + env: + USING_COVERAGE: 3.7,3.8,3.9,3.10 - 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" + strategy: + matrix: + python-version: ['3.7', '3.8', '3.9', '3.10'] - # 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" + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - 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 + 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 + - name: Prepare coverage artifact + if: ${{ contains(env.USING_COVERAGE, matrix.python-version) }} + uses: aio-libs/prepare-coverage@v21.9.1 - package: - name: "Build & verify package" - runs-on: "ubuntu-latest" + check: + name: Check + if: always() + needs: [lint, test] + runs-on: ubuntu-latest + steps: + - 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 - steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v2" - with: - python-version: "3.9" - - - 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@v2.4.0 + with: + fetch-depth: 0 + - name: Download distributions + uses: actions/download-artifact@v2 + 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.4.2 + 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..a085f108 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,66 @@ +--- +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.0 + hooks: + - id: yamlfmt + - 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/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/Makefile b/Makefile index 8cf88841..0817a0e7 100644 --- a/Makefile +++ b/Makefile @@ -20,9 +20,19 @@ 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 + pre-commit run --all-files --show-diff-on-failure +else + pre-commit run --all-files +endif 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..c63f72d2 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,6 +54,70 @@ To install pytest-asyncio, simply: This is enough for pytest to pick up pytest-asyncio. +Modes +----- + +Starting from ``pytest-asyncio>=0.17``, three modes are provided: *auto*, *strict* and +*legacy* (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 overriden 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; plase 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. + + +Legacy mode +~~~~~~~~~~~ + +This mode follows rules used by ``pytest-asyncio<0.17``: tests are not auto-marked but +fixtures are. + +This mode is used by default for the sake of backward compatibility, deprecation +warnings are emitted with suggestion to either switching to ``auto`` mode or using +``strict`` mode with ``@pytest_asyncio.fixture`` decorators. + +In future, the default will be changed. + + Fixtures -------- @@ -72,9 +139,9 @@ 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 @@ -109,18 +176,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 +205,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* and *legacy* mode automatically converts async fixtures declared with the +standard ``@pytest.fixture`` decorator to *asyncio-driven* versions. + Markers ------- @@ -139,7 +218,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,20 +229,43 @@ 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) +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 +Note about unittest +------------------- + +Test classes subclassing the standard `unittest `__ library are not supported, users +are recommended to use `unitest.IsolatedAsyncioTestCase `__ +or an async framework such as `asynctest `__. + Changelog --------- +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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..189ffa1d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[build-system] +requires = [ + "setuptools>=51.0", + "wheel>=0.36", + "setuptools_scm>=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..49157de5 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -1,22 +1,87 @@ """pytest-asyncio implementation.""" import asyncio import contextlib +import enum import functools import inspect import socket +import warnings import pytest -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 +class Mode(str, enum.Enum): + AUTO = "auto" + STRICT = "strict" + LEGACY = "legacy" + + +LEGACY_MODE = DeprecationWarning( + "The 'asyncio_mode' default value will change to 'strict' in future, " + "please explicitly use 'asyncio_mode=strict' or 'asyncio_mode=auto' " + "in pytest configuration file." +) + +LEGACY_ASYNCIO_FIXTURE = ( + "'@pytest.fixture' is applied to {name} " + "in 'legacy' mode, " + "please replace it with '@pytest_asyncio.fixture' as a preparation " + "for switching to 'strict' mode (or use 'auto' mode to seamlessly handle " + "all these fixtures as asyncio-driven)." +) + + +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) +'legacy' - for keeping compatibility with pytest-asyncio<0.17: \ +auto-handling is disabled but pytest_asyncio.fixture usage is not enforced +""" + + +def pytest_addoption(parser, pluginmanager): + 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", + type="string", + default="legacy", + ) + + +def fixture(fixture_function=None, **kwargs): + if fixture_function is not None: + _set_explicit_asyncio_mark(fixture_function) + return pytest.fixture(fixture_function, **kwargs) + + else: + + @functools.wraps(fixture) + def inner(fixture_function): + return fixture(fixture_function, **kwargs) + return inner -from inspect import isasyncgenfunction + +def _has_explicit_asyncio_mark(obj): + obj = getattr(obj, "__func__", obj) # instance method maybe? + return getattr(obj, "_force_asyncio_fixture", False) + + +def _set_explicit_asyncio_mark(obj): + if hasattr(obj, "__func__"): + # instance method, check the function object + obj = obj.__func__ + obj._force_asyncio_fixture = True def _is_coroutine(obj): @@ -24,6 +89,17 @@ def _is_coroutine(obj): return asyncio.iscoroutinefunction(obj) or inspect.isgeneratorfunction(obj) +def _is_coroutine_or_asyncgen(obj): + return _is_coroutine(obj) or inspect.isasyncgenfunction(obj) + + +def _get_asyncio_mode(config): + val = config.getoption("asyncio_mode") + if val is None: + val = config.getini("asyncio_mode") + return Mode(val) + + def pytest_configure(config): """Inject documentation.""" config.addinivalue_line( @@ -32,6 +108,8 @@ def pytest_configure(config): "mark the test as a coroutine, it will be " "run using an asyncio event loop", ) + if _get_asyncio_mode(config) == Mode.LEGACY: + config.issue_config_time_warning(LEGACY_MODE, stacklevel=2) @pytest.mark.tryfirst @@ -39,15 +117,15 @@ def pytest_pycollect_makeitem(collector, name, obj): """A pytest hook to collect asyncio coroutines.""" if collector.funcnamefilter(name) and _is_coroutine(obj): item = pytest.Function.from_parent(collector, name=name) - - # 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)) + else: + if _get_asyncio_mode(item.config) == Mode.AUTO: + # implicitly add asyncio marker if asyncio mode is on + ret = list(collector._genfunctions(name, obj)) + for elem in ret: + elem.add_marker("asyncio") + return ret class FixtureStripper: @@ -80,8 +158,12 @@ def get_and_strip_from(self, name, data_dict): def pytest_fixture_post_finalizer(fixturedef, request): """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() + # Clean up existing loop to avoid ResourceWarnings + policy.get_event_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) @@ -101,9 +183,42 @@ def pytest_fixture_setup(fixturedef, request): policy.set_event_loop(loop) return - if isasyncgenfunction(fixturedef.func): + func = fixturedef.func + if not _is_coroutine_or_asyncgen(func): + # Nothing to do with a regular fixture function + yield + return + + config = request.node.config + asyncio_mode = _get_asyncio_mode(config) + + if not _has_explicit_asyncio_mark(func): + if asyncio_mode == Mode.AUTO: + # Enforce asyncio mode if 'auto' + _set_explicit_asyncio_mark(func) + elif asyncio_mode == Mode.LEGACY: + _set_explicit_asyncio_mark(func) + try: + code = func.__code__ + except AttributeError: + code = func.__func__.__code__ + name = ( + f"" + ) + warnings.warn( + LEGACY_ASYNCIO_FIXTURE.format(name=name), + DeprecationWarning, + ) + else: + # asyncio_mode is STRICT, + # don't handle fixtures that are not explicitly marked + yield + return + + if inspect.isasyncgenfunction(func): # This is an async generator function. Wrap it accordingly. - generator = fixturedef.func + generator = func fixture_stripper = FixtureStripper(fixturedef) fixture_stripper.add(FixtureStripper.EVENT_LOOP) @@ -138,12 +253,13 @@ async def async_finalizer(): loop.run_until_complete(async_finalizer()) + result = loop.run_until_complete(setup()) request.addfinalizer(finalizer) - return loop.run_until_complete(setup()) + return result fixturedef.func = wrapper - elif inspect.iscoroutinefunction(fixturedef.func): - coro = fixturedef.func + elif inspect.iscoroutinefunction(func): + coro = func fixture_stripper = FixtureStripper(fixturedef) fixture_stripper.add(FixtureStripper.EVENT_LOOP) @@ -166,8 +282,10 @@ async def setup(): @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_pyfunc_call(pyfuncitem): """ - 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): @@ -186,6 +304,12 @@ def wrap_in_sync(func, _loop): """Return a sync wrapper around an async function executing it in the current event loop.""" + # if the function is already wrapped, we rewrap using the original one + # not using __wrapped__ because the original function may already be + # a wrapped one + if hasattr(func, "_raw_test_func"): + func = func._raw_test_func + @functools.wraps(func) def inner(**kwargs): coro = func(**kwargs) @@ -201,6 +325,7 @@ def inner(**kwargs): task.exception() raise + inner._raw_test_func = func return inner @@ -229,16 +354,21 @@ def event_loop(request): 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): + """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() + return _unused_port(socket.SOCK_STREAM) + + +@pytest.fixture +def unused_udp_port(): + return _unused_port(socket.SOCK_DGRAM) @pytest.fixture(scope="session") @@ -248,10 +378,29 @@ def unused_tcp_port_factory(): 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(): + """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/setup.cfg b/setup.cfg index 01610865..7be86f36 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,57 @@ +[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 + + Topic :: Software Development :: Testing + + Framework :: AsyncIO + Framework :: Pytest + +[options] +python_requires = >=3.7 +packages = find: +include_package_data = True + +setup_requires = + setuptools_scm >= 6.2 + +install_requires = + pytest >= 5.4.0 + +[options.extras_require] +testing = + coverage==6.2 + hypothesis >= 5.7.1 + flaky >= 3.5.0 + +[options.entry_points] +pytest11 = + asyncio = pytest_asyncio.plugin + [coverage:run] source = pytest_asyncio +branch = true [coverage:report] show_missing = true @@ -7,11 +59,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 index e15080fe..7f1a1763 100644 --- a/setup.py +++ b/setup.py @@ -1,53 +1,4 @@ -import re -from pathlib import Path +from setuptools import setup -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"]}, -) +if __name__ == "__main__": + setup() 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/test_hypothesis_integration.py b/tests/hypothesis/test_base.py similarity index 99% rename from tests/test_hypothesis_integration.py rename to tests/hypothesis/test_base.py index 39cb6075..e9273d0e 100644 --- a/tests/test_hypothesis_integration.py +++ b/tests/hypothesis/test_base.py @@ -4,7 +4,6 @@ import asyncio import pytest - from hypothesis import given, strategies as st 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/modes/test_auto_mode.py b/tests/modes/test_auto_mode.py new file mode 100644 index 00000000..980b0b04 --- /dev/null +++ b/tests/modes/test_auto_mode.py @@ -0,0 +1,91 @@ +from textwrap import dedent + +pytest_plugins = "pytester" + + +def test_auto_mode_cmdline(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) + + +def test_auto_mode_cfg(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + pytester.makefile(".ini", pytest="[pytest]\nasyncio_mode = auto\n") + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_auto_mode_async_fixture(pytester): + pytester.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 = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) + + +def test_auto_mode_method_fixture(pytester): + pytester.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 = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) diff --git a/tests/modes/test_legacy_mode.py b/tests/modes/test_legacy_mode.py new file mode 100644 index 00000000..df9c2cb6 --- /dev/null +++ b/tests/modes/test_legacy_mode.py @@ -0,0 +1,115 @@ +from textwrap import dedent + +pytest_plugins = "pytester" + + +LEGACY_MODE = ( + "The 'asyncio_mode' default value will change to 'strict' in future, " + "please explicitly use 'asyncio_mode=strict' or 'asyncio_mode=auto' " + "in pytest configuration file." +) + +LEGACY_ASYNCIO_FIXTURE = ( + "'@pytest.fixture' is applied to {name} " + "in 'legacy' mode, " + "please replace it with '@pytest_asyncio.fixture' as a preparation " + "for switching to 'strict' mode (or use 'auto' mode to seamlessly handle " + "all these fixtures as asyncio-driven)." +).format(name="*") + + +def test_warning_for_legacy_mode_cmdline(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=legacy") + assert result.parseoutcomes()["warnings"] == 1 + result.stdout.fnmatch_lines(["*" + LEGACY_MODE + "*"]) + + +def test_warning_for_legacy_mode_cfg(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + pytester.makefile(".ini", pytest="[pytest]\nasyncio_mode = legacy\n") + result = pytester.runpytest() + assert result.parseoutcomes()["warnings"] == 1 + result.stdout.fnmatch_lines(["*" + LEGACY_MODE + "*"]) + result.stdout.no_fnmatch_line("*" + LEGACY_ASYNCIO_FIXTURE + "*") + + +def test_warning_for_legacy_fixture(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.fixture + async def fixture_a(): + await asyncio.sleep(0) + return 1 + + @pytest.mark.asyncio + async def test_a(fixture_a): + await asyncio.sleep(0) + assert fixture_a == 1 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=legacy") + assert result.parseoutcomes()["warnings"] == 2 + result.stdout.fnmatch_lines(["*" + LEGACY_ASYNCIO_FIXTURE + "*"]) + + +def test_warning_for_legacy_method_fixture(pytester): + pytester.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 + + @pytest.mark.asyncio + async def test_a(self, fixture_a): + await asyncio.sleep(0) + assert fixture_a == 1 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=legacy") + assert result.parseoutcomes()["warnings"] == 2 + result.stdout.fnmatch_lines(["*" + LEGACY_ASYNCIO_FIXTURE + "*"]) diff --git a/tests/modes/test_strict_mode.py b/tests/modes/test_strict_mode.py new file mode 100644 index 00000000..7b574012 --- /dev/null +++ b/tests/modes/test_strict_mode.py @@ -0,0 +1,70 @@ +from textwrap import dedent + +pytest_plugins = "pytester" + + +def test_strict_mode_cmdline(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +def test_strict_mode_cfg(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + pytester.makefile(".ini", pytest="[pytest]\nasyncio_mode = strict\n") + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_strict_mode_method_fixture(pytester): + pytester.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 = pytester.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/sessionloop/conftest.py b/tests/sessionloop/conftest.py index 6c657688..bb6c1d6c 100644 --- a/tests/sessionloop/conftest.py +++ b/tests/sessionloop/conftest.py @@ -6,8 +6,6 @@ class CustomSelectorLoopSession(asyncio.SelectorEventLoop): """A subclass with no overrides, just to test for presence.""" - pass - loop = CustomSelectorLoopSession() diff --git a/tests/test_asyncio_fixture.py b/tests/test_asyncio_fixture.py new file mode 100644 index 00000000..cfe10479 --- /dev/null +++ b/tests/test_asyncio_fixture.py @@ -0,0 +1,41 @@ +import asyncio + +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 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_flaky_integration.py b/tests/test_flaky_integration.py new file mode 100644 index 00000000..2e551aad --- /dev/null +++ b/tests/test_flaky_integration.py @@ -0,0 +1,47 @@ +"""Tests for the Flaky integration, which retries failed tests. +""" + + +from textwrap import dedent + +pytest_plugins = "pytester" + + +def test_auto_mode_cmdline(pytester): + pytester.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 = pytester.runpytest_subprocess() + 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_simple.py b/tests/test_simple.py index 854faaf3..31204b6c 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -2,6 +2,7 @@ import asyncio import pytest + import pytest_asyncio.plugin @@ -26,7 +27,7 @@ async def test_asyncio_marker(): @pytest.mark.xfail(reason="need a failure", strict=True) @pytest.mark.asyncio def test_asyncio_marker_fail(): - assert False + raise AssertionError @pytest.mark.asyncio @@ -51,6 +52,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 +108,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 +167,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" diff --git a/tests/test_subprocess.py b/tests/test_subprocess.py index 88ea29ab..311d67d5 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 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..0092b03e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,21 +1,25 @@ [tox] minversion = 3.14.0 -envlist = py36, py37, py38, py39, py310, lint +envlist = py37, py38, py39, py310, lint, version-info skip_missing_interpreters = true +passenv = + CI [testenv] extras = testing -commands = coverage run -m pytest {posargs} +commands = make test +allowlist_externals = + make [testenv:lint] skip_install = true basepython = python3.9 -extras = tests deps = - flake8 - black + pre-commit == 2.16.0 commands = make lint +allowlist_externals = + make [testenv:coverage-report] deps = coverage @@ -24,9 +28,15 @@ commands = coverage combine coverage report +[testenv:version-info] +basepython = python3.9 +deps = + packaging == 21.3 +commands = + python ./tools/get-version.py + [gh-actions] python = - 3.6: py36 3.7: py37 3.8: py38 3.9: py39, lint