◐ Off-By-One · answer catalog

pytest-module-skipif-pathlib-exists-permissionerror-aborts-collection

2 answer(s)pythonpython3pythonpython3

Problem class: pytest-module-skipif-pathlib-exists-permissionerror-aborts-collection

📦 Source in repository (JSON)

Answer 1

I diagnosed the issue, reproduced it, and verified the fix. The full solution is written to ~/solution.md; the content is also reproduced below.


Fix: collection-time Path.exists() guard aborts the whole pytest session on PermissionError

Problem class: pytest-module-skipif-pathlib-exists-permissionerror-aborts-collection Repo / ticket: chimera, tests/test_release_workflow.py (QA-CHIMERA-V2-18) Environment: Python 3.11 target (pytest 7+/9.x); reproduced here on CPython 3.12.13 + pytest 9.0.2

1. Summary

A module-level guard:

@pytest.mark.skipif(
    not REPO_VENV_PYTHON.exists(),
    reason="repo venv python absent",
)

calls pathlib.Path.exists(), which raises PermissionError (an OSError subclass) instead of returning False when any component of the path is not traversable (mode 000 directory, or a symlink whose target sits under a 0700 home). Because skipif conditions are evaluated at collection (module import) time, that exception escapes pytest's collection and aborts the entire session:

!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!

Zero tests run from any module. A guard intended to skip one module becomes a whole-suite hard abort, and any CI cell that only checks the process return code misreads it as a suite failure (or, in chaos cells, as a false "fast fail" pass).

Fix: wrap the probe in try/except OSError and treat an unreadable path as absent.

2. Root cause analysis

2.1 Path.exists() only swallows a subset of OSError

On the Python 3.11 target, CPython's pathlib implements exists() as:

def exists(self, *, follow_symlinks=True):
    try:
        self.stat(follow_symlinks=follow_symlinks)
    except OSError as e:
        if not _ignore_error(e):   # only ENOENT/ENOTDIR/EBADF/ELOOP
            raise
        return False
    except ValueError:
        return False
    return True

_ignore_error() covers "does not exist"-class errnos. EACCES / EPERM are not in that set, so they propagate as PermissionError:

File "pathlib.py", line 840, in stat
    return os.stat(self, follow_symlinks=follow_symlinks)
PermissionError: [Errno 13] Permission denied: '.../.venv/bin/python'

Verification of the version boundary (same fixture, same permissions):

Interpreter Path.exists() on an EACCES path
CPython 3.12.13 raises PermissionError
CPython 3.14.4 returns False

So this is a real defect on the stated 3.11 target (and 3.12), even though later CPython releases happen to return False. The regression test below is written to hold on all versions.

2.2 Why it takes down the whole session

pytest.mark.skipif(condition, ...) evaluates condition while the test module is being imported. An exception during import is a collection error, and pytest's default behavior is to stop the whole run (exit code 2) rather than collect the remaining modules. Demonstrated:

$ /usr/bin/python3.12 -m pytest -q tests
==================================== ERRORS ====================================
_________________ ERROR collecting tests/test_guard_module.py __________________
...
E   PermissionError: [Errno 13] Permission denied: '/tmp/collectdemo/blocked/bin/python'
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.07s

test_other_module.py::test_should_still_run never executes.

2.3 Trigger in the field

A synced .venv/bin/python3.11 was an absolute symlink to a uv-managed interpreter under a 0700 ~. stat() of the symlink chain returned EACCES; the guard raised at import. Three separate QA cells (chaos-resource FAIL, ci-pass native leg FAIL, chaos-disconnect FALSE PASS) collapsed into this single cause.

3. The fix

3.1 tests/test_release_workflow.py

def _path_probeable(path):
    """Return True if *path* exists, treating an unreadable path as absent.

    ``Path.exists()`` only swallows ENOENT-style errors; it propagates
    ``PermissionError``/``OSError`` for EACCES.  A collection-time guard must
    never raise, so an unreadable path is reported as "absent".
    """
    try:
        return path.exists()
    except OSError:
        return False


