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 typing import TYPE_CHECKING
|
||||||
|
|
||||||
from plyngent.agent import tool
|
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:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
DEFAULT_MAX_DEPTH = 4
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class _TreeLimits:
|
class _TreeLimits:
|
||||||
max_depth: int
|
max_depth: int
|
||||||
max_entries: int
|
max_entries: int
|
||||||
skip_hidden_dirs: bool
|
skip_hidden_dirs: bool
|
||||||
|
skip_basenames: frozenset[str]
|
||||||
|
apply_path_denylist: bool
|
||||||
|
|
||||||
|
|
||||||
def _skip_directory(name: str, *, skip_hidden_dirs: bool) -> bool:
|
def _skip_directory(name: str, *, limits: _TreeLimits) -> bool:
|
||||||
if name in VCS_DIR_NAMES:
|
if name in VCS_DIR_NAMES or name in limits.skip_basenames:
|
||||||
return True
|
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:
|
try:
|
||||||
children = list(directory.iterdir())
|
children = list(directory.iterdir())
|
||||||
except OSError as exc:
|
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()
|
is_dir = child.is_dir()
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
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
|
continue
|
||||||
visible.append(child)
|
visible.append(child)
|
||||||
# Directories first, then files; alphabetical within each group.
|
# Directories first, then files; alphabetical within each group.
|
||||||
@@ -70,7 +110,7 @@ def _render_tree(
|
|||||||
if depth >= limits.max_depth:
|
if depth >= limits.max_depth:
|
||||||
return
|
return
|
||||||
|
|
||||||
children = _list_children(directory, skip_hidden_dirs=limits.skip_hidden_dirs)
|
children = _list_children(directory, limits=limits)
|
||||||
if isinstance(children, str):
|
if isinstance(children, str):
|
||||||
lines.append(f"{prefix}{children}")
|
lines.append(f"{prefix}{children}")
|
||||||
return
|
return
|
||||||
@@ -104,6 +144,13 @@ def _render_tree(
|
|||||||
lines.append(f"{prefix}└── … ({more} more entries not shown)")
|
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
|
@tool
|
||||||
def tree(
|
def tree(
|
||||||
path: str = ".",
|
path: str = ".",
|
||||||
@@ -111,13 +158,22 @@ def tree(
|
|||||||
max_depth: int = DEFAULT_MAX_DEPTH,
|
max_depth: int = DEFAULT_MAX_DEPTH,
|
||||||
max_entries: int = DEFAULT_MAX_ENTRIES,
|
max_entries: int = DEFAULT_MAX_ENTRIES,
|
||||||
skip_hidden_dirs: bool = True,
|
skip_hidden_dirs: bool = True,
|
||||||
|
skip_dirs: list[str] | None = None,
|
||||||
|
apply_path_denylist: bool = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Show a directory tree under the workspace.
|
"""Show a directory tree under the workspace.
|
||||||
|
|
||||||
Always skips VCS metadata directories (``.git``, ``.hg``, ``.svn``, …).
|
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
|
By default skips other dot-directories (not hidden files). Use
|
||||||
``skip_hidden_dirs=false`` to include them.
|
``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_depth`` limits how deep directories are expanded (1 = origin + children).
|
||||||
``max_entries`` caps how many entries are listed per directory.
|
``max_entries`` caps how many entries are listed per directory.
|
||||||
"""
|
"""
|
||||||
@@ -135,15 +191,18 @@ def tree(
|
|||||||
|
|
||||||
root_label = path.rstrip("/\\") or "."
|
root_label = path.rstrip("/\\") or "."
|
||||||
lines = [f"{root_label}/"]
|
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(
|
_render_tree(
|
||||||
origin,
|
origin,
|
||||||
prefix="",
|
prefix="",
|
||||||
depth=0,
|
depth=0,
|
||||||
limits=_TreeLimits(
|
limits=limits,
|
||||||
max_depth=max_depth,
|
|
||||||
max_entries=max_entries,
|
|
||||||
skip_hidden_dirs=skip_hidden_dirs,
|
|
||||||
),
|
|
||||||
lines=lines,
|
lines=lines,
|
||||||
)
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|||||||
@@ -83,3 +83,58 @@ def test_tree_invalid_limits(workspace: object) -> None:
|
|||||||
del workspace
|
del workspace
|
||||||
assert "max_depth" in call_sync(tree, ".", max_depth=0)
|
assert "max_depth" in call_sync(tree, ".", max_depth=0)
|
||||||
assert "max_entries" in call_sync(tree, ".", max_entries=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