tools/file: tree skip_dirs noise defaults and path_denylist walk

Default noise basenames (node_modules, __pycache__, …); skip_dirs=[] disables;
apply_path_denylist hides denied children mid-tree.
This commit is contained in:
2026-07-18 01:32:06 +08:00
parent 2368e46efd
commit 96cdd98e62
2 changed files with 126 additions and 12 deletions
+71 -12
View File
@@ -4,9 +4,10 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING
from plyngent.agent import tool
from plyngent.tools.workspace import WorkspaceError, resolve_path
from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
DEFAULT_MAX_DEPTH = 4
@@ -26,21 +27,58 @@ VCS_DIR_NAMES: frozenset[str] = frozenset(
}
)
# Extra noise dirs skipped by default (in addition to VCS / optional hidden).
# Pass skip_dirs=[] to disable this list (VCS still always skipped).
DEFAULT_NOISE_DIR_NAMES: frozenset[str] = frozenset(
{
"node_modules",
"__pycache__",
".venv",
"venv",
"dist",
"build",
"target",
".tox",
".mypy_cache",
".ruff_cache",
".pytest_cache",
"coverage",
".next",
".nuxt",
".turbo",
".cache",
"eggs",
".eggs",
"htmlcov",
}
)
@dataclass(frozen=True, slots=True)
class _TreeLimits:
max_depth: int
max_entries: int
skip_hidden_dirs: bool
skip_basenames: frozenset[str]
apply_path_denylist: bool
def _skip_directory(name: str, *, skip_hidden_dirs: bool) -> bool:
if name in VCS_DIR_NAMES:
def _skip_directory(name: str, *, limits: _TreeLimits) -> bool:
if name in VCS_DIR_NAMES or name in limits.skip_basenames:
return True
return bool(skip_hidden_dirs and name.startswith("."))
return bool(limits.skip_hidden_dirs and name.startswith("."))
def _list_children(directory: Path, *, skip_hidden_dirs: bool) -> list[Path] | str:
def _path_denied(path: Path) -> bool:
"""True when resolved path matches a path_denylist substring."""
denylist = get_path_denylist()
if not denylist:
return False
resolved_str = str(path).replace("\\", "/")
return any(pattern and pattern.replace("\\", "/") in resolved_str for pattern in denylist)
def _list_children(directory: Path, *, limits: _TreeLimits) -> list[Path] | str:
try:
children = list(directory.iterdir())
except OSError as exc:
@@ -51,7 +89,9 @@ def _list_children(directory: Path, *, skip_hidden_dirs: bool) -> list[Path] | s
is_dir = child.is_dir()
except OSError:
continue
if is_dir and _skip_directory(child.name, skip_hidden_dirs=skip_hidden_dirs):
if is_dir and _skip_directory(child.name, limits=limits):
continue
if limits.apply_path_denylist and _path_denied(child):
continue
visible.append(child)
# Directories first, then files; alphabetical within each group.
@@ -70,7 +110,7 @@ def _render_tree(
if depth >= limits.max_depth:
return
children = _list_children(directory, skip_hidden_dirs=limits.skip_hidden_dirs)
children = _list_children(directory, limits=limits)
if isinstance(children, str):
lines.append(f"{prefix}{children}")
return
@@ -104,6 +144,13 @@ def _render_tree(
lines.append(f"{prefix}└── … ({more} more entries not shown)")
def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
"""None → default noise set; explicit list (including empty) replaces defaults."""
if skip_dirs is None:
return DEFAULT_NOISE_DIR_NAMES
return frozenset(name for name in skip_dirs if name)
@tool
def tree(
path: str = ".",
@@ -111,13 +158,22 @@ def tree(
max_depth: int = DEFAULT_MAX_DEPTH,
max_entries: int = DEFAULT_MAX_ENTRIES,
skip_hidden_dirs: bool = True,
skip_dirs: list[str] | None = None,
apply_path_denylist: bool = True,
) -> str:
"""Show a directory tree under the workspace.
Always skips VCS metadata directories (``.git``, ``.hg``, ``.svn``, …).
By default also skips common noise dirs (``node_modules``, ``__pycache__``,
``.venv``, ``dist``, …). Pass ``skip_dirs=[]`` to disable the noise list
(VCS still skipped). Pass an explicit list to replace the default noise set.
By default skips other dot-directories (not hidden files). Use
``skip_hidden_dirs=false`` to include them.
``apply_path_denylist`` (default true) hides entries whose full path matches
the agent ``path_denylist`` policy.
``max_depth`` limits how deep directories are expanded (1 = origin + children).
``max_entries`` caps how many entries are listed per directory.
"""
@@ -135,15 +191,18 @@ def tree(
root_label = path.rstrip("/\\") or "."
lines = [f"{root_label}/"]
limits = _TreeLimits(
max_depth=max_depth,
max_entries=max_entries,
skip_hidden_dirs=skip_hidden_dirs,
skip_basenames=_resolve_skip_basenames(skip_dirs),
apply_path_denylist=apply_path_denylist,
)
_render_tree(
origin,
prefix="",
depth=0,
limits=_TreeLimits(
max_depth=max_depth,
max_entries=max_entries,
skip_hidden_dirs=skip_hidden_dirs,
),
limits=limits,
lines=lines,
)
return "\n".join(lines)
+55
View File
@@ -83,3 +83,58 @@ def test_tree_invalid_limits(workspace: object) -> None:
del workspace
assert "max_depth" in call_sync(tree, ".", max_depth=0)
assert "max_entries" in call_sync(tree, ".", max_entries=0)
def test_tree_default_noise_dirs(workspace: object) -> None:
assert isinstance(workspace, Path)
(workspace / "src").mkdir()
_ = (workspace / "src" / "app.py").write_text("x", encoding="utf-8")
(workspace / "node_modules").mkdir()
_ = (workspace / "node_modules" / "pkg.js").write_text("x", encoding="utf-8")
(workspace / "__pycache__").mkdir()
_ = (workspace / "__pycache__" / "x.pyc").write_text("x", encoding="utf-8")
out = call_sync(tree, ".")
assert "src/" in out
assert "app.py" in out
assert "node_modules" not in out
assert "__pycache__" not in out
def test_tree_skip_dirs_empty_shows_noise(workspace: object) -> None:
assert isinstance(workspace, Path)
(workspace / "node_modules").mkdir()
_ = (workspace / "node_modules" / "pkg.js").write_text("x", encoding="utf-8")
out = call_sync(tree, ".", skip_dirs=[])
assert "node_modules/" in out
assert "pkg.js" in out
def test_tree_skip_dirs_custom(workspace: object) -> None:
assert isinstance(workspace, Path)
(workspace / "keep_me").mkdir()
_ = (workspace / "keep_me" / "a.txt").write_text("x", encoding="utf-8")
(workspace / "drop_me").mkdir()
_ = (workspace / "drop_me" / "b.txt").write_text("x", encoding="utf-8")
# Custom list replaces default noise set (node_modules not in list → would show if present).
out = call_sync(tree, ".", skip_dirs=["drop_me"])
assert "keep_me/" in out
assert "drop_me" not in out
def test_tree_path_denylist_walk(workspace: object) -> None:
from plyngent.tools.workspace import set_path_denylist
assert isinstance(workspace, Path)
(workspace / "ok").mkdir()
_ = (workspace / "ok" / "a.txt").write_text("x", encoding="utf-8")
(workspace / "secrets").mkdir()
_ = (workspace / "secrets" / "key.txt").write_text("x", encoding="utf-8")
set_path_denylist(["/secrets"])
try:
out = call_sync(tree, ".")
assert "ok/" in out
assert "secrets" not in out
out2 = call_sync(tree, ".", apply_path_denylist=False, skip_dirs=[])
assert "secrets/" in out2
finally:
set_path_denylist(None)