Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/633.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add ``looponfailrootsignore`` to ignore generated files when using ``--looponfail``.
3 changes: 3 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 34 additions & 4 deletions src/xdist/looponfail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 17 additions & 3 deletions src/xdist/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.",
)


# -------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
Expand Down
69 changes: 69 additions & 0 deletions testing/test_looponfail.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import pytest

from xdist.looponfail import looponfail_main
from xdist.looponfail import RemoteControl
from xdist.looponfail import StatRecorder

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
Loading