Source code for klea_utils.mcp.tool_impls.walk

#!/usr/bin/env python3
"""
Shared filesystem walker for read-only search tools.

File: klea_utils/mcp/tool_impls/walk.py

Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail dot com>
"""

import logging
import os
from collections.abc import Collection, Iterator
from pathlib import Path

logger = logging.getLogger(__name__)

#: Directory names never descended into by default.  These are version
#: control internals, virtual environments, and tool caches: searching them
#: is almost never what the caller wants, and some (``.git``, caches) can be
#: large enough to dominate a scan.  Matched against the directory basename
#: at every depth; the search root itself is always traversed, even when it
#: matches, since the caller explicitly asked for it.
SKIP_DIRECTORIES: frozenset[str] = frozenset(
    {
        ".git",
        ".hg",
        ".svn",
        ".tox",
        ".nox",
        ".venv",
        "venv",
        ".mypy_cache",
        ".ruff_cache",
        ".pytest_cache",
        ".klea-cache",
        "__pycache__",
        "node_modules",
    }
)


[docs] def iter_files( root: str | os.PathLike, *, skip_dirs: Collection[str] = SKIP_DIRECTORIES, project_root: str | os.PathLike | None = None, ) -> Iterator[Path]: """Yield the files under *root*, skipping caches and symlinks. Framework-agnostic helper shared by the read-only search tools (``grep``, ``find_files``). This is a depth-first walk using ``os.scandir`` with symlinks disabled at every step: * directories named in *skip_dirs* are not descended into; * directory symlinks are not followed (so a link pointing outside *root* cannot pull external files into the results); * file symlinks are skipped, for the same boundary reason. The *root* itself is always traversed, even when its name is in *skip_dirs*: the caller explicitly asked for it. Unreadable subdirectories are logged and skipped rather than aborting the walk. This function performs no result capping; callers bound the number of files scanned and matches returned. :param root: Directory to walk. :param skip_dirs: Directory basenames to skip at any depth. :param project_root: Retained for callers; the boundary is enforced by the client-side gate, not here (ADR-0007 update 2026-10-08). :raises OSError: when *root* cannot be scanned (e.g. it is a file or does not exist). :yields: :class:`pathlib.Path` for each regular file found. """ base = Path(root) logger.debug(f"Walking files\n{base = }\n{project_root = }") # Scandir the root eagerly so an unusable root (missing, not a # directory, unreadable) raises before any results are yielded. stack: list[Path] = [base] while stack: current = stack.pop() try: entries = list(os.scandir(current)) except OSError as exc: if current == base: raise logger.warning(f"Could not scan directory {current}: {exc}") continue for entry in entries: try: if entry.is_dir(follow_symlinks=False): if entry.name in skip_dirs: logger.debug(f"Skipping directory {entry.name}") continue stack.append(Path(entry.path)) continue if entry.is_symlink(): logger.debug(f"Skipping symlink {entry.path}") continue if entry.is_file(follow_symlinks=False): yield Path(entry.path) except OSError as exc: logger.warning(f"Could not stat entry {entry.path}: {exc}")