mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user