|
7 | 7 | from collections.abc import Iterator |
8 | 8 | from contextlib import contextmanager |
9 | 9 | from dataclasses import dataclass |
10 | | -from fnmatch import fnmatch |
| 10 | +from fnmatch import fnmatchcase, translate as _fnmatch_translate |
| 11 | +from functools import lru_cache |
11 | 12 | from io import StringIO |
12 | 13 | from pathlib import Path |
13 | 14 | from typing import ( |
@@ -221,6 +222,41 @@ def is_excluded(path_entry: PathResolved, exclude_patterns: list[PathResolved]) |
221 | 222 | return is_excluded_raw(path, patterns, root) |
222 | 223 |
|
223 | 224 |
|
| 225 | +@lru_cache(maxsize=512) |
| 226 | +def _compile_glob_recursive(pattern: str) -> re.Pattern[str]: |
| 227 | + r""" |
| 228 | + Portable glob compiler: |
| 229 | + - On Python ≥ 3.11 or when '**' is absent, use the stock translation. |
| 230 | + - On Python ≤ 3.10 with '**', emulate recursive matching by joining |
| 231 | + translated chunks with '.*' (which can cross path separators). |
| 232 | + The returned regex uses the same '(?s:...)\Z' style as fnmatch.translate. |
| 233 | + """ |
| 234 | + if sys.version_info >= (3, 11) or "**" not in pattern: |
| 235 | + return re.compile(_fnmatch_translate(pattern)) |
| 236 | + |
| 237 | + # Split around '**' and translate each chunk using the stock translator. |
| 238 | + parts = pattern.split("**") |
| 239 | + chunk_regexes: list[str] = [] |
| 240 | + for part in parts: |
| 241 | + rx = _fnmatch_translate(part) |
| 242 | + # fnmatch.translate returns '(?s:...)\Z'; we need the inner '...' |
| 243 | + if rx.startswith("(?s:") and rx.endswith(")\\Z"): |
| 244 | + rx = rx[4:-2] |
| 245 | + chunk_regexes.append(rx) |
| 246 | + |
| 247 | + inner = ".*".join(chunk_regexes) # allow crossing '/' between chunks |
| 248 | + return re.compile(f"(?s:{inner})\\Z") |
| 249 | + |
| 250 | + |
| 251 | +def _fnmatch_portable(path: str, pattern: str) -> bool: |
| 252 | + """ |
| 253 | + A drop-in replacement for fnmatch.fnmatch that backports '**' recursion on 3.10. |
| 254 | + """ |
| 255 | + if sys.version_info >= (3, 11) or "**" not in pattern: |
| 256 | + return fnmatchcase(path, pattern) |
| 257 | + return bool(_compile_glob_recursive(pattern).match(path)) |
| 258 | + |
| 259 | + |
224 | 260 | def is_excluded_raw( # noqa: PLR0911 |
225 | 261 | path: Path | str, |
226 | 262 | exclude_patterns: list[str], |
@@ -266,22 +302,17 @@ def is_excluded_raw( # noqa: PLR0911 |
266 | 302 | for pattern in exclude_patterns: |
267 | 303 | pat = pattern.replace("\\", "/") |
268 | 304 |
|
269 | | - if "**" in pat and sys.version_info < (3, 11): |
270 | | - logger.trace( |
271 | | - f"'**' behaves non-recursively on Python {sys.version_info[:2]}", |
272 | | - ) |
273 | | - |
274 | 305 | # If pattern is absolute and under root, adjust to relative form |
275 | 306 | if pat.startswith(str(root)): |
276 | 307 | try: |
277 | 308 | pat_rel = str(Path(pat).relative_to(root)).replace("\\", "/") |
278 | 309 | except ValueError: |
279 | 310 | pat_rel = pat # not under root; treat as-is |
280 | | - if fnmatch(rel, pat_rel): |
| 311 | + if _fnmatch_portable(rel, pat_rel): |
281 | 312 | return True |
282 | 313 |
|
283 | 314 | # Otherwise treat pattern as relative glob |
284 | | - if fnmatch(rel, pat): |
| 315 | + if _fnmatch_portable(rel, pat): |
285 | 316 | return True |
286 | 317 |
|
287 | 318 | # Optional directory-only semantics |
|
0 commit comments