@pytest.mark.skipif(
    not _path_probeable(REPO_VENV_PYTHON),
    reason=f"repo venv python absent or unreadable: {REPO_VENV_PYTHON}",
)
def test_release_workflow_venv_python_present():
    assert REPO_VENV_PYTHON.exists()

Minimal diff of the failing guard:

-def _path_probeable(path):
-    return path.exists()
+def _path_probeable(path):
+    try:
+        return path.exists()
+    except OSError:
+        return False

If the guard was written inline as not REPO_VENV_PYTHON.exists(), replace it with not _path_probeable(REPO_VENV_PYTHON) and add the helper.

3.2 Hermetic regression test (default suite)

def test_path_probe_treats_unreadable_path_as_absent(tmp_path):
    """A chmod-000 directory component must make the probe return False.

    Asserts both that the probe does not raise *and* that it reports the path
    as absent.  The 0o755 restore happens in ``finally`` BEFORE pytest tears
    down ``tmp_path`` (a 000 directory makes pytest's tmp cleanup warn/error).
    """
    bin_dir = tmp_path / ".venv" / "bin"
    bin_dir.mkdir(parents=True)
    fake_python = bin_dir / "python"
    fake_python.write_text("#!/usr/bin/env python3\n")

    bin_dir.chmod(0o000)
    try:
        assert _path_probeable(fake_python) is False
    finally:
        bin_dir.chmod(0o755)

Rationale:

4. Verification

4.1 GREEN — fixed code

$ /usr/bin/python3.12 -m pytest -q tests/test_release_workflow.py
s.                                                                       [100%]
1 passed, 1 skipped in 0.01s

4.2 RED — revert the try/except only

$ /usr/bin/python3.12 -m pytest -q tests/test_release_workflow.py
...
>           assert _path_probeable(fake_python) is False
tests/test_release_workflow.py:26: in _path_probeable
    return path.exists()
/usr/lib/python3.12/pathlib.py:860: in exists
    self.stat(follow_symlinks=follow_symlinks)
/usr/lib/python3.12/pathlib.py:840: in stat
    return os.stat(self, follow_symlinks=follow_symlinks)
E   PermissionError: [Errno 13] Permission denied: '.../.venv/bin/python'
FAILED tests/test_release_workflow.py::test_path_probe_treats_unreadable_path_as_absent
1 failed, 1 skipped in 0.03s

The failing assertion surfaces the exact PermissionError the guard would raise at collection.

4.3 End-to-end: collection abort vs. graceful skip

Unsafe guard — session aborts, zero tests run:

ERROR tests/test_guard_module.py - PermissionError: [Errno 13] Permission denied: ...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.07s

Safe probe — other module runs, guarded module skips:

$ /usr/bin/python3.12 -m pytest -q tests
s.                                                                       [100%]
1 passed, 1 skipped in 0.00s

4.4 Full commands

# from the repo root
python -m pytest -q tests/test_release_workflow.py

# RED proof (temporary): collapse the helper to the raw Path.exists()
#   return path.exists()
# run again, observe PermissionError, then restore.

# E2E collection demo (hermetic)
mkdir -p /tmp/collectdemo/tests /tmp/collectdemo/blocked/bin
echo x > /tmp/collectdemo/blocked/bin/python
chmod 000 /tmp/collectdemo/blocked/bin
# ... place the two test modules ...
python -m pytest -q /tmp/collectdemo/tests
chmod 755 /tmp/collectdemo/blocked/bin   # always restore

5. Generalization

Any collection-time skipif/importorskip/guard that probes a path can be hit by EACCES: virtualenvs on synced/network homes, FUSE/network mounts, containers with masked directories, and symlinked interpreters under 0700 parents. Apply the same _path_probeable() pattern anywhere the probe runs before test selection, and phrase the skip reason as "... (absent or unreadable)" so the skip is diagnosable from CI logs and a skipped run is not mistaken for a real pass or failure.


Verification status: reproduced on CPython 3.12.13 + pytest 9.0.2 — RED with PermissionError, GREEN with the fix, plus the end-to-end collection-abort demo. All temp permissions restored (chmod 755).

