From ad1b157d8c4a81df5db92b95ae0a8892911d30f2 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 25 Oct 2024 11:48:21 +0300 Subject: [PATCH 01/13] Recurse into build / dist directories See #12625 for context --- changelog/12625.improvement.rst | 2 ++ src/_pytest/main.py | 22 ++++++++++++++++++++-- testing/test_collection.py | 25 +++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 changelog/12625.improvement.rst diff --git a/changelog/12625.improvement.rst b/changelog/12625.improvement.rst new file mode 100644 index 00000000000..7823a71297e --- /dev/null +++ b/changelog/12625.improvement.rst @@ -0,0 +1,2 @@ +Conditionally ignore collection of setuptools artifacts dirnames only if the +directories reside inside a setuptools project, i.e. `setup.cfg`, is present, etc. diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 26b0db74ff9..bee5489682e 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -62,9 +62,7 @@ def pytest_addoption(parser: Parser) -> None: "*.egg", ".*", "_darcs", - "build", "CVS", - "dist", "node_modules", "venv", "{arch}", @@ -371,6 +369,22 @@ def pytest_runtestloop(session: Session) -> bool: return True +def _in_build(path: Path) -> bool: + """Attempt to 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 + + if any(fnmatch_ex(pat, path) for pat in ("build", "dist")): + indicators = ("setup.py", "setup.cfg", "pyproject.toml") + if any((path.parent / f).is_file() for f in indicators): + 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 +436,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 = False # 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..c5d9cee054c 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,29 @@ 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_buildsystem_env = pytest.mark.parametrize( + "buildsystem_indicator_file", ["setup.py", "setup.cfg", "pyproject.toml"] + ) + + @known_build_dirs + @known_buildsystem_env + def test_build_dirs_collected( + self, pytester: Pytester, build_dir: str, buildsystem_indicator_file: 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 / buildsystem_indicator_file) + + result = pytester.runpytest("--collect-only").stdout.str() + assert "test_module" not in result + class TestCollectPluginHookRelay: def test_pytest_collect_file(self, pytester: Pytester) -> None: From b45dc40fb10cdedbfe0e5a857c46eb2f1ba6e2be Mon Sep 17 00:00:00 2001 From: David Peled Date: Fri, 1 Nov 2024 20:45:27 +0200 Subject: [PATCH 02/13] Add test for setup.cfg indicator only if setup.py --- testing/test_collection.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/testing/test_collection.py b/testing/test_collection.py index c5d9cee054c..eaad472155e 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -297,6 +297,26 @@ def test_build_dirs_collected( result = pytester.runpytest("--collect-only").stdout.str() assert "test_module" not in result + @known_build_dirs + def test_build_dirs_collected_when_setuptools_configuration_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") + assert "test_module" not in result + class TestCollectPluginHookRelay: def test_pytest_collect_file(self, pytester: Pytester) -> None: From c296f37a1e80438fabf996f1d884ae23ff318b6a Mon Sep 17 00:00:00 2001 From: David Peled Date: Sun, 10 Nov 2024 14:26:06 +0200 Subject: [PATCH 03/13] fix comment --- src/_pytest/main.py | 35 ++++++++++++++++++++++++++++++++--- testing/test_collection.py | 22 +++++++++++++--------- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index bee5489682e..d188d7a22c5 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -16,6 +16,9 @@ import os from pathlib import Path import sys +from typing import AbstractSet +from typing import Any +from typing import Callable from typing import final from typing import Literal from typing import overload @@ -369,6 +372,20 @@ def pytest_runtestloop(session: Session) -> bool: return True +def _decode_toml_file(toml: Path) -> dict[str, Any] | None: + """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") + return tomllib.loads(toml_text) + except tomllib.TOMLDecodeError: + return None + + def _in_build(path: Path) -> bool: """Attempt to detect if ``path`` is the root of a buildsystem's artifacts by checking known dirnames patterns, and the presence of configuration in @@ -378,9 +395,21 @@ def _in_build(path: Path) -> bool: return False if any(fnmatch_ex(pat, path) for pat in ("build", "dist")): - indicators = ("setup.py", "setup.cfg", "pyproject.toml") - if any((path.parent / f).is_file() for f in indicators): - return True + setup_cfg = path.parent / "setup.cfg" + if (setup_cfg).is_file(): + setup_py = path.parent / "setup.py" + if setup_py.is_file(): + return True + + pyproject_toml = path.parent / "pyproject.toml" + if pyproject_toml.is_file(): + config = _decode_toml_file(pyproject_toml) + if config: + if any( + "setuptools" in cfg + for cfg in config.get("build-system", {}).get("requires", {}) + ): + return True return False diff --git a/testing/test_collection.py b/testing/test_collection.py index eaad472155e..cdeef067fc9 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -275,14 +275,10 @@ def test_missing_permissions_on_unselected_directory_doesnt_crash( result.assert_outcomes(passed=1) known_build_dirs = pytest.mark.parametrize("build_dir", ["build", "dist"]) - known_buildsystem_env = pytest.mark.parametrize( - "buildsystem_indicator_file", ["setup.py", "setup.cfg", "pyproject.toml"] - ) @known_build_dirs - @known_buildsystem_env - def test_build_dirs_collected( - self, pytester: Pytester, build_dir: str, buildsystem_indicator_file: str + def test_build_dirs_collected_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( @@ -292,13 +288,17 @@ def test_build_dirs_collected( result = pytester.runpytest("--collect-only").stdout.str() assert "test_module" in result - ensure_file(tmp_path / buildsystem_indicator_file) + 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 @known_build_dirs - def test_build_dirs_collected_when_setuptools_configuration_present( + def test_build_dirs_collected_when_setuptools_present_in_pyproject_toml( self, pytester: Pytester, build_dir: str ) -> None: tmp_path = pytester.path @@ -314,7 +314,11 @@ def test_build_dirs_collected_when_setuptools_configuration_present( result = pytester.runpytest("--collect-only").stdout.str() assert "test_module" in result - ensure_file(tmp_path / "setup.py") + 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 From db9be298b223db04301cb63c06326929a32d8533 Mon Sep 17 00:00:00 2001 From: David Peled Date: Sun, 10 Nov 2024 14:51:34 +0200 Subject: [PATCH 04/13] simplify --- src/_pytest/main.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index d188d7a22c5..5cba9caa4fe 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -17,7 +17,6 @@ from pathlib import Path import sys from typing import AbstractSet -from typing import Any from typing import Callable from typing import final from typing import Literal @@ -372,7 +371,7 @@ def pytest_runtestloop(session: Session) -> bool: return True -def _decode_toml_file(toml: Path) -> dict[str, Any] | None: +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 @@ -381,9 +380,14 @@ def _decode_toml_file(toml: Path) -> dict[str, Any] | None: try: toml_text = toml.read_text(encoding="utf-8") - return tomllib.loads(toml_text) - except tomllib.TOMLDecodeError: - return None + parsed_toml = tomllib.loads(toml_text) + build_system = parsed_toml.get("build-system", {}).get("requires") + if "setuptools" in build_system: + return True + except Exception: + pass + + return False def _in_build(path: Path) -> bool: @@ -401,15 +405,9 @@ def _in_build(path: Path) -> bool: if setup_py.is_file(): return True - pyproject_toml = path.parent / "pyproject.toml" - if pyproject_toml.is_file(): - config = _decode_toml_file(pyproject_toml) - if config: - if any( - "setuptools" in cfg - for cfg in config.get("build-system", {}).get("requires", {}) - ): - return True + toml = path.parent / "pyproject.toml" + if toml.is_file() and _is_setuptools_in_pyproject_toml(toml): + return True return False From 9f312aab1f9d363d1ce2b5acb8297e944a1d3792 Mon Sep 17 00:00:00 2001 From: David Peled Date: Sat, 1 Feb 2025 09:44:40 +0200 Subject: [PATCH 05/13] Add description that norecursedirs ignores build --- src/_pytest/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 5cba9caa4fe..01357834f3e 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -58,7 +58,7 @@ 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", From 22e4e95ca91d78fc7fde7155c13f308251227f14 Mon Sep 17 00:00:00 2001 From: David Peled Date: Sat, 1 Feb 2025 09:47:07 +0200 Subject: [PATCH 06/13] Fix docstring --- src/_pytest/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 01357834f3e..ad4adedbf19 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -391,7 +391,7 @@ def _is_setuptools_in_pyproject_toml(toml: Path) -> bool: def _in_build(path: Path) -> bool: - """Attempt to detect if ``path`` is the root of a buildsystem's artifacts + """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. """ From 961bb4225a82066887fc410017dc3738ba360096 Mon Sep 17 00:00:00 2001 From: David Peled Date: Sat, 1 Feb 2025 10:06:39 +0200 Subject: [PATCH 07/13] Use specific toml decode error --- src/_pytest/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index ad4adedbf19..551abe55816 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -384,7 +384,8 @@ def _is_setuptools_in_pyproject_toml(toml: Path) -> bool: build_system = parsed_toml.get("build-system", {}).get("requires") if "setuptools" in build_system: return True - except Exception: + except tomllib.TOMLDecodeError: + # Not a valid Toml, so no setuptools. pass return False From 44d31b60d9c433677b2631f0d97c475348aecb1d Mon Sep 17 00:00:00 2001 From: David Peled Date: Sat, 1 Feb 2025 10:07:10 +0200 Subject: [PATCH 08/13] Bail if no setup.cfg present --- src/_pytest/main.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 551abe55816..83ede3f7638 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -399,16 +399,18 @@ def _in_build(path: Path) -> bool: 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_cfg = path.parent / "setup.cfg" - if (setup_cfg).is_file(): - 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 + 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 From 77c630ea06b82162535ada89f4e5379af980db43 Mon Sep 17 00:00:00 2001 From: David Peled Date: Sat, 1 Feb 2025 10:10:16 +0200 Subject: [PATCH 09/13] Add collect-in-build option --- src/_pytest/main.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 83ede3f7638..9d8ca8073b5 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -218,6 +218,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", @@ -466,7 +473,7 @@ 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 = False # config.getoption("collect_in_build") + allow_in_build = config.getoption("collect_in_build") if not allow_in_build and _in_build(collection_path): return True From 9c61dd31dcd7ce12e773ca2cb07dabd596cce803 Mon Sep 17 00:00:00 2001 From: David Peled Date: Sat, 1 Feb 2025 10:25:16 +0200 Subject: [PATCH 10/13] Fix lint in tests --- testing/test_collection.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/testing/test_collection.py b/testing/test_collection.py index cdeef067fc9..d94d2ef3eb4 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -277,7 +277,7 @@ def test_missing_permissions_on_unselected_directory_doesnt_crash( known_build_dirs = pytest.mark.parametrize("build_dir", ["build", "dist"]) @known_build_dirs - def test_build_dirs_collected_when_setuptools_setup_py_present( + def test_build_dirs_ignored_when_setuptools_setup_py_present( self, pytester: Pytester, build_dir: str ) -> None: tmp_path = pytester.path @@ -297,8 +297,11 @@ def test_build_dirs_collected_when_setuptools_setup_py_present( 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_collected_when_setuptools_present_in_pyproject_toml( + def test_build_dirs_ignored_when_setuptools_present_in_pyproject_toml( self, pytester: Pytester, build_dir: str ) -> None: tmp_path = pytester.path @@ -321,6 +324,9 @@ def test_build_dirs_collected_when_setuptools_present_in_pyproject_toml( 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: From 22d887907022006bd0f0d4b44f340d8f45c9e2aa Mon Sep 17 00:00:00 2001 From: David Peled Date: Sat, 1 Feb 2025 10:33:15 +0200 Subject: [PATCH 11/13] Add changelog --- changelog/12625.improvement.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog/12625.improvement.rst b/changelog/12625.improvement.rst index 7823a71297e..a69d65f407e 100644 --- a/changelog/12625.improvement.rst +++ b/changelog/12625.improvement.rst @@ -1,2 +1,4 @@ Conditionally ignore collection of setuptools artifacts dirnames only if the -directories reside inside a setuptools project, i.e. `setup.cfg`, is present, etc. +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. From d89d30fda578793f802f0c2bb795420d7baccd6e Mon Sep 17 00:00:00 2001 From: David Peled Date: Sat, 1 Feb 2025 10:40:51 +0200 Subject: [PATCH 12/13] Add docs --- doc/en/reference/reference.rst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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. From 8dee4e9b41506831502789e5daf63981b4f90e53 Mon Sep 17 00:00:00 2001 From: David Peled Date: Sat, 1 Feb 2025 10:50:42 +0200 Subject: [PATCH 13/13] fix --- src/_pytest/main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 9d8ca8073b5..25ed325a6ba 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -16,8 +16,6 @@ import os from pathlib import Path import sys -from typing import AbstractSet -from typing import Callable from typing import final from typing import Literal from typing import overload