mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(sandbox): make glob patterns root-relative with recursive ** (#6046)
path_matches backs the glob tool and grep's glob= filter for every sandbox provider. It relied on PurePosixPath.match, which matches from the right and treats ** as a single-segment * on Python 3.12+, so only a leading **/ was recursive: src/**/*.py missed src/top.py and anything two or more levels deep, and docs/*.md also matched vendor/docs/b.md. Match patterns that contain a / segment by segment from the search root, with ** spanning zero or more directories. Patterns without / keep matching the basename at any depth.
This commit is contained in:
parent
f64cc16385
commit
9b95f64e45
@ -90,13 +90,32 @@ def should_ignore_path(path: str) -> bool:
|
||||
return any(should_ignore_name(segment) for segment in path.replace("\\", "/").split("/") if segment)
|
||||
|
||||
|
||||
def _match_segments(pattern_parts: tuple[str, ...], path_parts: tuple[str, ...]) -> bool:
|
||||
# Positions in ``path_parts`` reachable after consuming each pattern part;
|
||||
# ``**`` spans zero or more whole segments, any other part exactly one.
|
||||
reachable = {0}
|
||||
for part in pattern_parts:
|
||||
if part == "**":
|
||||
reachable = set(range(min(reachable), len(path_parts) + 1))
|
||||
else:
|
||||
reachable = {i + 1 for i in reachable if i < len(path_parts) and fnmatch.fnmatchcase(path_parts[i], part)}
|
||||
if not reachable:
|
||||
return False
|
||||
return len(path_parts) in reachable
|
||||
|
||||
|
||||
def path_matches(pattern: str, rel_path: str) -> bool:
|
||||
path = PurePosixPath(rel_path)
|
||||
if path.match(pattern):
|
||||
return True
|
||||
if pattern.startswith("**/"):
|
||||
return path.match(pattern[3:])
|
||||
return False
|
||||
"""Match ``rel_path`` (relative to the search root) against a glob pattern.
|
||||
|
||||
A pattern without ``/`` matches the basename at any depth (``*.py``). Any
|
||||
other pattern is anchored at the search root, and ``**`` matches zero or
|
||||
more directories (``src/**/*.py`` includes ``src/top.py``).
|
||||
``PurePosixPath.match`` is not used for these: it matches from the right
|
||||
and treats ``**`` as a single-segment ``*``.
|
||||
"""
|
||||
if "/" not in pattern:
|
||||
return PurePosixPath(rel_path).match(pattern)
|
||||
return _match_segments(PurePosixPath(pattern).parts, PurePosixPath(rel_path).parts)
|
||||
|
||||
|
||||
def truncate_line(line: str, max_chars: int = DEFAULT_LINE_SUMMARY_LENGTH) -> str:
|
||||
|
||||
81
backend/tests/test_sandbox_glob_patterns.py
Normal file
81
backend/tests/test_sandbox_glob_patterns.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""Root-relative glob semantics of ``deerflow.sandbox.search.path_matches``.
|
||||
|
||||
``path_matches`` backs the ``glob`` tool and the ``grep`` ``glob=`` filter for
|
||||
every sandbox provider. It relied on ``PurePosixPath.match``, which matches
|
||||
from the right and treats ``**`` as a single-segment ``*`` on Python 3.12+, so
|
||||
only a leading ``**/`` behaved recursively:
|
||||
|
||||
- ``src/**/*.py`` missed ``src/top.py`` and anything two or more levels deep;
|
||||
- ``docs/*.md`` also matched ``vendor/docs/b.md`` although the pattern is
|
||||
documented as relative to the search root.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.sandbox.search import find_glob_matches, find_grep_matches, path_matches
|
||||
|
||||
TREE = (
|
||||
"src/top.py",
|
||||
"src/pkg/mid.py",
|
||||
"src/pkg/sub/deep.py",
|
||||
"docs/a.md",
|
||||
"vendor/docs/b.md",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree(tmp_path: Path) -> Path:
|
||||
for rel in TREE:
|
||||
path = tmp_path / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("needle\n", encoding="utf-8")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _rel(paths: list[str], root: Path) -> set[str]:
|
||||
return {Path(p).relative_to(root).as_posix() for p in paths}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pattern", "expected"),
|
||||
[
|
||||
("src/**/*.py", {"src/top.py", "src/pkg/mid.py", "src/pkg/sub/deep.py"}),
|
||||
("src/**", {"src/top.py", "src/pkg/mid.py", "src/pkg/sub/deep.py"}),
|
||||
("src/**/sub/*.py", {"src/pkg/sub/deep.py"}),
|
||||
("docs/*.md", {"docs/a.md"}),
|
||||
("**/docs/*.md", {"docs/a.md", "vendor/docs/b.md"}),
|
||||
# Unchanged behavior pinned elsewhere: a leading ``**/`` also matches
|
||||
# at the root, and a pattern without "/" matches a basename anywhere.
|
||||
("**/*.py", {"src/top.py", "src/pkg/mid.py", "src/pkg/sub/deep.py"}),
|
||||
("*.md", {"docs/a.md", "vendor/docs/b.md"}),
|
||||
],
|
||||
)
|
||||
def test_glob_patterns_are_root_relative_and_recursive(tree: Path, pattern: str, expected: set[str]) -> None:
|
||||
matches, truncated = find_glob_matches(tree, pattern)
|
||||
|
||||
assert _rel(matches, tree) == expected
|
||||
assert truncated is False
|
||||
|
||||
|
||||
def test_grep_glob_filter_uses_the_same_recursive_semantics(tree: Path) -> None:
|
||||
matches, _ = find_grep_matches(tree, "needle", glob_pattern="src/**/*.py")
|
||||
|
||||
assert _rel([m.path for m in matches], tree) == {"src/top.py", "src/pkg/mid.py", "src/pkg/sub/deep.py"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pattern", "rel_path", "expected"),
|
||||
[
|
||||
("**", "a/b/c.txt", True),
|
||||
("a/**/**/c.txt", "a/c.txt", True),
|
||||
("a/*/c.txt", "a/b/x/c.txt", False),
|
||||
("nested/*.txt", "nested/result.txt", True),
|
||||
("nested/*.txt", "other/nested/result.txt", False),
|
||||
("./nested/*.txt", "nested/result.txt", True),
|
||||
("*.TXT", "a.txt", False),
|
||||
],
|
||||
)
|
||||
def test_path_matches_segments(pattern: str, rel_path: str, expected: bool) -> None:
|
||||
assert path_matches(pattern, rel_path) is expected
|
||||
Loading…
x
Reference in New Issue
Block a user