Repository: pytest-dev/pytest-xdist

Issue:
Plugin raises error with `--pdb` option despite using `-n0`
```toml
# pyproject.toml
[tool.pytest.ini_options]
addopts = "--dist=load"
```

```commandline
$ pytest  --pdb -n0
ERROR: --pdb is incompatible with distributing tests; try using -n0 or -nauto.
```

As you can see,  `-n0` is already passed. However, pytest still suggests using `-n0` to address the issue.


#### Solution

`-n0` should imply `--dist=no`.


Discussion:
nicoddemus: Rebased.

GitHub does not always run the GH actions for first time contributors, IIRC there were people opening random PRs which would use the actions for mining... 👀 

Patch:
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 37a46f6..69bb823 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -39,7 +39,7 @@ jobs:
         path: dist
 
     - name: Publish package to PyPI
-      uses: pypa/gh-action-pypi-publish@v1.8.11
+      uses: pypa/gh-action-pypi-publish@v1.8.12
 
     - name: Push tag
       run: |
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 0183e83..f81a443 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
 repos:
 -   repo: https://github.com/PyCQA/autoflake
-    rev: v2.2.1
+    rev: v2.3.0
     hooks:
     -   id: autoflake
         args: ["--in-place", "--remove-unused-variables", "--remove-all-unused-imports"]
@@ -26,7 +26,7 @@ repos:
     hooks:
     -   id: flake8
 -   repo: https://github.com/asottile/pyupgrade
-    rev: v3.15.0
+    rev: v3.15.1
     hooks:
     -   id: pyupgrade
         args: [--py3-plus]
diff --git a/changelog/1028.bugfix b/changelog/1028.bugfix
new file mode 100644
index 0000000..39bc7a9
--- /dev/null
+++ b/changelog/1028.bugfix
@@ -0,0 +1,1 @@
+Fix compatiblity issue between `looponfail` and editable installs.
diff --git a/changelog/937.bugfix b/changelog/937.bugfix
new file mode 100644
index 0000000..8b868be
--- /dev/null
+++ b/changelog/937.bugfix
@@ -0,0 +1,1 @@
+Fix a bug where plugin would raise an incompatibility error with ``--pdb`` despite using ``-n0``.
diff --git a/docs/distribution.rst b/docs/distribution.rst
index 93d52f6..ebbfa08 100644
--- a/docs/distribution.rst
+++ b/docs/distribution.rst
@@ -11,11 +11,11 @@ This can lead to considerable speed ups, especially if your test suite takes a
 noticeable amount of time.
 
 With ``-n auto``, pytest-xdist will use as many processes as your computer
-has CPU cores.
+has physical CPU cores.
 
 Use ``-n logical`` to use the number of *logical* CPU cores rather than
-physical ones. This currently requires the ``psutil`` package to be installed;
-if it is not, pytest-xdist will fall back to ``-n auto`` behavior.
+physical ones. This currently requires the `psutil <https://pypi.org/project/psutil/>`__ package to be installed;
+if it is not or if it fails to determine the number of logical CPUs, fall back to ``-n auto`` behavior.
 
 Pass a number, e.g. ``-n 8``, to specify the number of processes explicitly.
 
diff --git a/src/xdist/looponfail.py b/src/xdist/looponfail.py
index ab9ff32..370cb8b 100644
--- a/src/xdist/looponfail.py
+++ b/src/xdist/looponfail.py
@@ -29,8 +29,8 @@ def pytest_addoption(parser):
         action="store_true",
         dest="looponfail",
         default=False,
-        help="run tests in subprocess, wait for modified files "
-        "and re-run failing test set until all pass.",
+        help="Run tests in subprocess: wait for files to be modified, then "
+        "re-run failing test set until all pass.",
     )
 
 
@@ -160,7 +160,8 @@ def init_worker_session(channel, args, option_dict):
     newpaths = []
     for p in sys.path:
         if p:
