Files
computer-playbook/tests/lint/filesystem/python/test_project_root_import.py
T
kevinveenbirkenbachandClaude Opus 5 5137465c6f style: delete every comment the comments-valid policy rejects
3529 invalid comments in 528 files, grandfathered in while the linter
only saw the working-tree diff. Removal ran through the linter's own
collectors, so detection and deletion cannot drift: a comment whose
opener line is blank before it and whose closer line is blank after it
loses all its lines, anything else loses only the comment region, and a
line left holding a bare delimiter goes with it.

Five files keep their comments behind a nocheck marker instead. Four are
upstream configs whose inline docs are upstream's own (mautrix, systemd
journald, nextcloud-oidc-login, MediaWiki). In littlejs the
commented-out entries ARE the deactivation mechanism for an example
tile, so deleting them would delete configuration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:22:02 +02:00

284 lines
9.8 KiB
Python

"""Lint that pins ``PROJECT_ROOT`` to a single canonical source.
Three rules are enforced:
1. Every Python file in the repo (``*.py``) MUST NOT compute the
project root locally. The forbidden patterns are:
* a top-level assignment that climbs ``parents[N]`` from
``__file__`` into a ``*_ROOT``-style variable,
* a bare ``Path(__file__).…parents`` chain (no ``[N]`` index)
used to iterate upward in search of the root,
* any chain of ``..`` segments (string literals or ``os.pardir``
references) used to walk up to the root,
* a function such as ``repo_root()`` / ``project_root()`` (or
their underscore-prefixed ``_repo_root`` / ``_project_root``
siblings) that searches upward for ``pyproject.toml``.
The single legitimate definition site is the package's own
``__init__.py``. Consumers MUST import ``PROJECT_ROOT`` from there
(relatively as ``from . import PROJECT_ROOT`` or absolutely as
``from <pkg> import PROJECT_ROOT``) so a future move of the file
does not silently break a hard-coded ``parents[N]`` index.
2. ``PROJECT_ROOT`` itself MUST only be assigned inside an
``__init__.py``. A ``PROJECT_ROOT = …`` line anywhere else (test
modules, ``__main__.py`` shims, helper modules, …) fails the lint
even when the right-hand side does not climb ``parents[N]`` —
re-binding the constant locally defeats the single-source guarantee
rule 1 was added to enforce.
3. Every ``__init__.py`` that DOES define ``PROJECT_ROOT`` MUST point
at a directory containing ``pyproject.toml``. A wrong
``parents[N]`` index turns the constant into a silent footgun;
the lint catches it the moment the index drifts off the repo root.
Suppression
-----------
Bootstrap files that prepend the repo root to ``sys.path`` before any
package import resolves (the ``__main__`` shims under ``cli/``) MUST
mark the offending line with ``# nocheck: project-root-import`` and
explain why the local computation is unavoidable. The same applies to
standalone scripts under ``roles/<role>/files/`` that have no
package container to import from. See
[suppression.md](../../../docs/contributing/actions/testing/suppression.md).
"""
from __future__ import annotations
import re
import unittest
from pathlib import Path
from utils.annotations.suppress import is_suppressed_at
from utils.cache.files import iter_non_ignored_files, read_text
SUPPRESS_RULE: str = "project-root-import"
# with `# nocheck: project-root-import` and document why.
_FORBIDDEN_PATTERNS: tuple[tuple[str, re.Pattern[str], str], ...] = (
(
"`parents[N]` index expression",
re.compile(r"\.parents\[\s*\d+\s*\]"),
"stripped",
),
(
"chained `Path(__file__)…parent.parent.…` upward walk",
re.compile(
r"Path\(\s*__file__\s*\)(?:\s*\.\s*[A-Za-z_]\w*\([^)]*\))*"
r"(?:\s*\.\s*parent\b){2,}"
),
"stripped",
),
(
"`Path(__file__).…parents` upward iteration",
re.compile(
r"Path\(\s*__file__\s*\)(?:\s*\.\s*[A-Za-z_]\w*\([^)]*\))*"
r"\s*\.\s*parents\b(?!\s*\[)"
),
"stripped",
),
(
"`os.pardir` reference",
re.compile(r"\bos\.pardir\b"),
"stripped",
),
(
# nocheck: project-root-import catalog entry mentions the literal pattern by design
'".." path segment in a path-construction context',
re.compile(r"""["']\.\.(?:/+\.\.)*/?["']"""),
"no_docs",
),
(
"function searching upward for pyproject.toml",
re.compile(r"^\s*def\s+_*(?:repo_root|project_root)\s*\("),
"stripped",
),
)
_PROJECT_ROOT_ASSIGN_RE = re.compile(r"^\s*PROJECT_ROOT(?:\s*:\s*[^=]+?)?\s*=(?!=)")
_INIT_PROJECT_ROOT_RE = re.compile(
r"^\s*PROJECT_ROOT(?:\s*:\s*[A-Za-z_][\w\[\], ]*)?\s*=\s*"
r"Path\(\s*__file__\s*\)\s*\.resolve\(\)\.parents\[\s*(\d+)\s*\]"
)
def _is_init_file(path: Path) -> bool:
return path.name == "__init__.py"
def _strip_strings_and_comments(text: str) -> str:
"""Return *text* with every string literal and `#` comment replaced
by whitespace that preserves line numbers and column offsets.
The forbidden patterns must only fire on real code, never on
documentation that mentions the patterns by name (the catalog
rule definitions in this very file, the dictionary docstring of
`utils/cache/__init__.py`, etc.).
"""
return _strip(text, keep_inline_strings=False)
def _strip_docs_and_comments(text: str) -> str:
"""Like :func:`_strip_strings_and_comments`, but preserve regular
single-line ``"…"`` and ``'…'`` literals.
Used by the ``".."`` rule, which deliberately scans the *content*
of path strings: with the inline strings stripped (the default
behaviour above) the regex would never match a real
``Path(… / ".." / …)`` callsite, while a docstring or
``# comment`` mentioning ``..`` should still be ignored.
"""
return _strip(text, keep_inline_strings=True)
def _strip(text: str, *, keep_inline_strings: bool) -> str:
out: list[str] = []
i = 0
n = len(text)
while i < n:
ch = text[i]
if text[i : i + 3] in ('"""', "'''"):
quote = text[i : i + 3]
end = text.find(quote, i + 3)
if end == -1:
out.append("\n" * text[i:].count("\n"))
break
block = text[i : end + 3]
out.append("".join("\n" if c == "\n" else " " for c in block))
i = end + 3
continue
if ch in ('"', "'"):
quote = ch
j = i + 1
while j < n and text[j] != quote:
if text[j] == "\\" and j + 1 < n:
j += 2
continue
if text[j] == "\n":
break
j += 1
block = text[i : j + 1]
if keep_inline_strings:
out.append(block)
else:
out.append("".join("\n" if c == "\n" else " " for c in block))
i = j + 1
continue
if ch == "#":
j = text.find("\n", i)
if j == -1:
out.append(" " * (n - i))
break
out.append(" " * (j - i))
i = j
continue
out.append(ch)
i += 1
return "".join(out)
def _scan_file(path: Path) -> list[str]:
"""Return human-readable failure descriptions for *path*.
Empty list means the file is clean. Suppression markers are
consulted on the offending line and the line directly above
(`same-or-above` semantics matching the rest of the suppression
catalog).
"""
try:
text = read_text(str(path))
except Exception:
return []
raw_lines = text.splitlines()
code_lines = _strip_strings_and_comments(text).splitlines()
no_docs_lines = _strip_docs_and_comments(text).splitlines()
failures: list[str] = []
is_init = _is_init_file(path)
for lineno in range(1, len(raw_lines) + 1):
stripped = code_lines[lineno - 1] if lineno - 1 < len(code_lines) else ""
no_docs = no_docs_lines[lineno - 1] if lineno - 1 < len(no_docs_lines) else ""
for label, pattern, scan_mode in _FORBIDDEN_PATTERNS:
target = stripped if scan_mode == "stripped" else no_docs
if not pattern.search(target):
continue
if is_init and _INIT_PROJECT_ROOT_RE.match(stripped):
continue
if is_suppressed_at(raw_lines, lineno, SUPPRESS_RULE, mode="same-or-above"):
continue
failures.append(f"{path}:{lineno}: {label}")
if (
not is_init
and _PROJECT_ROOT_ASSIGN_RE.match(stripped)
and not is_suppressed_at(
raw_lines, lineno, SUPPRESS_RULE, mode="same-or-above"
)
):
failures.append(
f"{path}:{lineno}: `PROJECT_ROOT` assignment outside "
f"`__init__.py` (import via `from . import PROJECT_ROOT` "
f"or `from <pkg> import PROJECT_ROOT` instead)"
)
return failures
def _check_init_project_root(path: Path) -> list[str]:
"""If *path* is an ``__init__.py`` that defines ``PROJECT_ROOT`` at
top level via ``Path(__file__).resolve().parents[N]``, validate
that the resolved path actually carries ``pyproject.toml``."""
if not _is_init_file(path):
return []
try:
text = read_text(str(path))
except Exception:
return []
failures: list[str] = []
for lineno, line in enumerate(text.splitlines(), start=1):
m = _INIT_PROJECT_ROOT_RE.match(line)
if not m:
continue
depth = int(m.group(1))
try:
resolved = path.resolve().parents[depth]
except IndexError:
failures.append(
f"{path}:{lineno}: PROJECT_ROOT parents[{depth}] is out of "
f"range for this file's depth."
)
continue
if not (resolved / "pyproject.toml").is_file():
failures.append(
f"{path}:{lineno}: PROJECT_ROOT resolves to {resolved}, "
f"which does not contain pyproject.toml; fix the "
f"parents[{depth}] index."
)
return failures
class TestProjectRootImport(unittest.TestCase):
def test_no_local_project_root_computation(self):
offenders: list[str] = []
for path_str in iter_non_ignored_files(extensions=(".py",)):
path = Path(path_str)
offenders.extend(_scan_file(path))
offenders.extend(_check_init_project_root(path))
if offenders:
self.fail(
f"{len(offenders)} project-root violation(s):\n"
+ "\n".join(f" - {o}" for o in offenders)
)
if __name__ == "__main__": # pragma: no cover
unittest.main()