diff --git a/changelog/12625.improvement.rst b/changelog/12625.improvement.rst new file mode 100644 index 00000000000..a69d65f407e --- /dev/null +++ b/changelog/12625.improvement.rst @@ -0,0 +1,4 @@ +Conditionally ignore collection of setuptools artifacts dirnames only if the +directories reside inside a setuptools project, i.e. `setup.cfg` is present alongside +a `setup.py` or a `pyproject.toml` with setuptools configuration inside. +You can use the new `--collect-in-build` to force collection of ignored directories. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index 809e97b4747..a126f0c94e7 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -1754,7 +1754,7 @@ passed multiple times. The expected format is ``name=value``. For example:: [seq] matches any character in seq [!seq] matches any char not in seq - Default patterns are ``'*.egg'``, ``'.*'``, ``'_darcs'``, ``'build'``, + Default patterns are ``'*.egg'``, ``'.*'``, ``'_darcs'``, ``'CVS'``, ``'dist'``, ``'node_modules'``, ``'venv'``, ``'{arch}'``. Setting a ``norecursedirs`` replaces the default. Here is an example of how to avoid certain directories: @@ -1776,6 +1776,9 @@ passed multiple times. The expected format is ``name=value``. For example:: *must* override ``norecursedirs`` in addition to using the ``--collect-in-virtualenv`` flag. + Similarly, pytest will attempt to intelligently identify and igmore build + artifacts of a setuptools project unless ``--collect-in-build`` is used. + .. confval:: python_classes @@ -2163,6 +2166,8 @@ All the command-line flags can be obtained by running ``pytest --help``:: --keep-duplicates Keep duplicate tests --collect-in-virtualenv Don't ignore tests in a local virtualenv directory + --collect-in-build + Don't ignore tests in a local build directory --import-mode={prepend,append,importlib} Prepend/append to sys.path when importing test modules and conftest files. Default: prepend. diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 26b0db74ff9..25ed325a6ba 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -56,15 +56,13 @@ def pytest_addoption(parser: Parser) -> None: parser.addini( "norecursedirs", - "Directory patterns to avoid for recursion", + "Directory patterns to avoid collecting (defaults to ignore build artifacts)", type="args", default=[ "*.egg", ".*", "_darcs", - "build", "CVS", - "dist", "node_modules", "venv", "{arch}", @@ -218,6 +216,13 @@ def pytest_addoption(parser: Parser) -> None: default=False, help="Don't ignore tests in a local virtualenv directory", ) + group.addoption( + "--collect-in-build", + action="store_true", + dest="collect_in_build", + default=False, + help="Don't ignore builds in a local build/dist artifacts directory", + ) group.addoption( "--import-mode", default="prepend", @@ -371,6 +376,50 @@ def pytest_runtestloop(session: Session) -> bool: return True +def _is_setuptools_in_pyproject_toml(toml: Path) -> bool: + """Attempt to decode a toml file into a dict, returning None if fails.""" + if sys.version_info >= (3, 11): + import tomllib + else: + import tomli as tomllib + + try: + toml_text = toml.read_text(encoding="utf-8") + parsed_toml = tomllib.loads(toml_text) + build_system = parsed_toml.get("build-system", {}).get("requires") + if "setuptools" in build_system: + return True + except tomllib.TOMLDecodeError: + # Not a valid Toml, so no setuptools. + pass + + return False + + +def _in_build(path: Path) -> bool: + """Detect if ``path`` is the root of a buildsystem's artifacts + by checking known dirnames patterns, and the presence of configuration in + the parent dir by checking for a setup.py, setup.cfg, or pyproject.toml. + """ + if not path.is_dir(): + return False + + setup_cfg = path.parent / "setup.cfg" + if not setup_cfg.is_file(): + return False + + if any(fnmatch_ex(pat, path) for pat in ("build", "dist")): + setup_py = path.parent / "setup.py" + if setup_py.is_file(): + return True + + toml = path.parent / "pyproject.toml" + if toml.is_file() and _is_setuptools_in_pyproject_toml(toml): + return True + + return False + + def _in_venv(path: Path) -> bool: """Attempt to detect if ``path`` is the root of a Virtual Environment by checking for the existence of the pyvenv.cfg file. @@ -422,6 +471,10 @@ def pytest_ignore_collect(collection_path: Path, config: Config) -> bool | None: if not allow_in_venv and _in_venv(collection_path): return True + allow_in_build = config.getoption("collect_in_build") + if not allow_in_build and _in_build(collection_path): + return True + if collection_path.is_dir(): norecursepatterns = config.getini("norecursedirs") if any(fnmatch_ex(pat, collection_path) for pat in norecursepatterns): diff --git a/testing/test_collection.py b/testing/test_collection.py index ccd57eeef43..d94d2ef3eb4 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -137,8 +137,6 @@ def test_foo(): class TestCollectFS: def test_ignored_certain_directories(self, pytester: Pytester) -> None: tmp_path = pytester.path - ensure_file(tmp_path / "build" / "test_notfound.py") - ensure_file(tmp_path / "dist" / "test_notfound.py") ensure_file(tmp_path / "_darcs" / "test_notfound.py") ensure_file(tmp_path / "CVS" / "test_notfound.py") ensure_file(tmp_path / "{arch}" / "test_notfound.py") @@ -276,6 +274,59 @@ def test_missing_permissions_on_unselected_directory_doesnt_crash( assert result.ret == ExitCode.OK result.assert_outcomes(passed=1) + known_build_dirs = pytest.mark.parametrize("build_dir", ["build", "dist"]) + + @known_build_dirs + def test_build_dirs_ignored_when_setuptools_setup_py_present( + self, pytester: Pytester, build_dir: str + ) -> None: + tmp_path = pytester.path + ensure_file(tmp_path / build_dir / "test_module.py").write_text( + "def test_hello(): pass", encoding="utf-8" + ) + + result = pytester.runpytest("--collect-only").stdout.str() + assert "test_module" in result + + ensure_file(tmp_path / "setup.cfg") + + result = pytester.runpytest("--collect-only").stdout.str() + assert "test_module" in result + + ensure_file(tmp_path / "setup.py") + result = pytester.runpytest("--collect-only").stdout.str() + assert "test_module" not in result + + result = pytester.runpytest("--collect-only", "--collect-in-build").stdout.str() + assert "test_module" in result + + @known_build_dirs + def test_build_dirs_ignored_when_setuptools_present_in_pyproject_toml( + self, pytester: Pytester, build_dir: str + ) -> None: + tmp_path = pytester.path + ensure_file(tmp_path / build_dir / "test_module.py").write_text( + "def test_hello(): pass", encoding="utf-8" + ) + + result = pytester.runpytest("--collect-only").stdout.str() + assert "test_module" in result + + ensure_file(tmp_path / "setup.cfg") + + result = pytester.runpytest("--collect-only").stdout.str() + assert "test_module" in result + + ensure_file(tmp_path / "pyproject.toml").write_text( + '[build-system]\nrequires = ["setuptools", "setuptools-scm"]\n', + encoding="utf-8", + ) + result = pytester.runpytest("--collect-only").stdout.str() + assert "test_module" not in result + + result = pytester.runpytest("--collect-only", "--collect-in-build").stdout.str() + assert "test_module" in result + class TestCollectPluginHookRelay: def test_pytest_collect_file(self, pytester: Pytester) -> None: