|
4 | 4 | in :class:`GlobTool` (which shells out to ``tree``) and the macOS |
5 | 5 | variant :class:`GlobMac` (which shells out to ``find``) both fail on |
6 | 6 | 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. |
9 | 9 |
|
10 | 10 | This is slower than the C-based ``tree``/``find`` on large directory |
11 | 11 | trees, but produces identical results and has no external dependencies |
|
19 | 19 | from __future__ import annotations |
20 | 20 |
|
21 | 21 | import os |
| 22 | +import re |
22 | 23 | from pathlib import Path |
23 | 24 |
|
24 | 25 | from .base import ToolContext |
25 | | -from .filesystem import _natnump, _spool |
| 26 | +from .filesystem import _glob_to_regex, _natnump, _spool, _walk_files |
26 | 27 | from .glob import GlobTool |
27 | 28 |
|
28 | 29 |
|
29 | 30 | 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. |
40 | 43 | """ |
41 | 44 | root = Path(base) |
42 | 45 | 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)) |
43 | 57 |
|
44 | 58 | 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]}" |
63 | 76 |
|
64 | 77 | matches.sort(key=lambda t: t[0], reverse=True) |
65 | 78 | out = "\n".join(path for _, path in matches) |
@@ -88,4 +101,4 @@ def run(self, args: dict, ctx: ToolContext) -> str: |
88 | 101 | if git_root: |
89 | 102 | return super().run(args, ctx) |
90 | 103 |
|
91 | | - return self._rglob_fallback(pattern, base, depth) |
| 104 | + return self._walk_fallback(pattern, base, depth) |
0 commit comments