Skip to content

Commit 90f1bdb

Browse files
committed
backport ** on py3.10
1 parent 43a37bc commit 90f1bdb

File tree

4 files changed

+44
-17
lines changed

4 files changed

+44
-17
lines changed

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Exploring bundling options for generating the single-file release:
2121
## 🧪 Tests
2222
- verify every function has test coverage
2323
- check for redundant tests
24-
24+
- improve test_is_excluded_raw_gitignore_double_star_diff to test our backport on py 3.10
2525

2626
## 🧑‍💻 Development
2727

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,11 @@ ignore = [
8989
"D104", # Missing docstring in public package
9090
"D105", # Missing docstring in magic method
9191
"D107", # Missing docstring in `__init__`
92+
"D200", # One-line docstring should fit on one line
9293
"D203", # Force blank between class and docstring
9394
"D205", # 1 blank line required between summary line and description
9495
"D209", # Multi-line docstring closing quotes should be on a separate line
96+
"D212", # Multi-line docstring summary should start at the first line
9597
"D213", # Force first line of docstring """ to not have text
9698
"D400", # First line should end with a period
9799
"D401", # First line of docstring should be in imperative mood

src/pocket_build/utils.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
from collections.abc import Iterator
88
from contextlib import contextmanager
99
from dataclasses import dataclass
10-
from fnmatch import fnmatch
10+
from fnmatch import fnmatchcase, translate as _fnmatch_translate
11+
from functools import lru_cache
1112
from io import StringIO
1213
from pathlib import Path
1314
from typing import (
@@ -221,6 +222,41 @@ def is_excluded(path_entry: PathResolved, exclude_patterns: list[PathResolved])
221222
return is_excluded_raw(path, patterns, root)
222223

223224

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+
224260
def is_excluded_raw( # noqa: PLR0911
225261
path: Path | str,
226262
exclude_patterns: list[str],
@@ -266,22 +302,17 @@ def is_excluded_raw( # noqa: PLR0911
266302
for pattern in exclude_patterns:
267303
pat = pattern.replace("\\", "/")
268304

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-
274305
# If pattern is absolute and under root, adjust to relative form
275306
if pat.startswith(str(root)):
276307
try:
277308
pat_rel = str(Path(pat).relative_to(root)).replace("\\", "/")
278309
except ValueError:
279310
pat_rel = pat # not under root; treat as-is
280-
if fnmatch(rel, pat_rel):
311+
if _fnmatch_portable(rel, pat_rel):
281312
return True
282313

283314
# Otherwise treat pattern as relative glob
284-
if fnmatch(rel, pat):
315+
if _fnmatch_portable(rel, pat):
285316
return True
286317

287318
# Optional directory-only semantics

tests/0_independant/test_is_excluded_raw.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
- gitignore_double_star_diff — '**' not recursive unlike gitignore in ≤Py3.10.
1313
"""
1414

15-
import sys
1615
from pathlib import Path
1716

1817
import pocket_build.utils as mod_utils
@@ -173,7 +172,7 @@ def test_is_excluded_raw_gitignore_double_star_diff(tmp_path: Path) -> None:
173172
Result: True (Python ≥3.11)
174173
False (Python ≤3.10)
175174
Explanation:
176-
- In Python ≤3.10, fnmatch treats '**' as simple '*', matching only one level.
175+
- In Python ≤3.10, we backport 3.11 behaviour.
177176
- In Python ≥3.11, fnmatch matches recursively across directories.
178177
- Our code uses fnmatch directly, so it inherits the platform behavior.
179178
This test exists to document that difference, not to enforce one side.
@@ -189,12 +188,7 @@ def test_is_excluded_raw_gitignore_double_star_diff(tmp_path: Path) -> None:
189188
result = mod_utils.is_excluded_raw(nested, ["dir/**/*.py"], root)
190189

191190
# --- verify ---
192-
if sys.version_info >= (3, 11):
193-
# Python 3.11+ uses recursive '**'
194-
assert result, "Expected True on Python ≥3.11 where '**' is recursive"
195-
else:
196-
# Python 3.10 and earlier use single-level '*'
197-
assert not result, "Expected False on Python ≤3.10 where '**' is shallow"
191+
assert result, "Expected True on Python ≥3.11 where '**' is recursive"
198192

199193

200194
def test_is_excluded_wrapper_delegates(tmp_path: Path) -> None:

0 commit comments

Comments
 (0)