diff --git a/changelog/633.feature.rst b/changelog/633.feature.rst new file mode 100644 index 00000000..de7df904 --- /dev/null +++ b/changelog/633.feature.rst @@ -0,0 +1 @@ +Add ``looponfailrootsignore`` to ignore generated files when using ``--looponfail``. diff --git a/docs/index.rst b/docs/index.rst index 78555b7e..b2f9bfb3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -35,6 +35,9 @@ Features pytest waits until a file in your project changes and then re-runs the previously failing tests. This is repeated until all tests pass after which again a full run is performed (DEPRECATED). + The ``looponfailroots`` configuration value can limit watched directories, + and ``looponfailrootsignore`` can ignore generated files using glob-style + path patterns. * :ref:`Multi-Platform` coverage: you can specify different Python interpreters or different platforms and run tests in parallel on all of them. diff --git a/src/xdist/looponfail.py b/src/xdist/looponfail.py index 7fcebe51..7ccbb5a6 100644 --- a/src/xdist/looponfail.py +++ b/src/xdist/looponfail.py @@ -35,6 +35,13 @@ def pytest_addoption(parser: pytest.Parser) -> None: help="Run tests in subprocess: wait for files to be modified, then " "re-run failing test set until all pass.", ) + group.addoption( + "--looponfailrootsignore", + action="append", + default=[], + metavar="GLOB", + help="Ignore glob-style paths when watching for --looponfail changes.", + ) @pytest.hookimpl @@ -54,7 +61,9 @@ def looponfail_main(config: pytest.Config) -> None: if not config_roots: config_roots = [Path.cwd()] rootdirs = [Path(root) for root in config_roots] - statrecorder = StatRecorder(rootdirs) + ignores = list(config.getoption("looponfailrootsignore", [])) + ignores += config.getini("looponfailrootsignore") + statrecorder = StatRecorder(rootdirs, ignores=ignores) try: while 1: remotecontrol.loop_once() @@ -248,16 +257,37 @@ def main(self) -> None: class StatRecorder: - def __init__(self, rootdirlist: Sequence[Path]) -> None: + def __init__( + self, rootdirlist: Sequence[Path], ignores: Sequence[str] = () + ) -> None: self.rootdirlist = rootdirlist + self.ignore_patterns = tuple(ignores) self.statcache: dict[Path, os.stat_result] = {} self.check() # snapshot state def fil(self, p: Path) -> bool: - return p.is_file() and not p.name.startswith(".") and p.suffix != ".pyc" + return ( + p.is_file() + and not p.name.startswith(".") + and p.suffix != ".pyc" + and not self.is_ignored(p) + ) def rec(self, p: Path) -> bool: - return not p.name.startswith(".") and p.exists() + return not p.name.startswith(".") and p.exists() and not self.is_ignored(p) + + def is_ignored(self, p: Path) -> bool: + for pattern in self.ignore_patterns: + if Path(p.name).match(pattern) or p.match(pattern): + return True + for rootdir in self.rootdirlist: + try: + relpath = p.relative_to(rootdir) + except ValueError: + continue + if relpath.match(pattern): + return True + return False def waitonchange(self, checkinterval: float = 1.0) -> None: while 1: diff --git a/src/xdist/plugin.py b/src/xdist/plugin.py index d8553ce4..31faeb05 100644 --- a/src/xdist/plugin.py +++ b/src/xdist/plugin.py @@ -93,7 +93,10 @@ def _auto_num_workers_psutil(config: pytest.Config) -> int | None: def _auto_num_workers_os_sched_getaffinity(config: pytest.Config) -> int | None: try: - from os import sched_getaffinity + if TYPE_CHECKING: + sched_getaffinity: Callable[[int], set[int]] + else: + from os import sched_getaffinity return len(sched_getaffinity(0)) except ImportError: @@ -319,6 +322,11 @@ def pytest_addoption(parser: pytest.Parser) -> None: type="paths", help="directories to check for changes. Default: current directory.", ) + parser.addini( + "looponfailrootsignore", + type="args", + help="glob-style paths to ignore when checking for looponfail changes.", + ) # ------------------------------------------------------------------------- @@ -362,9 +370,15 @@ def pytest_configure(config: pytest.Config) -> None: tr.showfspath = False # Deprecation warnings for deprecated command-line/configuration options. - if config.getoption("looponfail", None) or config.getini("looponfailroots"): + if ( + config.getoption("looponfail", None) + or config.getini("looponfailroots") + or config.getoption("looponfailrootsignore", None) + or config.getini("looponfailrootsignore") + ): warning = DeprecationWarning( - "The --looponfail command line argument and looponfailroots config variable are deprecated.\n" + "The --looponfail command line argument and looponfailroots/looponfailrootsignore " + "config variables are deprecated.\n" "The loop-on-fail feature will be removed in pytest-xdist 4.0." ) config.issue_config_time_warning(warning, 2) diff --git a/testing/test_looponfail.py b/testing/test_looponfail.py index 844736f6..4aff4c23 100644 --- a/testing/test_looponfail.py +++ b/testing/test_looponfail.py @@ -9,6 +9,7 @@ import pytest +from xdist.looponfail import looponfail_main from xdist.looponfail import RemoteControl from xdist.looponfail import StatRecorder @@ -54,6 +55,37 @@ def test_filechange(self, tmp_path: Path) -> None: changed = sd.check() assert changed + def test_filechange_ignored(self, tmp_path: Path) -> None: + tmp = tmp_path + hello = tmp / "hello.py" + generated = tmp / "generated.sqlite" + hello.touch() + generated.touch() + sd = StatRecorder([tmp], ignores=["*.sqlite"]) + assert not sd.check() + + generated.write_text("generated") + assert not sd.check() + + hello.write_text("world") + assert sd.check() + + def test_dirchange_ignored(self, tmp_path: Path) -> None: + tmp = tmp_path + generated = tmp / "generated" + generated.mkdir() + generated.joinpath("data.yaml").touch() + hello = tmp / "hello.py" + hello.touch() + sd = StatRecorder([tmp], ignores=["generated"]) + assert not sd.check() + + generated.joinpath("data.yaml").write_text("generated") + assert not sd.check() + + hello.write_text("world") + assert sd.check() + def test_dirchange(self, tmp_path: Path) -> None: tmp = tmp_path tmp.joinpath("dir").mkdir() @@ -222,6 +254,43 @@ def test_func(): assert not failures +def test_looponfail_passes_ignore_patterns( + pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch +) -> None: + pytester.makeini( + """ + [pytest] + looponfailrootsignore = *.sqlite + """ + ) + config = pytester.parseconfigure("--looponfailrootsignore=*.yaml") + ignore_patterns = [] + + class FakeRemoteControl: + failures: list[str] = [] + wasfailing = False + + def __init__(self, config: pytest.Config) -> None: + pass + + def loop_once(self) -> None: + pass + + class FakeStatRecorder: + def __init__(self, rootdirs: list[Path], ignores: list[str]) -> None: + ignore_patterns.extend(ignores) + + def waitonchange(self, checkinterval: float = 1.0) -> None: + raise KeyboardInterrupt + + monkeypatch.setattr("xdist.looponfail.RemoteControl", FakeRemoteControl) + monkeypatch.setattr("xdist.looponfail.StatRecorder", FakeStatRecorder) + + looponfail_main(config) + + assert ignore_patterns == ["*.yaml", "*.sqlite"] + + class TestLooponFailing: def test_looponfail_from_fail_to_ok(self, pytester: pytest.Pytester) -> None: modcol = pytester.getmodulecol(