Skip to content

Commit 03a93a5

Browse files
committed
Make win glob/grep traversal version-independent
1 parent 468aa74 commit 03a93a5

5 files changed

Lines changed: 239 additions & 56 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ jobs:
8585
- name: Run tests with coverage
8686
run: coverage run -m unittest discover -s tests -v
8787

88-
# `coverage report` enforces `fail_under` from pyproject.toml (90%),
88+
# `coverage report` enforces `fail_under` from pyproject.toml (95%),
8989
# so an insufficiently-tested change fails this job.
9090
- name: Coverage report (enforces fail_under)
9191
run: coverage report

‎python_agent_harness/tools/filesystem.py‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@
3232
from __future__ import annotations
3333

3434
import os
35+
import re
3536
import shutil # noqa: F401 (mock target for tests)
3637
import subprocess # noqa: F401 (mock target for tests)
3738
import tempfile
3839
import threading
3940
import time
41+
from collections.abc import Callable, Iterator
4042
from pathlib import Path
4143
from typing import TypeGuard
4244

@@ -132,6 +134,87 @@ def _natnump(n: object) -> TypeGuard[int]:
132134
return isinstance(n, int) and not isinstance(n, bool) and n >= 0
133135

134136

137+
def _glob_to_regex(pattern: str) -> str:
138+
"""Translate a pathlib-style glob pattern into a :mod:`re` pattern.
139+
140+
Follows :mod:`fnmatch` conventions with two deliberate deviations:
141+
``*`` does not cross path separators (unlike :func:`fnmatch.fnmatch`)
142+
and ``**`` matches any number of directories (including none). A
143+
pattern without any glob metacharacters is escaped, so plain names
144+
match exactly.
145+
"""
146+
i, n = 0, len(pattern)
147+
out: list[str] = []
148+
while i < n:
149+
c = pattern[i]
150+
if c == "*":
151+
if pattern[i : i + 2] == "**":
152+
i += 2
153+
if pattern[i : i + 1] == "/":
154+
i += 1
155+
out.append("(?:.*/)?")
156+
continue
157+
out.append("[^/]*")
158+
i += 1
159+
elif c == "?":
160+
out.append("[^/]")
161+
i += 1
162+
elif c == "[":
163+
end = _find_glob_class_end(pattern, i)
164+
if end is None:
165+
out.append(re.escape(c))
166+
i += 1
167+
else:
168+
body = pattern[i + 1 : end]
169+
if body.startswith("!"):
170+
body = "^" + body[1:]
171+
out.append("[" + body.replace("\\", "\\\\") + "]")
172+
i = end + 1
173+
else:
174+
out.append(re.escape(c))
175+
i += 1
176+
return "".join(out)
177+
178+
179+
def _find_glob_class_end(pattern: str, start: int) -> int | None:
180+
"""Index of the ``]`` closing the char class opened at ``start``.
181+
182+
Mirrors glob semantics: a ``]`` immediately after ``[`` (or ``[!``)
183+
is a literal bracket, and backslash escapes are honoured.
184+
"""
185+
i = start + 1
186+
if i < len(pattern) and pattern[i] == "!":
187+
i += 1
188+
if i < len(pattern) and pattern[i] == "]":
189+
i += 1
190+
while i < len(pattern):
191+
if pattern[i] == "\\" and i + 1 < len(pattern):
192+
i += 2
193+
elif pattern[i] == "]":
194+
return i
195+
else:
196+
i += 1
197+
return None
198+
199+
200+
def _walk_files(root: Path, onerror: Callable[[OSError], object] | None = None) -> Iterator[Path]:
201+
"""Depth-first file iterator that never raises on traversal errors.
202+
203+
Wraps :func:`os.walk` with ``onerror`` so unreadable directories are
204+
reported instead of silently swallowed, and wraps the whole iteration
205+
in a try/except so a scan that dies mid-flight still yields the
206+
entries collected so far (mirroring the 3.13+ ``pathlib`` scan
207+
behaviour on every supported version).
208+
"""
209+
try:
210+
for dirpath, _dirnames, filenames in os.walk(root, onerror=onerror, followlinks=False):
211+
for name in filenames:
212+
yield Path(dirpath) / name
213+
except OSError as e:
214+
if onerror is not None:
215+
onerror(e)
216+
217+
135218
def _git_root(path: str) -> str | None:
136219
d = Path(path).resolve()
137220
for parent in [d, *d.parents]:

‎python_agent_harness/tools/glob_win.py‎

Lines changed: 45 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
in :class:`GlobTool` (which shells out to ``tree``) and the macOS
55
variant :class:`GlobMac` (which shells out to ``find``) both fail on
66
a stock Windows install. ``GlobWindows`` replaces that fallback with
7-
a pure-Python :meth:`pathlib.Path.rglob` approach: Python handles
8-
directory traversal, pattern matching, and mtime sorting.
7+
a pure-Python :func:`os.walk` approach: Python handles directory
8+
traversal, pattern matching, and mtime sorting.
99
1010
This is slower than the C-based ``tree``/``find`` on large directory
1111
trees, but produces identical results and has no external dependencies
@@ -19,47 +19,60 @@
1919
from __future__ import annotations
2020

2121
import os
22+
import re
2223
from pathlib import Path
2324

2425
from .base import ToolContext
25-
from .filesystem import _natnump, _spool
26+
from .filesystem import _glob_to_regex, _natnump, _spool, _walk_files
2627
from .glob import GlobTool
2728

2829