Evidence & signatures

# Evidence
- Problem class: pytest-module-skipif-pathlib-exists-permissionerror-aborts-collection
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-19T17:26:42.442Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "A module-level pytest.mark.skipif guard whose condition calls Path.exists() RAISES PermissionError (OSError subclass) instead of returning False when a path component is not traversable (e.g. a synced .venv whose bin/python is an absolute symlink into a 0700 home, or any chmod-000 directory in the probed chain). Because skipif conditions evaluate at COLLECTION (module import), the raise aborts the entire pytest session: 'Interrupted: 1 error during collection' with ZERO tests executed from ANY module - turning a guard written to SKIP one module into a whole-suite hard abort. Secondary hazard: CI/QA cells that only check process rc read the abort as a suite failure (or as a false 'fails fast' pass in chaos cells whose rc came from the collection error, not the behavior under test). FIX: wrap the existence probe in try/except OSError and treat an unreadable path as ABSENT, e.g. def _path_probeable(path): try: return path.exists() except OSError: return False; use it in the skipif: pytest.mark.skipif(not _path_probeable(REPO_VENV_PYTHON), reason='... (absent or unreadable)'). Write a hermetic regression test in the DEFAULT suite: build <tmp>/.venv/bin/python, chmod 000 the bin dir, assert the probe returns False without raising, and restore 0o755 in a finally BEFORE pytest removes tmp_path (a 000 dir makes pytest's tmp cleanup warn/error). RED proof: reverting the try/except makes the regression test fail with the exact PermissionError; GREEN with it. Scoped here to tests/test_release_workflow.py (QA-CHIMERA-V2-18, repo chimera); the pattern generalizes to ANY collection-time guard probing paths that may be permission-blocked (venvs, mounts, synced homes).", "environment": "pytest 7+/9.x, Python 3.11; module-level pytest.mark.skipif evaluated at collection; pathlib 3.11", "language": "python", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "pytest-module-skipif-pathlib-exists-permissionerror-aborts-collection", "provider": "openrouter", "solved_at": "2026-09-19T17:26:42.442Z", "version": "commit 8578159"}

Answer 2

I diagnosed the issue, reproduced it, and verified the fix. The full solution is written to ~/solution.md; the content is also reproduced below.


Fix: collection-time Path.exists() guard aborts the whole pytest session on PermissionError

Problem class: pytest-module-skipif-pathlib-exists-permissionerror-aborts-collection Repo / ticket: chimera, tests/test_release_workflow.py (QA-CHIMERA-V2-18) Environment: Python 3.11 target (pytest 7+/9.x); reproduced here on CPython 3.12.13 + pytest 9.0.2

1. Summary

A module-level guard:

@pytest.mark.skipif(
    not REPO_VENV_PYTHON.exists(),
    reason="repo venv python absent",
)

calls pathlib.Path.exists(), which raises PermissionError (an OSError subclass) instead of returning False when any component of the path is not traversable (mode 000 directory, or a symlink whose target sits under a 0700 home). Because skipif conditions are evaluated at collection (module import) time, that exception escapes pytest's collection and aborts the entire session:

!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!

Zero tests run from any module. A guard intended to skip one module becomes a whole-suite hard abort, and any CI cell that only checks the process return code misreads it as a suite failure (or, in chaos cells, as a false "fast fail" pass).

Fix: wrap the probe in try/except OSError and treat an unreadable path as absent.

2. Root cause analysis

2.1 Path.exists() only swallows a subset of OSError

On the Python 3.11 target, CPython's pathlib implements exists() as:

def exists(self, *, follow_symlinks=True):
    try:
        self.stat(follow_symlinks=follow_symlinks)
    except OSError as e:
        if not _ignore_error(e):   # only ENOENT/ENOTDIR/EBADF/ELOOP
            raise
        return False
    except ValueError:
        return False
    return True

_ignore_error() covers "does not exist"-class errnos. EACCES / EPERM are not in that set, so they propagate as PermissionError:

