perf(sandbox): speed up should_ignore_name in glob/grep walks (#3657)

should_ignore_name runs once per directory entry during glob/grep tree
walks and looped over ~57 IGNORE_PATTERNS calling fnmatch per pattern.
Precompute the literal names into a frozenset (O(1)) and the few glob
patterns into one combined compiled regex via fnmatch.translate; per name
it's now one normcase + a set lookup + at most one regex match.
os.path.normcase preserves fnmatch's platform case behavior, so results
are identical (covered by an equivalence test against the old loop).

Fixes #3655.
This commit is contained in:
Eilen Shin
2026-06-19 21:57:03 +08:00
committed by GitHub
parent 9124f991de
commit d2292d73da
2 changed files with 87 additions and 4 deletions
@@ -67,11 +67,22 @@ class GrepMatch:
line: str
# ``should_ignore_name`` runs once per directory entry during glob/grep tree
# walks, so we avoid ~50 ``fnmatch`` calls per name. Most ignore patterns are
# literal names (O(1) set lookup after normcase); the few glob patterns are
# pre-translated into a single combined regex. ``os.path.normcase`` keeps the
# same case behavior ``fnmatch`` applies (case-sensitive on POSIX, folded on
# Windows).
_EXACT_IGNORE_NAMES = frozenset(os.path.normcase(p) for p in IGNORE_PATTERNS if not any(c in p for c in "*?["))
_GLOB_IGNORE_PATTERNS = [p for p in IGNORE_PATTERNS if any(c in p for c in "*?[")]
_GLOB_IGNORE_RE = re.compile("|".join(fnmatch.translate(os.path.normcase(p)) for p in _GLOB_IGNORE_PATTERNS)) if _GLOB_IGNORE_PATTERNS else None
def should_ignore_name(name: str) -> bool:
for pattern in IGNORE_PATTERNS:
if fnmatch.fnmatch(name, pattern):
return True
return False
normalized = os.path.normcase(name)
if normalized in _EXACT_IGNORE_NAMES:
return True
return _GLOB_IGNORE_RE is not None and _GLOB_IGNORE_RE.match(normalized) is not None
def should_ignore_path(path: str) -> bool:
+72
View File
@@ -0,0 +1,72 @@
"""should_ignore_name must stay behavior-identical to the original per-pattern
fnmatch loop while doing O(1) set lookup + one combined glob regex instead of
~50 fnmatch calls per directory entry.
"""
from __future__ import annotations
import fnmatch
from deerflow.sandbox.search import IGNORE_PATTERNS, should_ignore_name, should_ignore_path
def _reference(name: str) -> bool:
"""Original implementation, kept here as the equivalence oracle."""
return any(fnmatch.fnmatch(name, pattern) for pattern in IGNORE_PATTERNS)
_SAMPLES = [
# exact-name ignores
".git",
"node_modules",
"__pycache__",
".venv",
"env",
"logs",
"coverage",
".pytest_cache",
".DS_Store",
"Thumbs.db",
"desktop.ini",
# glob ignores
"thing.egg-info",
"x.swp",
"y.swo",
"z.log",
"a.tmp",
"b.temp",
"c.bak",
"d.cache",
"core~",
"shortcut.lnk",
# kept (must NOT be ignored)
"foo.py",
"README.md",
"src",
"myenv",
"node_modules_x",
"x.git",
"log",
"main.c",
]
def test_matches_reference_for_all_samples():
for name in _SAMPLES:
assert should_ignore_name(name) == _reference(name), name
def test_known_ignored_names():
for name in [".git", "node_modules", "__pycache__", "x.swp", "z.log", "core~", "thing.egg-info"]:
assert should_ignore_name(name) is True
def test_known_kept_names():
for name in ["foo.py", "README.md", "src", "myenv", "node_modules_x", "x.git"]:
assert should_ignore_name(name) is False
def test_should_ignore_path_segments():
assert should_ignore_path("a/node_modules/b") is True
assert should_ignore_path("proj/.git/config") is True
assert should_ignore_path("a/b/c.py") is False