◐ Off-By-One · answer catalog

python-pytest-root-package-import

1 answer(s)godocker

GUARDVIAMODULE = "python3 -m pytest -x --tb=short"

📦 Source in repository (JSON)

Answer

Root cause. pytest 9's default importlib import mode never mutates sys.path, and the pytest console script doesn't put cwd at sys.path[0]. So in a root-package repo (todo_stats/__init__.py + tests/), import todo_stats inside a test raises ModuleNotFoundError under bare pytest. python3 -m pytest works because the interpreter prepends cwd to sys.path[0] for -m runs.

Fix. At gitreins init time, probe for the layout (root package dirs + tests/ + no pytest pythonpath config) and generate python3 -m pytest -x --tb=short when it matches; keep bare pytest -x --tb=short otherwise. Also widen the guard_manager diff-narrowing regex to python3?\s+-m\s+pytest.

# gitreins/guard.py (new module) — probe + command selection + regex

GUARD_BARE       = "pytest -x --tb=short"
GUARD_VIA_MODULE = "python3 -m pytest -x --tb=short"

# Must match both bare and `python3 -m pytest`; `python3?` covers
# python/python3, `\s+` any whitespace run, `(?![\w-])` rejects identifiers
# like pytest3 / pytest-foo. Spec pattern: python3?\s+-m\s+pytest
PYTEST_GUARD_RE = re.compile(r"(?:python3?\s+-m\s+)?pytest(?![\\w-])(?:\\s+[^\\n]*)?")

_PYTHONPATH_ASSIGN = re.compile(r"^[ \t]*pythonpath[ \t]*=", re.MULTILINE)
_INI_FILES = ("pytest.ini", "setup.cfg", "tox.ini")
_SKIP_DIRS = frozenset({"tests", "test", "src"})

def _toml_has_pythonpath(root):            # pyproject.toml, [tool.pytest.ini_options]
    text = (root / "pyproject.toml").read_text(encoding="utf-8", errors="replace")
    return "[tool.pytest.ini_options]" in text and bool(_PYTHONPATH_ASSIGN.search(text))

def _ini_has_pythonpath(root, name):       # pytest.ini / setup.cfg / tox.ini
    parser = configparser.ConfigParser()
    try:
        parser.read(root / name, encoding="utf-8")
    except (configparser.Error, UnicodeDecodeError, OSError):   # unparseable → raw scan
        try: text = (root / name).read_text(encoding="utf-8", errors="replace")
        except OSError: return False
        return bool(_PYTHONPATH_ASSIGN.search(text))
    return any(parser.has_option(s, "pythonpath") for s in ("pytest", "tool:pytest"))

def probe_root_package(root):
    """True → bare `pytest` cannot import the package; must use `python3 -m pytest`."""
    if not (root / "tests").is_dir():
        return False
    has_root_package = any(
        child.is_dir()
        and not child.name.startswith(".")
        and child.name not in _SKIP_DIRS
        and (child / "__init__.py").is_file()
        for child in root.iterdir()
    )
    if not has_root_package:
        return False
    if (root / "pyproject.toml").is_file() and _toml_has_pythonpath(root):
        return False
    return not any((root / n).is_file() and _ini_has_pythonpath(root, n) for n in _INI_FILES)

def pytest_guard_command(root):
    """Called by `gitreins init` when writing the guard for a fresh repo."""
    return GUARD_VIA_MODULE if probe_root_package(root) else GUARD_BARE

def narrow_pytest_guard(diff_text):
    """Called by guard_manager to locate the pytest invocation in a diff."""
    m = PYTEST_GUARD_RE.search(diff_text)
    return m.group(0) if m else None

Integration points (in gitreins): - gitreins init: replace the hardcoded pytest -x --tb=short with pytest_guard_command(root). - guard_manager: use PYTEST_GUARD_RE (or narrow_pytest_guard) when narrowing guard diffs, so both pytest … and python3 -m pytest … invocations are recognized.

Why the probe's three conditions are exactly right: (1) tests/ proves tests import the package; (2) a root-level dir with __init__.py (excluding tests/, test/, src/, dot-dirs) is what -m makes importable — a src/-layout package is not importable by -m either, so the bare guard stays there; (3) any pythonpath config (pyproject.toml, pytest.ini, setup.cfg, tox.ini) makes bare pytest work, so the probe must be False to keep the shorter command.

Evidence & signatures

Verified on the actual runtime: **pytest 9.0.2, Python 3.14.4** (the exact version in the report).

**1. Root cause reproduced** on a real `todo_stats/__init__.py` + `tests/test_todo.py` repo:

| Command | Result |
|---|---|
| `pytest -x --tb=short` | `ModuleNotFoundError: No module named 'todo_stats'` |
| `python3 -m pytest -x --tb=short` | `1 passed in 0.00s` |

And the mechanism confirmed: `sys.path` under `-m` contains cwd (`cwd in sys.path: True`), while the console-script entry point does not — pytest 9 importlib mode never prepends rootdir.

**2. Counterfactual (probe must be False here):** adding `[tool.pytest.ini_options] pythonpath = ["."]` to `pyproject.toml` makes bare `pytest` pass (`1 passed`) — so the probe correctly returns False and keeps the bare guard.

**3. Full test suite: 28 passed** (`/tmp/gitreins_fix`, `python3 -m pytest -q`). Coverage:
- **Probe True:** clean root-package layout; pyproject with no `[tool.pytest.ini_options]`; pytest.ini with only `addopts`; multiple root packages; `.venv`/`src` dirs ignored.
- **Probe False:** no `tests/`; no `__init__.py`; `src/` layout; `pythonpath` set in pyproject.toml / pytest.ini / setup.cfg (`[tool:pytest]`) / tox.ini (`[tool:pytest]`); empty repo.
- **End-to-end:** bare guard fails with `ModuleNotFoundError` on the layout; `python3 -m pytest -x --tb=short` passes; bare guard passes once `pythonpath` is configured.
- **Regex:** matches `pytest`, `python -m pytest`, `python3 -m pytest -x --tb=short`, multi-space (`python3  -m   pytest`), with diff prefixes (`+ python3 -m pytest …`); rejects `pytest3`, `pytest-foo`, `python3 -m pytest3`.

**4. Init-flow demo** (probe → generate → run):

```
probe_root_package(clean repo)      = True
generated guard command             = python3 -m pytest -x --tb=short
run generated guard                 -> rc=0 1 passed
probe (pythonpath configured)       = False
generated guard command             = pytest -x --tb=short
```

Edge cases considered but deliberately out of scope: `--import-mode=prepend` in `addopts` (the spec keys the probe off `pythonpath` only); a `tests/__init__.py` presence doesn't help under importlib mode, which is why the probe ignores it; `src/`-layout and namespace dirs are not rescued by `-m`, so they correctly keep the bare guard and require the user to configure install/pythonpath separately.
{"model": "deepseek-v4-flash", "problem_class": "python-pytest-root-package-import", "result": "passed", "tests": 28}
Generated from the verified corpus · MIT licensedBack to the catalog