From e4b4fb02b91e0f5a60a9678604653aecedff513b Mon Sep 17 00:00:00 2001 From: =?utf8?q?=C5=81ukasz=20Langa?= Date: Fri, 7 May 2021 15:03:13 +0200 Subject: [PATCH] Use optional tests for "no_python2" to simplify local testing (#2203) --- pyproject.toml | 5 +- tests/conftest.py | 1 + tests/optional.py | 119 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_black.py | 10 ++-- tox.ini | 4 +- 5 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/optional.py diff --git a/pyproject.toml b/pyproject.toml index ca75f8f..e89cc7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,4 +27,7 @@ requires = ["setuptools>=41.0", "setuptools-scm", "wheel"] build-backend = "setuptools.build_meta" [tool.pytest.ini_options] -markers = ['python2', "without_python2"] \ No newline at end of file +# Option below requires `tests/optional.py` +optional-tests = [ + "no_python2: run when `python2` extra NOT installed", +] \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6751726 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +pytest_plugins = ["tests.optional"] diff --git a/tests/optional.py b/tests/optional.py new file mode 100644 index 0000000..e12b94c --- /dev/null +++ b/tests/optional.py @@ -0,0 +1,119 @@ +""" +Allows configuring optional test markers in config, see pyproject.toml. + +Run optional tests with `pytest --run-optional=...`. + +Mark tests to run only if an optional test ISN'T selected by prepending the mark with +"no_". + +You can specify a "no_" prefix straight in config, in which case you can mark tests +to run when this tests ISN'T selected by omitting the "no_" prefix. + +Specifying the name of the default behavior in `--run-optional=` is harmless. + +Adapted from https://pypi.org/project/pytest-optional-tests/, (c) 2019 Reece Hart +""" + +from functools import lru_cache +import itertools +import logging +import re +from typing import FrozenSet, List, Set, TYPE_CHECKING + +import pytest +from _pytest.store import StoreKey + +log = logging.getLogger(__name__) + + +if TYPE_CHECKING: + from _pytest.config.argparsing import Parser + from _pytest.config import Config + from _pytest.mark.structures import MarkDecorator + from _pytest.nodes import Node + + +ALL_POSSIBLE_OPTIONAL_MARKERS = StoreKey[FrozenSet[str]]() +ENABLED_OPTIONAL_MARKERS = StoreKey[FrozenSet[str]]() + + +def pytest_addoption(parser: "Parser") -> None: + group = parser.getgroup("collect") + group.addoption( + "--run-optional", + action="append", + dest="run_optional", + default=None, + help="Optional test markers to run; comma-separated", + ) + parser.addini("optional-tests", "List of optional tests markers", "linelist") + + +def pytest_configure(config: "Config") -> None: + """Optional tests are markers. + + Use the syntax in https://docs.pytest.org/en/stable/mark.html#registering-marks. + """ + ot_ini = config.inicfg.get("optional-tests") or [] + ot_markers = set() + ot_run: Set[str] = set() + if isinstance(ot_ini, str): + ot_ini = ot_ini.strip().split("\n") + marker_re = re.compile(r"^\s*(?Pno_)?(?P\w+)(:\s*(?P.*))?") + for ot in ot_ini: + m = marker_re.match(ot) + if not m: + raise ValueError(f"{ot!r} doesn't match pytest marker syntax") + + marker = (m.group("no") or "") + m.group("marker") + description = m.group("description") + config.addinivalue_line("markers", f"{marker}: {description}") + config.addinivalue_line( + "markers", f"{no(marker)}: run when `{marker}` not passed" + ) + ot_markers.add(marker) + + # collect requested optional tests + passed_args = config.getoption("run_optional") + if passed_args: + ot_run.update(itertools.chain.from_iterable(a.split(",") for a in passed_args)) + ot_run |= {no(excluded) for excluded in ot_markers - ot_run} + ot_markers |= {no(m) for m in ot_markers} + + log.info("optional tests to run:", ot_run) + unknown_tests = ot_run - ot_markers + if unknown_tests: + raise ValueError(f"Unknown optional tests wanted: {unknown_tests!r}") + + store = config._store + store[ALL_POSSIBLE_OPTIONAL_MARKERS] = frozenset(ot_markers) + store[ENABLED_OPTIONAL_MARKERS] = frozenset(ot_run) + + +def pytest_collection_modifyitems(config: "Config", items: "List[Node]") -> None: + store = config._store + all_possible_optional_markers = store[ALL_POSSIBLE_OPTIONAL_MARKERS] + enabled_optional_markers = store[ENABLED_OPTIONAL_MARKERS] + + for item in items: + all_markers_on_test = set(m.name for m in item.iter_markers()) + optional_markers_on_test = all_markers_on_test & all_possible_optional_markers + if not optional_markers_on_test or ( + optional_markers_on_test & enabled_optional_markers + ): + continue + log.info("skipping non-requested optional", item) + item.add_marker(skip_mark(frozenset(optional_markers_on_test))) + + +@lru_cache() +def skip_mark(tests: FrozenSet[str]) -> "MarkDecorator": + names = ", ".join(sorted(tests)) + return pytest.mark.skip(reason=f"Marked with disabled optional tests ({names})") + + +@lru_cache() +def no(name: str) -> str: + if name.startswith("no_"): + return name[len("no_") :] + return "no_" + name diff --git a/tests/test_black.py b/tests/test_black.py index 9b2bfcd..b8e526a 100644 --- a/tests/test_black.py +++ b/tests/test_black.py @@ -460,11 +460,15 @@ class BlackTestCase(BlackBaseTestCase): ) self.assertEqual(expected, actual, msg) - @pytest.mark.without_python2 + @pytest.mark.no_python2 def test_python2_should_fail_without_optional_install(self) -> None: - # python 3.7 and below will install typed-ast and will be able to parse Python 2 if sys.version_info < (3, 8): - return + self.skipTest( + "Python 3.6 and 3.7 will install typed-ast to work and as such will be" + " able to parse Python 2 syntax without explicitly specifying the" + " python2 extra" + ) + source = "x = 1234l" tmp_file = Path(black.dump_to_file(source)) try: diff --git a/tox.ini b/tox.ini index cbb0f75..317bf48 100644 --- a/tox.ini +++ b/tox.ini @@ -9,9 +9,9 @@ deps = commands = pip install -e .[d] coverage erase - coverage run -m pytest tests -m "not python2" {posargs} + coverage run -m pytest tests --run-optional=no_python2 {posargs} pip install -e .[d,python2] - coverage run -m pytest tests -m "not without_python2" {posargs} + coverage run -m pytest tests --run-optional=python2 {posargs} coverage report [testenv:fuzz] -- 2.39.5