mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 04:06:42 +00:00
Adds a SPOT-aware lint that walks the repo via the cached
project-tree iterator (no `git` subprocess) and fails on any tracked
non-test Python file longer than 500 lines. Files under `tests/` are
intentionally excluded — coverage breadth there reflects feature
matrix, not design weight. A per-file `# nocheck` (or `#nocheck`)
marker in the first 30 lines opts the file out; the marker is
intentionally visible so the cost of carrying long modules is not
silently buried.
Gitignore-aware filtering moves to its own SPOT
-----------------------------------------------
The fnmatch-based `.gitignore` parser/matcher previously lived inline
in `tests/lint/repository/test_test_files_location.py` (and in
near-duplicates across other tests). Pull it into
`utils.cache.gitignore` as a single source of truth:
* `load_gitignore_patterns(root)` — cached pattern loader.
* `is_path_gitignored(rel_path, patterns)` — pure matcher.
* `iter_non_ignored_files(...)` — convenience iterator in
`utils.cache.files` that composes the cached project walk with the
matcher.
`utils.cache._reset_cache_for_tests` now resets the gitignore module
too, so test fixtures that mutate the tree see consistent state.
Refactor / opt-out for existing offenders
-----------------------------------------
Three production files exceeded the cap; addressed each:
* `roles/sys-svc-compose-ca/files/compose_ca.py` — opted out via
`# nocheck: file-size`. The script is delivered to target hosts
via `ansible.builtin.copy` and runs with only the stdlib; splitting
would require also delivering a package, which the role does not
do.
* `tasks/utils/migrate_meta_layout.py` — converted to a package
(`tasks/utils/migrate_meta_layout/`) with focused submodules:
- `__main__` — entrypoint + run-orchestration.
- `yaml_io` — load/dump/empty-dir/deep-merge helpers.
- `credentials` — schema-vs-config collision detection +
runtime-to-schema conversion.
- `role_builder` — per-role meta file emission.
- `indices` — networks + ports + relays index builders.
Run via `python3 -m tasks.utils.migrate_meta_layout`. Largest
submodule is `role_builder.py` at 250 lines.
* `cli/fix/move_unnecessary_dependencies/__main__.py` — split
alongside two new sibling modules in the same package:
- `analysis` — provider/consumer static analysis.
- `apply` — meta rewrite + guarded include-block prepending.
- `yaml_io` — ruamel round-trip helpers + role-tree discovery.
Largest submodule is `analysis.py` at 174 lines.
`make test` green (162 tests, 1 skipped); the new lint passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
183 lines
6.3 KiB
Python
183 lines
6.3 KiB
Python
"""Process-level cached filesystem helpers for project-tree scans.
|
|
|
|
Many integration/lint tests and CLI tools walk the whole repo and read every
|
|
matching file. Doing that N times per process is wasteful — the tree doesn't
|
|
change during a run. These helpers walk the tree once per process and memoise
|
|
both the path list and file contents, so every caller after the first one pays
|
|
near-zero cost.
|
|
|
|
Usage:
|
|
|
|
from utils.cache.files import iter_project_files, read_text
|
|
|
|
for path in iter_project_files(extensions=(".yml", ".yml.j2"), exclude_tests=True):
|
|
content = read_text(path)
|
|
...
|
|
|
|
`PROJECT_ROOT` is the repo root derived from this file's location.
|
|
|
|
Gitignore-filtered iteration
|
|
----------------------------
|
|
|
|
The matcher itself lives in :mod:`utils.cache.gitignore` (single SPOT for
|
|
``.gitignore`` parsing and matching). :func:`iter_non_ignored_files`
|
|
here composes the cached project walk with that matcher and is the
|
|
recommended helper for lint tests that want the "files git would track"
|
|
view without subprocessing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Iterable, Iterator, Tuple
|
|
|
|
from .base import PROJECT_ROOT
|
|
from .gitignore import is_path_gitignored, load_gitignore_patterns
|
|
|
|
# Directories never worth descending into during project-tree scans.
|
|
_DEFAULT_SKIP_DIRS: frozenset[str] = frozenset(
|
|
{
|
|
".git",
|
|
".cache",
|
|
".pytest_cache",
|
|
".ruff_cache",
|
|
".venv",
|
|
"venv",
|
|
"__pycache__",
|
|
"node_modules",
|
|
}
|
|
)
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _all_project_files() -> tuple[str, ...]:
|
|
"""One-shot filesystem walk from PROJECT_ROOT.
|
|
|
|
Returns absolute paths as a tuple (immutable → safe to cache).
|
|
Pruned at walk-time by `_DEFAULT_SKIP_DIRS`, since those dirs are never
|
|
relevant for any project-tree test and descending into them is pure waste.
|
|
"""
|
|
root_str = str(PROJECT_ROOT)
|
|
paths: list[str] = []
|
|
for dirpath, dirnames, filenames in os.walk(root_str, topdown=True):
|
|
# prune in-place (topdown=True): stops descent into skipped dirs
|
|
dirnames[:] = [d for d in dirnames if d not in _DEFAULT_SKIP_DIRS]
|
|
for fn in filenames:
|
|
paths.append(os.path.join(dirpath, fn))
|
|
return tuple(paths)
|
|
|
|
|
|
def iter_project_files(
|
|
*,
|
|
extensions: Iterable[str] | None = None,
|
|
exclude_tests: bool = False,
|
|
exclude_dirs: Iterable[str] | None = None,
|
|
) -> Iterator[str]:
|
|
"""Yield absolute paths of project files matching the filter.
|
|
|
|
Args:
|
|
extensions: Suffix strings (e.g. ``(".yml", ".yaml.j2")``). Matching is
|
|
case-insensitive via plain string suffix. ``None`` yields all files.
|
|
exclude_tests: Skip everything under ``<root>/tests/``.
|
|
exclude_dirs: Additional top-level-or-nested directory names to skip.
|
|
Matched by path-component containment, not by prefix. E.g.
|
|
``exclude_dirs=("docs",)`` skips any file whose path contains a
|
|
``docs`` segment.
|
|
"""
|
|
all_files = _all_project_files()
|
|
tests_prefix = os.path.join(str(PROJECT_ROOT), "tests") + os.sep
|
|
ext_lower = tuple(e.lower() for e in extensions) if extensions else None
|
|
exclude_dirs_set = set(exclude_dirs) if exclude_dirs else None
|
|
|
|
for path in all_files:
|
|
if exclude_tests and path.startswith(tests_prefix):
|
|
continue
|
|
if exclude_dirs_set is not None:
|
|
# Cheap path-segment check; avoids splitting the full path.
|
|
rel = os.path.relpath(path, str(PROJECT_ROOT))
|
|
segments = rel.split(os.sep)
|
|
if exclude_dirs_set.intersection(segments):
|
|
continue
|
|
if ext_lower is not None:
|
|
lower = path.lower()
|
|
if not any(lower.endswith(e) for e in ext_lower):
|
|
continue
|
|
yield path
|
|
|
|
|
|
@lru_cache(maxsize=8192)
|
|
def read_text(path: str) -> str:
|
|
"""Return the UTF-8 text content of a file; cached process-wide.
|
|
|
|
Raises OSError / UnicodeDecodeError unchanged — callers that scan arbitrary
|
|
files can wrap this in try/except if non-UTF-8 blobs may appear.
|
|
"""
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return f.read()
|
|
|
|
|
|
def iter_project_files_with_content(
|
|
*,
|
|
extensions: Iterable[str] | None = None,
|
|
exclude_tests: bool = False,
|
|
exclude_dirs: Iterable[str] | None = None,
|
|
) -> Iterator[Tuple[str, str]]:
|
|
"""Yield ``(path, content)`` for matching files; content is cached.
|
|
|
|
Files that fail to read (permission errors, non-UTF-8) are silently
|
|
skipped — that matches the prior caller behaviour of wrapping
|
|
``open().read()`` in a bare ``try/except Exception: continue``.
|
|
"""
|
|
for path in iter_project_files(
|
|
extensions=extensions,
|
|
exclude_tests=exclude_tests,
|
|
exclude_dirs=exclude_dirs,
|
|
):
|
|
try:
|
|
content = read_text(path)
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
yield path, content
|
|
|
|
|
|
def iter_non_ignored_files(
|
|
*,
|
|
extensions: Iterable[str] | None = None,
|
|
exclude_tests: bool = False,
|
|
exclude_dirs: Iterable[str] | None = None,
|
|
root: str | None = None,
|
|
) -> Iterator[str]:
|
|
"""Yield project files filtered through ``.gitignore``.
|
|
|
|
Combines :func:`iter_project_files` with :func:`is_path_gitignored`
|
|
so callers get the "files git would track" view without spawning a
|
|
git subprocess. ``root`` defaults to :data:`PROJECT_ROOT`.
|
|
|
|
A path is yielded iff it survives both the in-process skip-dir
|
|
pruning of :func:`iter_project_files` and the ``.gitignore`` match.
|
|
"""
|
|
base = Path(root) if root else PROJECT_ROOT
|
|
patterns = load_gitignore_patterns(str(base))
|
|
for path in iter_project_files(
|
|
extensions=extensions,
|
|
exclude_tests=exclude_tests,
|
|
exclude_dirs=exclude_dirs,
|
|
):
|
|
rel = os.path.relpath(path, str(base)).replace(os.sep, "/")
|
|
if is_path_gitignored(rel, patterns):
|
|
continue
|
|
yield path
|
|
|
|
|
|
def _reset() -> None:
|
|
"""Test-only helper: clear the project-walk and file-content caches.
|
|
|
|
Named ``_reset`` for parity with the per-domain reset helpers in
|
|
sibling modules; ``utils.cache._reset_cache_for_tests`` orchestrates
|
|
calls to all of them.
|
|
"""
|
|
_all_project_files.cache_clear()
|
|
read_text.cache_clear()
|