2930
class GlobWindows(GlobTool):
30-
"""Glob with a pure-Python ``pathlib.rglob`` non-git fallback for Windows."""
31-
32-
def _rglob_fallback(self, pattern: str, base: str, depth: object) -> str:
33-
"""Use ``pathlib.Path.rglob`` for traversal + matching, Python for mtime sort.
34-
35-
Walks the directory tree with :meth:`pathlib.Path.rglob`, filters
36-
hidden directories (``.git``, etc.), and sorts results by
37-
modification time (newest first), matching the ``tree --sort=mtime``
38-
order of the Linux fallback. Symlinks are followed by default
39-
via ``Path.rglob``.
31+
"""Glob with a fault-tolerant pure-Python non-git fallback for Windows."""
32+
33+
def _walk_fallback(self, pattern: str, base: str, depth: object) -> str:
34+
"""Walk the tree with :func:`os.walk`, match, and sort by mtime.
35+
36+
Matches files against a pathlib-style glob translated via
37+
:func:`_glob_to_regex`, filters hidden directories (``.git``,
38+
etc.), and sorts results by modification time (newest first),
39+
matching the ``tree --sort=mtime`` order of the Linux fallback.
40+
Unlike ``Path.rglob`` (whose OSError suppression only exists on
41+
3.13+), this traversal tolerates races and unreadable directories
42+
identically on every supported Python version.
4043
"""
4144
root = Path(base)
4245
max_depth = depth if _natnump(depth) else None
46+
# pathlib rglob semantics: a pattern without a directory part is
47+
# matched against the basename at any depth; a pattern with one is
48+
# matched against the path relative to the root.
49+
if "/" in pattern or os.sep in pattern:
50+
rx = re.compile(_glob_to_regex(pattern.replace(os.sep, "/")))
51+
else:
52+
rx = re.compile(r"(?:.*/)?" + _glob_to_regex(pattern))
53+
errors: list[str] = []
54+
55+
def onerror(e: OSError) -> None:
56+
errors.append(str(e))
4357

4458
matches: list[tuple[float, str]] = []
45-
try:
46-
for p in root.rglob(pattern):
47-
if not p.is_file():
48-
continue
49-
# skip hidden directories (.git, etc.)
50-
if any(part.startswith(".") for part in p.relative_to(root).parts[:-1]):
51-
continue
52-
if max_depth is not None:
53-
rel_depth = len(p.relative_to(root).parts)
54-
if rel_depth > max_depth:
55-
continue
56-
try:
57-
mtime = p.stat().st_mtime
58-
except OSError:
59-
mtime = 0.0
60-
matches.append((mtime, str(p)))
61-
except OSError as e:
62-
return f"Error: {e}"
59+
for p in _walk_files(root, onerror=onerror):
60+
rel = p.relative_to(root)
61+
rel_parts = rel.parts
62+
if any(part.startswith(".") for part in rel_parts[:-1]):
63+
continue
64+
if max_depth is not None and len(rel_parts) > max_depth:
65+
continue
66+
if not rx.fullmatch(rel.as_posix()):
67+
continue
68+
try:
69+
mtime = p.stat().st_mtime
70+
except OSError:
71+
mtime = 0.0
72+
matches.append((mtime, str(p)))
73+
74+
if not matches and errors:
75+
return f"Error: {errors[0]}"
6376

6477
matches.sort(key=lambda t: t[0], reverse=True)
6578
out = "\n".join(path for _, path in matches)
@@ -88,4 +101,4 @@ def run(self, args: dict, ctx: ToolContext) -> str:
88101
if git_root:
89102
return super().run(args, ctx)
90103

91-
return self._rglob_fallback(pattern, base, depth)
104+
return self._walk_fallback(pattern, base, depth)

‎python_agent_harness/tools/grep_win.py‎

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
99
``GrepWindows`` extends the fallback chain with a pure-Python
1010
``re``-based search: it walks the directory tree with
11-
:meth:`pathlib.Path.rglob`, applies the regex to each file, and
12-
collects matches with line numbers and optional context lines. This
13-
ensures the Grep tool always works on a stock Windows install with
14-
only Python and Git installed.
11+
:func:`os.walk` (via :func:`_walk_files`), applies the regex to each
12+
file, and collects matches with line numbers and optional context
13+
lines. This ensures the Grep tool always works on a stock Windows
14+
install with only Python and Git installed.
1515
1616
Only the fallback strategy differs; the git path (``git grep -P``),
1717
the tool name, and the result format are inherited so callers, the
@@ -26,7 +26,7 @@
2626
import subprocess
2727
from pathlib import Path
2828

29-
from .filesystem import _spool
29+
from .filesystem import _spool, _walk_files
3030
from .grep import Grep, _grep_out
3131

3232

@@ -98,17 +98,11 @@ def _python_grep(self, regex: str, path: str, glob: str | None, context: int | N
9898
files = [Path(path)]
9999
else:
100100
root = Path(path)
101-
collected: list[Path] = []
102-
try:
103-
for p in root.rglob("*"):
104-
if not p.is_file():
105-
continue
106-
if any(part.startswith(".") for part in p.relative_to(root).parts[:-1]):
107-
continue
108-
collected.append(p)
109-
except OSError:
110-
pass
111-
files = collected
101+
files = [
102+
p
103+
for p in _walk_files(root)
104+
if not any(part.startswith(".") for part in p.relative_to(root).parts[:-1])
105+
]
112106

113107
for file_path in files:
114108
if match_count >= max_matches:

0 commit comments

Comments
 (0)