Skip to content

Commit 393dc89

Browse files
lemonyteKludex
andauthored
✨ Add ignore patterns (#92)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
1 parent b210d2f commit 393dc89

File tree

5 files changed

+136
-7
lines changed

5 files changed

+136
-7
lines changed

bump_pydantic/__main__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from bump_pydantic.main import app
1+
from bump_pydantic.main import entrypoint
22

33
if __name__ == "__main__":
4-
app()
4+
entrypoint()

bump_pydantic/glob_helpers.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import fnmatch
2+
import re
3+
from pathlib import Path
4+
from typing import List
5+
6+
MATCH_SEP = r"(?:/|\\)"
7+
MATCH_SEP_OR_END = r"(?:/|\\|\Z)"
8+
MATCH_NON_RECURSIVE = r"[^/\\]*"
9+
MATCH_RECURSIVE = r"(?:.*)"
10+
11+
12+
def glob_to_re(pattern: str) -> str:
13+
"""Translate a glob pattern to a regular expression for matching."""
14+
fragments: List[str] = []
15+
for segment in re.split(r"/|\\", pattern):
16+
if segment == "":
17+
continue
18+
if segment == "**":
19+
# Remove previous separator match, so the recursive match can match zero or more segments.
20+
if fragments and fragments[-1] == MATCH_SEP:
21+
fragments.pop()
22+
fragments.append(MATCH_RECURSIVE)
23+
elif "**" in segment:
24+
raise ValueError("invalid pattern: '**' can only be an entire path component")
25+
else:
26+
fragment = fnmatch.translate(segment)
27+
fragment = fragment.replace(r"(?s:", r"(?:")
28+
fragment = fragment.replace(r".*", MATCH_NON_RECURSIVE)
29+
fragment = fragment.replace(r"\Z", r"")
30+
fragments.append(fragment)
31+
fragments.append(MATCH_SEP)
32+
# Remove trailing MATCH_SEP, so it can be replaced with MATCH_SEP_OR_END.
33+
if fragments and fragments[-1] == MATCH_SEP:
34+
fragments.pop()
35+
fragments.append(MATCH_SEP_OR_END)
36+
return rf"(?s:{''.join(fragments)})"
37+
38+
39+
def match_glob(path: Path, pattern: str) -> bool:
40+
"""Check if a path matches a glob pattern.
41+
42+
If the pattern ends with a directory separator, the path must be a directory.
43+
"""
44+
match = bool(re.fullmatch(glob_to_re(pattern), str(path)))
45+
if pattern.endswith("/") or pattern.endswith("\\"):
46+
return match and path.is_dir()
47+
return match

bump_pydantic/main.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,17 @@
1919
from bump_pydantic import __version__
2020
from bump_pydantic.codemods import Rule, gather_codemods
2121
from bump_pydantic.codemods.class_def_visitor import ClassDefVisitor
22+
from bump_pydantic.glob_helpers import match_glob
2223

2324
app = Typer(invoke_without_command=True, add_completion=False)
2425

26+
entrypoint = functools.partial(app, windows_expand_args=False)
27+
2528
P = ParamSpec("P")
2629
T = TypeVar("T")
2730

31+
DEFAULT_IGNORES = [".venv/**"]
32+
2833

2934
def version_callback(value: bool):
3035
if value:
@@ -37,6 +42,7 @@ def main(
3742
path: Path = Argument(..., exists=True, dir_okay=True, allow_dash=False),
3843
disable: List[Rule] = Option(default=[], help="Disable a rule."),
3944
diff: bool = Option(False, help="Show diff instead of applying changes."),
45+
ignore: List[str] = Option(default=DEFAULT_IGNORES, help="Ignore a path glob pattern."),
4046
log_file: Path = Option("log.txt", help="Log errors to this file."),
4147
version: bool = Option(
4248
None,
@@ -57,13 +63,19 @@ def main(
5763

5864
if os.path.isfile(path):
5965
package = path.parent
60-
files = [str(path.relative_to("."))]
66+
all_files = [path]
6167
else:
6268
package = path
63-
files_str = list(package.glob("**/*.py"))
64-
files = [str(file.relative_to(".")) for file in files_str]
69+
all_files = list(package.glob("**/*.py"))
70+
71+
filtered_files = [file for file in all_files if not any(match_glob(file, pattern) for pattern in ignore)]
72+
files = [str(file.relative_to(".")) for file in filtered_files]
6573

66-
console.log(f"Found {len(files)} files to process")
74+
if files:
75+
console.log(f"Found {len(files)} files to process")
76+
else:
77+
console.log("No files to process.")
78+
raise Exit()
6779

6880
providers = {FullyQualifiedNameProvider, ScopeProvider}
6981
metadata_manager = FullRepoManager(".", files, providers=providers) # type: ignore[arg-type]

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Issues = "https://github.com/pydantic/bump-pydantic/issues"
3030
Source = "https://github.com/pydantic/bump-pydantic"
3131

3232
[project.scripts]
33-
bump-pydantic = "bump_pydantic.main:app"
33+
bump-pydantic = "bump_pydantic.main:entrypoint"
3434

3535
[tool.hatch.version]
3636
path = "bump_pydantic/__init__.py"

tests/unit/test_glob_helpers.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
from bump_pydantic.glob_helpers import glob_to_re, match_glob
8+
9+
10+
class TestGlobHelpers:
11+
match_glob_values: list[tuple[str, Path, bool]] = [
12+
("foo", Path("foo"), True),
13+
("foo", Path("bar"), False),
14+
("foo", Path("foo/bar"), False),
15+
("*", Path("foo"), True),
16+
("*", Path("bar"), True),
17+
("*", Path("foo/bar"), False),
18+
("**", Path("foo"), True),
19+
("**", Path("foo/bar"), True),
20+
("**", Path("foo/bar/baz/qux"), True),
21+
("foo/bar", Path("foo/bar"), True),
22+
("foo/bar", Path("foo"), False),
23+
("foo/bar", Path("far"), False),
24+
("foo/bar", Path("foo/foo"), False),
25+
("foo/*", Path("foo/bar"), True),
26+
("foo/*", Path("foo/bar/baz"), False),
27+
("foo/*", Path("foo"), False),
28+
("foo/*", Path("bar"), False),
29+
("foo/**", Path("foo/bar"), True),
30+
("foo/**", Path("foo/bar/baz"), True),
31+
("foo/**", Path("foo/bar/baz/qux"), True),
32+
("foo/**", Path("foo"), True),
33+
("foo/**", Path("bar"), False),
34+
("foo/**/bar", Path("foo/bar"), True),
35+
("foo/**/bar", Path("foo/baz/bar"), True),
36+
("foo/**/bar", Path("foo/baz/qux/bar"), True),
37+
("foo/**/bar", Path("foo/baz/qux"), False),
38+
("foo/**/bar", Path("foo/bar/baz"), False),
39+
("foo/**/bar", Path("foo/bar/bar"), True),
40+
("foo/**/bar", Path("foo"), False),
41+
("foo/**/bar", Path("bar"), False),
42+
("foo/**/*/bar", Path("foo/bar"), False),
43+
("foo/**/*/bar", Path("foo/baz/bar"), True),
44+
("foo/**/*/bar", Path("foo/baz/qux/bar"), True),
45+
("foo/**/*/bar", Path("foo/baz/qux"), False),
46+
("foo/**/*/bar", Path("foo/bar/baz"), False),
47+
("foo/**/*/bar", Path("foo/bar/bar"), True),
48+
("foo/**/*/bar", Path("foo"), False),
49+
("foo/**/*/bar", Path("bar"), False),
50+
("foo/ba*", Path("foo/bar"), True),
51+
("foo/ba*", Path("foo/baz"), True),
52+
("foo/ba*", Path("foo/qux"), False),
53+
("foo/ba*", Path("foo/baz/qux"), False),
54+
("foo/ba*", Path("foo/bar/baz"), False),
55+
("foo/ba*", Path("foo"), False),
56+
("foo/ba*", Path("bar"), False),
57+
("foo/**/ba*/*/qux", Path("foo/a/b/c/bar/a/qux"), True),
58+
("foo/**/ba*/*/qux", Path("foo/a/b/c/baz/a/qux"), True),
59+
("foo/**/ba*/*/qux", Path("foo/a/bar/a/qux"), True),
60+
("foo/**/ba*/*/qux", Path("foo/baz/a/qux"), True),
61+
("foo/**/ba*/*/qux", Path("foo/baz/qux"), False),
62+
("foo/**/ba*/*/qux", Path("foo/a/b/c/qux/a/qux"), False),
63+
("foo/**/ba*/*/qux", Path("foo"), False),
64+
("foo/**/ba*/*/qux", Path("bar"), False),
65+
]
66+
67+
@pytest.mark.parametrize(("pattern", "path", "expected"), match_glob_values)
68+
def test_match_glob(self, pattern: str, path: Path, expected: bool):
69+
expr = glob_to_re(pattern)
70+
assert match_glob(path, pattern) == expected, f"path: {path}, pattern: {pattern}, expr: {expr}"

0 commit comments

Comments
 (0)