-            if not os.path.isabs(p):
+            # Ignore path placeholders created for editable installs
+            if not os.path.isabs(p) and not p.endswith(".__path_hook__"):
                 p = os.path.abspath(p)
             newpaths.append(p)
     sys.path[:] = newpaths
diff --git a/src/xdist/plugin.py b/src/xdist/plugin.py
index 5360b5d..c1350b4 100644
--- a/src/xdist/plugin.py
+++ b/src/xdist/plugin.py
@@ -61,6 +61,10 @@ def parse_numprocesses(s):
 
 @pytest.hookimpl
 def pytest_addoption(parser):
+    # 'Help' formatting (same rules as pytest's):
+    # Start with capitalized letters.
+    # If a single phrase, do not end with period. If more than one phrase, all phrases end with periods.
+    # Use \n to separate logical lines.
     group = parser.getgroup("xdist", "distributed and subprocess testing")
     group._addoption(
         "-n",
@@ -69,10 +73,11 @@ def pytest_addoption(parser):
         metavar="numprocesses",
         action="store",
         type=parse_numprocesses,
-        help="Shortcut for '--dist=load --tx=NUM*popen'. With 'auto', attempt "
-        "to detect physical CPU count. With 'logical', detect logical CPU "
-        "count. If physical CPU count cannot be found, falls back to logical "
-        "count. This will be 0 when used with --pdb.",
+        help="Shortcut for '--dist=load --tx=NUM*popen'.\n"
+        "With 'logical', attempt to detect logical CPU count (requires psutil, falls back to 'auto').\n"
+        "With 'auto', attempt to detect physical CPU count. If physical CPU count cannot be determined, "
+        "falls back to 1.\n"
+        "Forced to 0 (disabled) when used with --pdb.",
     )
     group.addoption(
         "--maxprocesses",
@@ -80,14 +85,15 @@ def pytest_addoption(parser):
         metavar="maxprocesses",
         action="store",
         type=int,
-        help="limit the maximum number of workers to process the tests when using --numprocesses=auto",
+        help="Limit the maximum number of workers to process the tests when using --numprocesses "
+        "with 'auto' or 'logical'",
     )
     group.addoption(
         "--max-worker-restart",
         action="store",
         default=None,
         dest="maxworkerrestart",
-        help="maximum number of workers that can be restarted "
+        help="Maximum number of workers that can be restarted "
         "when crashed (set to zero to disable this feature)",
     )
     group.addoption(
@@ -106,18 +112,18 @@ def pytest_addoption(parser):
         dest="dist",
         default="no",
         help=(
-            "set mode for distributing tests to exec environments.\n\n"
-            "each: send each test to all available environments.\n\n"
-            "load: load balance by sending any pending test to any"
+            "Set mode for distributing tests to exec environments.\n\n"
+            "each: Send each test to all available environments.\n\n"
+            "load: Load balance by sending any pending test to any"
             " available environment.\n\n"
-            "loadscope: load balance by sending pending groups of tests in"
+            "loadscope: Load balance by sending pending groups of tests in"
             " the same scope to any available environment.\n\n"
-            "loadfile: load balance by sending test grouped by file"
+            "loadfile: Load balance by sending test grouped by file"
             " to any available environment.\n\n"
-            "loadgroup: like load, but sends tests marked with 'xdist_group' to the same worker.\n\n"
-            "worksteal: split the test suite between available environments,"
-            " then rebalance when any worker runs out of tests.\n\n"
-            "(default) no: run tests inprocess, don't distribute."
+            "loadgroup: Like 'load', but sends tests marked with 'xdist_group' to the same worker.\n\n"
+            "worksteal: Split the test suite between available environments,"
+            " then re-balance when any worker runs out of tests.\n\n"
+            "(default) no: Run tests inprocess, don't distribute."
         ),
     )
     group.addoption(
@@ -127,8 +133,8 @@ def pytest_addoption(parser):
         default=[],
         metavar="xspec",
         help=(
-            "add a test execution environment. some examples: "
-            "--tx popen//python=python2.5 --tx socket=192.168.1.102:8888 "
+            "Add a test execution environment. Some examples:\n"
+            "--tx popen//python=python2.5 --tx socket=192.168.1.102:8888\n"
             "--tx ssh=user@codespeak.net//chdir=testcache"
         ),
     )
