file_length: match repo exemptions without PurePath.full_match (3.13-only)

The TachiyomiSY fork's first CI run died with AttributeError on the
runner's Python 3.12. Globs now go through a small tested translation
(** spans directories, * and ? stop at a slash) that runs on any 3.x.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Yc4TiPjhbQLfH35QBPfrbP
This commit is contained in:
Krzysztof Rudnicki 2026-09-12 19:04:36 +02:00
parent 879265b966
commit ee276cd8e5
2 changed files with 52 additions and 3 deletions

View File

@ -17,9 +17,10 @@ fails closed on it (exit 2) instead of honouring it silently.
from __future__ import annotations from __future__ import annotations
import re
from dataclasses import dataclass from dataclasses import dataclass
from functools import cache from functools import cache
from pathlib import Path, PurePosixPath from pathlib import Path
#: Basename of the per-repo exemption list, looked up in the gate's cwd. #: Basename of the per-repo exemption list, looked up in the gate's cwd.
EXEMPT_FILE = ".file-length-exempt" EXEMPT_FILE = ".file-length-exempt"
@ -53,6 +54,35 @@ def parse(text: str) -> list[Exemption]:
return entries return entries
def glob_to_regex(pattern: str) -> re.Pattern[str]:
"""A repo-relative glob as an anchored regex.
`**` spans directories, `*` and `?` stop at a slash. Hand-rolled rather
than `PurePath.full_match` because that needs Python 3.13 and the gate
runs on whatever `python3` a CI runner ships (3.12 broke it in the
TachiyomiSY fork's first CI run).
"""
out: list[str] = []
i = 0
while i < len(pattern):
char = pattern[i]
if pattern.startswith("**", i):
out.append(".*")
i += 2
if pattern.startswith("/", i):
out[-1] = "(?:.*/)?"
i += 1
continue
if char == "*":
out.append("[^/]*")
elif char == "?":
out.append("[^/]")
else:
out.append(re.escape(char))
i += 1
return re.compile("^" + "".join(out) + "$")
@cache @cache
def load(root: Path) -> tuple[Exemption, ...]: def load(root: Path) -> tuple[Exemption, ...]:
"""The exemptions for the repo at `root`, empty when it has no file.""" """The exemptions for the repo at `root`, empty when it has no file."""
@ -72,10 +102,10 @@ def repo_exempt_reason(path: Path, root: Path) -> str | None:
if not entries: if not entries:
return None return None
try: try:
relative = PurePosixPath(path.relative_to(root).as_posix()) relative = path.relative_to(root).as_posix()
except ValueError: except ValueError:
return None return None
for entry in entries: for entry in entries:
if relative.full_match(entry.pattern): if glob_to_regex(entry.pattern).match(relative):
return f"repo exemption: {entry.reason}" return f"repo exemption: {entry.reason}"
return None return None

View File

@ -10,6 +10,7 @@ from file_length.repo_exempt import (
EXEMPT_FILE, EXEMPT_FILE,
ExemptFileError, ExemptFileError,
Exemption, Exemption,
glob_to_regex,
load, load,
parse, parse,
repo_exempt_reason, repo_exempt_reason,
@ -64,3 +65,21 @@ def test_reason_none_for_paths_outside_root(tmp_path: Path) -> None:
write(tmp_path, EXEMPT_FILE, "**/*.kt # everything\n") write(tmp_path, EXEMPT_FILE, "**/*.kt # everything\n")
outside = tmp_path.parent / "elsewhere.kt" outside = tmp_path.parent / "elsewhere.kt"
assert repo_exempt_reason(outside, tmp_path) is None assert repo_exempt_reason(outside, tmp_path) is None
@pytest.mark.parametrize(
("pattern", "hit", "miss"),
[
("a/*.kt", "a/x.kt", "a/b/x.kt"),
("a/**/*.kt", "a/b/c/x.kt", "b/x.kt"),
("a/**/*.kt", "a/x.kt", "a/x.kts"),
("**/tags/*.kt", "tags/x.kt", "tags/sub/x.kt"),
("a/?.kt", "a/x.kt", "a/xy.kt"),
("a/**", "a/b/c.kt", "b/a"),
("a.b", "a.b", "axb"),
],
)
def test_glob_to_regex(pattern: str, hit: str, miss: str) -> None:
regex = glob_to_regex(pattern)
assert regex.match(hit), (pattern, hit)
assert not regex.match(miss), (pattern, miss)