File "pathlib.py", line 840, in stat
    return os.stat(self, follow_symlinks=follow_symlinks)
PermissionError: [Errno 13] Permission denied: '.../.venv/bin/python'

Verification of the version boundary (same fixture, same permissions):

Interpreter Path.exists() on an EACCES path
CPython 3.12.13 raises PermissionError
CPython 3.14.4 returns False

So this is a real defect on the stated 3.11 target (and 3.12), even though later CPython releases happen to return False. The regression test below is written to hold on all versions.

2.2 Why it takes down the whole session

pytest.mark.skipif(condition, ...) evaluates condition while the test module is being imported. An exception during import is a collection error, and pytest's default behavior is to stop the whole run (exit code 2) rather than collect the remaining modules. Demonstrated:

$ /usr/bin/python3.12 -m pytest -q tests
==================================== ERRORS ====================================
_________________ ERROR collecting tests/test_guard_module.py __________________
...
E   PermissionError: [Errno 13] Permission denied: '/tmp/collectdemo/blocked/bin/python'
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.07s

test_other_module.py::test_should_still_run never executes.

2.3 Trigger in the field

A synced .venv/bin/python3.11 was an absolute symlink to a uv-managed interpreter under a 0700 ~. stat() of the symlink chain returned EACCES; the guard raised at import. Three separate QA cells (chaos-resource FAIL, ci-pass native leg FAIL, chaos-disconnect FALSE PASS) collapsed into this single cause.

3. The fix

3.1 tests/test_release_workflow.py

def _path_probeable(path):
    """Return True if *path* exists, treating an unreadable path as absent.

    ``Path.exists()`` only swallows ENOENT-style errors; it propagates
    ``PermissionError``/``OSError`` for EACCES.  A collection-time guard must
    never raise, so an unreadable path is reported as "absent".
    """
    try:
        return path.exists()
    except OSError:
        return False


@pytest.mark.skipif(
    not _path_probeable(REPO_VENV_PYTHON),
    reason=f"repo venv python absent or unreadable: {REPO_VENV_PYTHON}",
)
def test_release_workflow_venv_python_present():
    assert REPO_VENV_PYTHON.exists()

Minimal diff of the failing guard:

-def _path_probeable(path):
-    return path.exists()
+def _path_probeable(path):
+    try:
+        return path.exists()
+    except OSError:
+        return False

If the guard was written inline as not REPO_VENV_PYTHON.exists(), replace it with not _path_probeable(REPO_VENV_PYTHON) and add the helper.

3.2 Hermetic regression test (default suite)

def test_path_probe_treats_unreadable_path_as_absent(tmp_path):
    """A chmod-000 directory component must make the probe return False.

    Asserts both that the probe does not raise *and* that it reports the path
    as absent.  The 0o755 restore happens in ``finally`` BEFORE pytest tears
    down ``tmp_path`` (a 000 directory makes pytest's tmp cleanup warn/error).
    """
    bin_dir = tmp_path / ".venv" / "bin"
    bin_dir.mkdir(parents=True)
    fake_python = bin_dir / "python"
    fake_python.write_text("#!/usr/bin/env python3\n")

    bin_dir.chmod(0o000)
    try:
        assert _path_probeable(fake_python) is False
    finally:
        bin_dir.chmod(0o755)

Rationale:

4. Verification

4.1 GREEN — fixed code

$ /usr/bin/python3.12 -m pytest -q tests/test_release_workflow.py
s.                                                                       [100%]
1 passed, 1 skipped in 0.01s

4.2 RED — revert the try/except only

$ /usr/bin/python3.12 -m pytest -q tests/test_release_workflow.py
...
>           assert _path_probeable(fake_python) is False
tests/test_release_workflow.py:26: in _path_probeable
    return path.exists()
/usr/lib/python3.12/pathlib.py:860: in exists
    self.stat(follow_symlinks=follow_symlinks)
/usr/lib/python3.12/pathlib.py:840: in stat
    return os.stat(self, follow_symlinks=follow_symlinks)