@@ -137,29 +143,29 @@ def pytest_addoption(parser):
         action="store_true",
         dest="distload",
         default=False,
-        help="load-balance tests.  shortcut for '--dist=load'",
+        help="Load-balance tests. Shortcut for '--dist=load'.",
     )
     group.addoption(
         "--rsyncdir",
         action="append",
         default=[],
         metavar="DIR",
-        help="add directory for rsyncing to remote tx nodes.",
+        help="Add directory for rsyncing to remote tx nodes",
     )
     group.addoption(
         "--rsyncignore",
         action="append",
         default=[],
         metavar="GLOB",
-        help="add expression for ignores when rsyncing to remote tx nodes.",
+        help="Add expression for ignores when rsyncing to remote tx nodes",
     )
     group.addoption(
         "--testrunuid",
         action="store",
         help=(
-            "provide an identifier shared amongst all workers as the value of "
-            "the 'testrun_uid' fixture,\n\n,"
-            "if not provided, 'testrun_uid' is filled with a new unique string "
+            "Provide an identifier shared amongst all workers as the value of "
+            "the 'testrun_uid' fixture.\n"
+            "If not provided, 'testrun_uid' is filled with a new unique string "
             "on every test run."
         ),
     )
@@ -168,13 +174,13 @@ def pytest_addoption(parser):
         action="store",
         type=int,
         help=(
-            "Maximum number of tests scheduled in one step for --dist=load. "
+            "Maximum number of tests scheduled in one step for --dist=load.\n"
             "Setting it to 1 will force pytest to send tests to workers one by "
-            "one - might be useful for a small number of slow tests. "
+            "one - might be useful for a small number of slow tests.\n"
             "Larger numbers will allow the scheduler to submit consecutive "
-            "chunks of tests to workers - allows reusing fixtures. "
+            "chunks of tests to workers - allows reusing fixtures.\n"
             "Due to implementation reasons, at least 2 tests are scheduled per "
-            "worker at the start. Only later tests can be scheduled one by one. "
+            "worker at the start. Only later tests can be scheduled one by one.\n"
             "Unlimited if not set."
         ),
     )
@@ -227,7 +233,7 @@ def pytest_configure(config):
 
     # Create the distributed session in case we have a valid distribution
     # mode and test environments.
-    if config.getoption("dist") != "no" and config.getoption("tx"):
+    if _is_distribution_mode(config):
         from xdist.dsession import DSession
 
         session = DSession(config)
@@ -252,8 +258,19 @@ def pytest_configure(config):
         config.issue_config_time_warning(warning, 2)
 
 
+def _is_distribution_mode(config):
+    """Return `True` if distribution mode is on, `False` otherwise.
+
+    :param config: the `pytest` `config` object
+    """
+    return config.getoption("dist") != "no" and config.getoption("tx")
+
+
 @pytest.hookimpl(tryfirst=True)
 def pytest_cmdline_main(config):
+    if config.option.distload:
+        config.option.dist = "load"
+
     usepdb = config.getoption("usepdb", False)  # a core option
     if config.option.numprocesses in ("auto", "logical"):
         if usepdb:
@@ -270,10 +287,13 @@ def pytest_cmdline_main(config):
         if config.option.maxprocesses:
             numprocesses = min(numprocesses, config.option.maxprocesses)
         config.option.tx = ["popen"] * numprocesses
-    if config.option.distload:
-        config.option.dist = "load"
+
+    if config.option.numprocesses == 0:
+        config.option.dist = "no"
+        config.option.tx = []
+
     val = config.getvalue
-    if not val("collectonly") and val("dist") != "no" and usepdb:
+    if not val("collectonly") and _is_distribution_mode(config) and usepdb:
         raise pytest.UsageError(
             "--pdb is incompatible with distributing tests; try using -n0 or -nauto."
         )  # noqa: E501


Test patch:
diff --git a/testing/test_looponfail.py b/testing/test_looponfail.py
index 92d4384..65a89fb 100644
--- a/testing/test_looponfail.py
+++ b/testing/test_looponfail.py
@@ -1,3 +1,5 @@
+import pathlib
+import tempfile
 import unittest.mock
 from typing import List
 
@@ -191,6 +193,43 @@ class TestRemoteControl:
         control.loop_once()
         assert control.failures
 
+    def test_ignore_sys_path_hook_entry(
+        self, pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        # Modifying sys.path as seen by the worker process is a bit tricky,
+        # because any changes made in the current process do not carry over.
+        # However, we can leverage the `sitecustomize` behavior to run arbitrary
+        # code when the subprocess interpreter is starting up. We just need to
+        # install our module in the search path, which we can accomplish by
+        # adding a temporary directory to PYTHONPATH.
+        tmpdir = tempfile.TemporaryDirectory()
+        with open(pathlib.Path(tmpdir.name) / "sitecustomize.py", "w") as custom:
+            print(
+                textwrap.dedent(
+                    """
+                    import sys
+                    sys.path.append('dummy.__path_hook__')
+                    """
+                ),
+                file=custom,
+            )
+
+        monkeypatch.setenv("PYTHONPATH", tmpdir.name, prepend=":")
+
+        item = pytester.getitem(
+            textwrap.dedent(
+                """
+                def test_func():
+                    import sys
+                    assert "dummy.__path_hook__" in sys.path
+                """
+            )
+        )
+        control = RemoteControl(item.config)
+        control.setup()
+        topdir, failures = control.runsession()[:2]
+        assert not failures
+
 
 class TestLooponFailing:
     def test_looponfail_from_fail_to_ok(self, pytester: pytest.Pytester) -> None:
diff --git a/testing/test_plugin.py b/testing/test_plugin.py
index f42f3cc..4bf514b 100644
--- a/testing/test_plugin.py
+++ b/testing/test_plugin.py
@@ -48,6 +48,23 @@ def test_dist_options(pytester: pytest.Pytester) -> None:
     check_options(config)
     assert config.option.dist == "load"
 
+    config = pytester.parseconfigure("--numprocesses", "0")
+    check_options(config)
+    assert config.option.dist == "no"
+    assert config.option.tx == []
+
+    config = pytester.parseconfigure("--numprocesses", "0", "-d")
+    check_options(config)
+    assert config.option.dist == "no"
+    assert config.option.tx == []
+
+    config = pytester.parseconfigure(
+        "--numprocesses", "0", "--dist", "each", "--tx", "2*popen"
+    )
+    check_options(config)
+    assert config.option.dist == "no"
+    assert config.option.tx == []
+
 
 def test_auto_detect_cpus(
     pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch
@@ -77,17 +94,12 @@ def test_auto_detect_cpus(
     check_options(config)
     assert config.getoption("numprocesses") == 99
 
-    config = pytester.parseconfigure("-nauto", "--pdb")
-    check_options(config)
-    assert config.getoption("usepdb")
-    assert config.getoption("numprocesses") == 0
-    assert config.getoption("dist") == "no"
-
-    config = pytester.parseconfigure("-nlogical", "--pdb")
-    check_options(config)
-    assert config.getoption("usepdb")
-    assert config.getoption("numprocesses") == 0
-    assert config.getoption("dist") == "no"
+    for numprocesses in (0, "auto", "logical"):
+        config = pytester.parseconfigure(f"-n{numprocesses}", "--pdb")
+        check_options(config)
+        assert config.getoption("usepdb")
+        assert config.getoption("numprocesses") == 0
+        assert config.getoption("dist") == "no"
 
     monkeypatch.delattr(os, "sched_getaffinity", raising=False)
     monkeypatch.setenv("TRAVIS", "true")