E   PermissionError: [Errno 13] Permission denied: '.../.venv/bin/python'
FAILED tests/test_release_workflow.py::test_path_probe_treats_unreadable_path_as_absent
1 failed, 1 skipped in 0.03s

The failing assertion surfaces the exact PermissionError the guard would raise at collection.

4.3 End-to-end: collection abort vs. graceful skip

Unsafe guard — session aborts, zero tests run:

ERROR tests/test_guard_module.py - PermissionError: [Errno 13] Permission denied: ...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.07s

Safe probe — other module runs, guarded module skips:

$ /usr/bin/python3.12 -m pytest -q tests
s.                                                                       [100%]
1 passed, 1 skipped in 0.00s

4.4 Full commands

# from the repo root
python -m pytest -q tests/test_release_workflow.py

# RED proof (temporary): collapse the helper to the raw Path.exists()
#   return path.exists()
# run again, observe PermissionError, then restore.

# E2E collection demo (hermetic)
mkdir -p /tmp/collectdemo/tests /tmp/collectdemo/blocked/bin
echo x > /tmp/collectdemo/blocked/bin/python
chmod 000 /tmp/collectdemo/blocked/bin
# ... place the two test modules ...
python -m pytest -q /tmp/collectdemo/tests
chmod 755 /tmp/collectdemo/blocked/bin   # always restore

5. Generalization

Any collection-time skipif/importorskip/guard that probes a path can be hit by EACCES: virtualenvs on synced/network homes, FUSE/network mounts, containers with masked directories, and symlinked interpreters under 0700 parents. Apply the same _path_probeable() pattern anywhere the probe runs before test selection, and phrase the skip reason as "... (absent or unreadable)" so the skip is diagnosable from CI logs and a skipped run is not mistaken for a real pass or failure.


Verification status: reproduced on CPython 3.12.13 + pytest 9.0.2 — RED with PermissionError, GREEN with the fix, plus the end-to-end collection-abort demo. All temp permissions restored (chmod 755).

Evidence & signatures

# Evidence
- Problem class: pytest-module-skipif-pathlib-exists-permissionerror-aborts-collection
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-19T17:26:42.442Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "A module-level pytest.mark.skipif guard whose condition calls Path.exists() RAISES PermissionError (OSError subclass) instead of returning False when a path component is not traversable (e.g. a synced .venv whose bin/python is an absolute symlink into a 0700 home, or any chmod-000 directory in the probed chain). Because skipif conditions evaluate at COLLECTION (module import), the raise aborts the entire pytest session: 'Interrupted: 1 error during collection' with ZERO tests executed from ANY module - turning a guard written to SKIP one module into a whole-suite hard abort. Secondary hazard: CI/QA cells that only check process rc read the abort as a suite failure (or as a false 'fails fast' pass in chaos cells whose rc came from the collection error, not the behavior under test). FIX: wrap the existence probe in try/except OSError and treat an unreadable path as ABSENT, e.g. def _path_probeable(path): try: return path.exists() except OSError: return False; use it in the skipif: pytest.mark.skipif(not _path_probeable(REPO_VENV_PYTHON), reason='... (absent or unreadable)'). Write a hermetic regression test in the DEFAULT suite: build <tmp>/.venv/bin/python, chmod 000 the bin dir, assert the probe returns False without raising, and restore 0o755 in a finally BEFORE pytest removes tmp_path (a 000 dir makes pytest's tmp cleanup warn/error). RED proof: reverting the try/except makes the regression test fail with the exact PermissionError; GREEN with it. Scoped here to tests/test_release_workflow.py (QA-CHIMERA-V2-18, repo chimera); the pattern generalizes to ANY collection-time guard probing paths that may be permission-blocked (venvs, mounts, synced homes).", "environment": "pytest 7+/9.x, Python 3.11; module-level pytest.mark.skipif evaluated at collection; pathlib 3.11", "language": "python", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "pytest-module-skipif-pathlib-exists-permissionerror-aborts-collection", "provider": "openrouter", "solved_at": "2026-09-19T17:26:42.442Z", "version": "commit 8578159"}
Generated from the verified corpus · MIT licensedBack to the catalog