From 89a4a47b66d31352e074f561709379c14ea1d04a Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Thu, 13 Jul 2023 01:27:07 -0700 Subject: [PATCH 01/15] Add glob_to_re function by @godlygeek --- bump_pydantic/main.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/bump_pydantic/main.py b/bump_pydantic/main.py index 7f1c282..ef19d66 100644 --- a/bump_pydantic/main.py +++ b/bump_pydantic/main.py @@ -1,7 +1,9 @@ import difflib +import fnmatch import functools import multiprocessing import os +import re import time import traceback from pathlib import Path @@ -32,6 +34,21 @@ def version_callback(value: bool): raise Exit() +def glob_to_re(pattern: str) -> str: + """Translate a glob pattern to a regular expression for matching.""" + fragments = [] + for part in re.split(r"/|\\", pattern): + if part == "**": + fragment = r".*(?:/|\\|\Z)" + else: + fragment = fnmatch.translate(part) + fragment = fragment.replace(r"(?s:", r"(?:") + fragment = fragment.replace(r".*", r"[^/\\]*") + fragment = fragment.replace(r"\Z", r"(?:/|\\|\Z)") + fragments.append(fragment) + return rf"(?s:{''.join(fragments)})\Z" + + @app.callback() def main( path: Path = Argument(..., exists=True, dir_okay=True, allow_dash=False), From 436390e010ab56160187f8ac8eb0aedabca23e0e Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Thu, 13 Jul 2023 01:33:19 -0700 Subject: [PATCH 02/15] Add option for ignore patterns --- bump_pydantic/main.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/bump_pydantic/main.py b/bump_pydantic/main.py index ef19d66..7d32371 100644 --- a/bump_pydantic/main.py +++ b/bump_pydantic/main.py @@ -27,6 +27,10 @@ P = ParamSpec("P") T = TypeVar("T") +DEFAULT_IGNORES = [ + ".venv/**", +] + def version_callback(value: bool): if value: @@ -49,11 +53,17 @@ def glob_to_re(pattern: str) -> str: return rf"(?s:{''.join(fragments)})\Z" +def match_glob(path: Path, pattern: str) -> bool: + """Check if a path matches a glob pattern.""" + return bool(re.fullmatch(glob_to_re(pattern), str(path))) + + @app.callback() def main( path: Path = Argument(..., exists=True, dir_okay=True, allow_dash=False), disable: List[Rule] = Option(default=[], help="Disable a rule."), diff: bool = Option(False, help="Show diff instead of applying changes."), + ignore: List[str] = Option(default=DEFAULT_IGNORES, help="Ignore a path glob pattern."), log_file: Path = Option("log.txt", help="Log errors to this file."), version: bool = Option( None, @@ -74,11 +84,12 @@ def main( if os.path.isfile(path): package = path.parent - files = [str(path.relative_to("."))] + files = [path] else: package = path - files_str = list(package.glob("**/*.py")) - files = [str(file.relative_to(".")) for file in files_str] + files = list(package.glob("**/*.py")) + + files = [str(file.relative_to(".")) for file in files if not any(match_glob(file, pattern) for pattern in ignore)] console.log(f"Found {len(files)} files to process") From 8b0cd0ec08a4792c6f6b707a02fd04fe2f1508e3 Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Thu, 13 Jul 2023 01:33:41 -0700 Subject: [PATCH 03/15] Exit gracefully when no files are found --- bump_pydantic/main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bump_pydantic/main.py b/bump_pydantic/main.py index 7d32371..60a8608 100644 --- a/bump_pydantic/main.py +++ b/bump_pydantic/main.py @@ -93,6 +93,9 @@ def main( console.log(f"Found {len(files)} files to process") + if not files: + raise Exit() + providers = {FullyQualifiedNameProvider, ScopeProvider} metadata_manager = FullRepoManager(".", files, providers=providers) # type: ignore[arg-type] metadata_manager.resolve_cache() From aa6e4f0ea3c4b54337a695d812e6c3e97540829e Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Thu, 13 Jul 2023 01:35:06 -0700 Subject: [PATCH 04/15] Disable expanding args on windows If windows_expand_args is True, command line option values like .venv/** will be turned into .venv\\ --- bump_pydantic/main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bump_pydantic/main.py b/bump_pydantic/main.py index 60a8608..7778b1b 100644 --- a/bump_pydantic/main.py +++ b/bump_pydantic/main.py @@ -233,3 +233,6 @@ def color_diff(console: Console, lines: Iterable[str]) -> None: console.print(line, style="blue") else: console.print(line, style="white") + + +app = functools.partial(app, windows_expand_args=False) From a9685983f54437eb1496c50b240f23edfbc39cd8 Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Thu, 13 Jul 2023 02:10:27 -0700 Subject: [PATCH 05/15] Make entrypoint a separate partial --- bump_pydantic/__main__.py | 4 ++-- bump_pydantic/main.py | 2 ++ pyproject.toml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bump_pydantic/__main__.py b/bump_pydantic/__main__.py index 10ff24e..3fa895d 100644 --- a/bump_pydantic/__main__.py +++ b/bump_pydantic/__main__.py @@ -1,4 +1,4 @@ -from bump_pydantic.main import app +from bump_pydantic.main import entrypoint if __name__ == "__main__": - app() + entrypoint() diff --git a/bump_pydantic/main.py b/bump_pydantic/main.py index 7778b1b..3bb08fc 100644 --- a/bump_pydantic/main.py +++ b/bump_pydantic/main.py @@ -24,6 +24,8 @@ app = Typer(invoke_without_command=True, add_completion=False) +entrypoint = functools.partial(app, windows_expand_args=False) + P = ParamSpec("P") T = TypeVar("T") diff --git a/pyproject.toml b/pyproject.toml index 9836a1b..eb536fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ Issues = "https://github.com/pydantic/bump-pydantic/issues" Source = "https://github.com/pydantic/bump-pydantic" [project.scripts] -bump-pydantic = "bump_pydantic.main:app" +bump-pydantic = "bump_pydantic.main:entrypoint" [tool.hatch.version] path = "bump_pydantic/__init__.py" From b9fbfeca555e0953266d6e54355f82967f9ba7a0 Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Thu, 13 Jul 2023 02:17:36 -0700 Subject: [PATCH 06/15] Fix linting errors --- bump_pydantic/main.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bump_pydantic/main.py b/bump_pydantic/main.py index 3bb08fc..9bae0a0 100644 --- a/bump_pydantic/main.py +++ b/bump_pydantic/main.py @@ -86,12 +86,13 @@ def main( if os.path.isfile(path): package = path.parent - files = [path] + all_files = [path] else: package = path - files = list(package.glob("**/*.py")) + all_files = list(package.glob("**/*.py")) - files = [str(file.relative_to(".")) for file in files if not any(match_glob(file, pattern) for pattern in ignore)] + filtered_files = [file for file in all_files if not any(match_glob(file, pattern) for pattern in ignore)] + files = [str(file.relative_to(".")) for file in filtered_files] console.log(f"Found {len(files)} files to process") From fa50122df71a3ea6e804a0bdfcd9b491a859be81 Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Fri, 14 Jul 2023 14:21:49 -0400 Subject: [PATCH 07/15] Update bump_pydantic/main.py Co-authored-by: Marcelo Trylesinski --- bump_pydantic/main.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bump_pydantic/main.py b/bump_pydantic/main.py index 9bae0a0..89e95d4 100644 --- a/bump_pydantic/main.py +++ b/bump_pydantic/main.py @@ -29,9 +29,7 @@ P = ParamSpec("P") T = TypeVar("T") -DEFAULT_IGNORES = [ - ".venv/**", -] +DEFAULT_IGNORES = [".venv/**"] def version_callback(value: bool): From 01cf8832cc01a8f2d57ecd08bfd7575c48cfb41f Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Sat, 15 Jul 2023 15:10:35 -0700 Subject: [PATCH 08/15] Move glob functions to helpers module, make glob_to_re more reliable --- bump_pydantic/helpers.py | 46 ++++++++++++++++++++++++++++++++++++++++ bump_pydantic/main.py | 22 +------------------ 2 files changed, 47 insertions(+), 21 deletions(-) create mode 100644 bump_pydantic/helpers.py diff --git a/bump_pydantic/helpers.py b/bump_pydantic/helpers.py new file mode 100644 index 0000000..26ab87c --- /dev/null +++ b/bump_pydantic/helpers.py @@ -0,0 +1,46 @@ +import fnmatch +import re +from pathlib import Path + +MATCH_SEP = r"(?:/|\\)" +MATCH_SEP_OR_END = r"(?:/|\\|\Z)" +MATCH_NON_RECURSIVE = r"[^/\\]*" +MATCH_RECURSIVE = r"(?:.*)" + + +def glob_to_re(pattern: str) -> str: + """Translate a glob pattern to a regular expression for matching.""" + fragments = [] + for segment in re.split(r"/|\\", pattern): + if segment == "": + continue + if segment == "**": + # Remove previous separator match, so the recursive match can match zero or more segments. + if fragments and fragments[-1] == MATCH_SEP: + fragments.pop() + fragments.append(MATCH_RECURSIVE) + elif "**" in segment: + raise ValueError("invalid pattern: '**' can only be an entire path component") + else: + fragment = fnmatch.translate(segment) + fragment = fragment.replace(r"(?s:", r"(?:") + fragment = fragment.replace(r".*", MATCH_NON_RECURSIVE) + fragment = fragment.replace(r"\Z", r"") + fragments.append(fragment) + fragments.append(MATCH_SEP) + # Remove trailing MATCH_SEP, so it can be replaced with MATCH_SEP_OR_END. + if fragments and fragments[-1] == MATCH_SEP: + fragments.pop() + fragments.append(MATCH_SEP_OR_END) + return rf"(?s:{''.join(fragments)})" + + +def match_glob(path: Path, pattern: str) -> bool: + """Check if a path matches a glob pattern. + + If the pattern ends with a directory separator, the path must be a directory. + """ + match = bool(re.fullmatch(glob_to_re(pattern), str(path))) + if pattern.endswith("/") or pattern.endswith("\\"): + return match and path.is_dir() + return match diff --git a/bump_pydantic/main.py b/bump_pydantic/main.py index 89e95d4..4c822ae 100644 --- a/bump_pydantic/main.py +++ b/bump_pydantic/main.py @@ -3,7 +3,6 @@ import functools import multiprocessing import os -import re import time import traceback from pathlib import Path @@ -21,6 +20,7 @@ from bump_pydantic import __version__ from bump_pydantic.codemods import Rule, gather_codemods from bump_pydantic.codemods.class_def_visitor import ClassDefVisitor +from bump_pydantic.helpers import match_glob app = Typer(invoke_without_command=True, add_completion=False) @@ -38,26 +38,6 @@ def version_callback(value: bool): raise Exit() -def glob_to_re(pattern: str) -> str: - """Translate a glob pattern to a regular expression for matching.""" - fragments = [] - for part in re.split(r"/|\\", pattern): - if part == "**": - fragment = r".*(?:/|\\|\Z)" - else: - fragment = fnmatch.translate(part) - fragment = fragment.replace(r"(?s:", r"(?:") - fragment = fragment.replace(r".*", r"[^/\\]*") - fragment = fragment.replace(r"\Z", r"(?:/|\\|\Z)") - fragments.append(fragment) - return rf"(?s:{''.join(fragments)})\Z" - - -def match_glob(path: Path, pattern: str) -> bool: - """Check if a path matches a glob pattern.""" - return bool(re.fullmatch(glob_to_re(pattern), str(path))) - - @app.callback() def main( path: Path = Argument(..., exists=True, dir_okay=True, allow_dash=False), From 3ad76499be338b4f1fe30e0319d6b3ff69847388 Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Sat, 15 Jul 2023 15:11:29 -0700 Subject: [PATCH 09/15] Add tests for match_glob --- tests/unit/test_glob.py | 70 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/unit/test_glob.py diff --git a/tests/unit/test_glob.py b/tests/unit/test_glob.py new file mode 100644 index 0000000..3907fd4 --- /dev/null +++ b/tests/unit/test_glob.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from bump_pydantic.helpers import glob_to_re, match_glob + + +class TestGlob: + match_glob_values: list[tuple[str, Path, bool]] = [ + ("foo", Path("foo"), True), + ("foo", Path("bar"), False), + ("foo", Path("foo/bar"), False), + ("*", Path("foo"), True), + ("*", Path("bar"), True), + ("*", Path("foo/bar"), False), + ("**", Path("foo"), True), + ("**", Path("foo/bar"), True), + ("**", Path("foo/bar/baz/qux"), True), + ("foo/bar", Path("foo/bar"), True), + ("foo/bar", Path("foo"), False), + ("foo/bar", Path("far"), False), + ("foo/bar", Path("foo/foo"), False), + ("foo/*", Path("foo/bar"), True), + ("foo/*", Path("foo/bar/baz"), False), + ("foo/*", Path("foo"), False), + ("foo/*", Path("bar"), False), + ("foo/**", Path("foo/bar"), True), + ("foo/**", Path("foo/bar/baz"), True), + ("foo/**", Path("foo/bar/baz/qux"), True), + ("foo/**", Path("foo"), True), + ("foo/**", Path("bar"), False), + ("foo/**/bar", Path("foo/bar"), True), + ("foo/**/bar", Path("foo/baz/bar"), True), + ("foo/**/bar", Path("foo/baz/qux/bar"), True), + ("foo/**/bar", Path("foo/baz/qux"), False), + ("foo/**/bar", Path("foo/bar/baz"), False), + ("foo/**/bar", Path("foo/bar/bar"), True), + ("foo/**/bar", Path("foo"), False), + ("foo/**/bar", Path("bar"), False), + ("foo/**/*/bar", Path("foo/bar"), False), + ("foo/**/*/bar", Path("foo/baz/bar"), True), + ("foo/**/*/bar", Path("foo/baz/qux/bar"), True), + ("foo/**/*/bar", Path("foo/baz/qux"), False), + ("foo/**/*/bar", Path("foo/bar/baz"), False), + ("foo/**/*/bar", Path("foo/bar/bar"), True), + ("foo/**/*/bar", Path("foo"), False), + ("foo/**/*/bar", Path("bar"), False), + ("foo/ba*", Path("foo/bar"), True), + ("foo/ba*", Path("foo/baz"), True), + ("foo/ba*", Path("foo/qux"), False), + ("foo/ba*", Path("foo/baz/qux"), False), + ("foo/ba*", Path("foo/bar/baz"), False), + ("foo/ba*", Path("foo"), False), + ("foo/ba*", Path("bar"), False), + ("foo/**/ba*/*/qux", Path("foo/a/b/c/bar/a/qux"), True), + ("foo/**/ba*/*/qux", Path("foo/a/b/c/baz/a/qux"), True), + ("foo/**/ba*/*/qux", Path("foo/a/bar/a/qux"), True), + ("foo/**/ba*/*/qux", Path("foo/baz/a/qux"), True), + ("foo/**/ba*/*/qux", Path("foo/baz/qux"), False), + ("foo/**/ba*/*/qux", Path("foo/a/b/c/qux/a/qux"), False), + ("foo/**/ba*/*/qux", Path("foo"), False), + ("foo/**/ba*/*/qux", Path("bar"), False), + ] + + @pytest.mark.parametrize("pattern,path,expected", match_glob_values) + def test_match_glob(self, pattern: str, path: Path, expected: bool): + expr = glob_to_re(pattern) + assert match_glob(path, pattern) == expected, f"path: {path}, pattern: {pattern}, expr: {expr}" From baf62c74ef31527a0411e684996f57989405bd36 Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Sat, 15 Jul 2023 15:16:00 -0700 Subject: [PATCH 10/15] Fix ruff error in test_glob --- tests/unit/test_glob.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_glob.py b/tests/unit/test_glob.py index 3907fd4..d5c0e71 100644 --- a/tests/unit/test_glob.py +++ b/tests/unit/test_glob.py @@ -64,7 +64,7 @@ class TestGlob: ("foo/**/ba*/*/qux", Path("bar"), False), ] - @pytest.mark.parametrize("pattern,path,expected", match_glob_values) + @pytest.mark.parametrize(("pattern", "path", "expected"), match_glob_values) def test_match_glob(self, pattern: str, path: Path, expected: bool): expr = glob_to_re(pattern) assert match_glob(path, pattern) == expected, f"path: {path}, pattern: {pattern}, expr: {expr}" From 98e6d556ed45e510d7cffe8f370d73f08e509298 Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Sat, 15 Jul 2023 15:27:04 -0700 Subject: [PATCH 11/15] Type hint fragments list --- bump_pydantic/helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bump_pydantic/helpers.py b/bump_pydantic/helpers.py index 26ab87c..f070e1a 100644 --- a/bump_pydantic/helpers.py +++ b/bump_pydantic/helpers.py @@ -1,6 +1,7 @@ import fnmatch import re from pathlib import Path +from typing import List MATCH_SEP = r"(?:/|\\)" MATCH_SEP_OR_END = r"(?:/|\\|\Z)" @@ -10,7 +11,7 @@ def glob_to_re(pattern: str) -> str: """Translate a glob pattern to a regular expression for matching.""" - fragments = [] + fragments: List[str] = [] for segment in re.split(r"/|\\", pattern): if segment == "": continue From f9191c5796ca02578d5604f770e5244f51fc6737 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 17 Jul 2023 18:54:15 +0200 Subject: [PATCH 12/15] rebase --- bump_pydantic/main.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bump_pydantic/main.py b/bump_pydantic/main.py index 4c822ae..420f290 100644 --- a/bump_pydantic/main.py +++ b/bump_pydantic/main.py @@ -1,5 +1,4 @@ import difflib -import fnmatch import functools import multiprocessing import os @@ -214,6 +213,3 @@ def color_diff(console: Console, lines: Iterable[str]) -> None: console.print(line, style="blue") else: console.print(line, style="white") - - -app = functools.partial(app, windows_expand_args=False) From 9900f9f639710cda1c70afa1afe50bb4b84e5e8f Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Mon, 17 Jul 2023 13:38:00 -0700 Subject: [PATCH 13/15] Rename helpers.py to glob_helpers.py --- bump_pydantic/{helpers.py => glob_helpers.py} | 0 bump_pydantic/main.py | 2 +- tests/unit/test_glob.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename bump_pydantic/{helpers.py => glob_helpers.py} (100%) diff --git a/bump_pydantic/helpers.py b/bump_pydantic/glob_helpers.py similarity index 100% rename from bump_pydantic/helpers.py rename to bump_pydantic/glob_helpers.py diff --git a/bump_pydantic/main.py b/bump_pydantic/main.py index 1960a4d..7f8ef19 100644 --- a/bump_pydantic/main.py +++ b/bump_pydantic/main.py @@ -19,7 +19,7 @@ from bump_pydantic import __version__ from bump_pydantic.codemods import Rule, gather_codemods from bump_pydantic.codemods.class_def_visitor import ClassDefVisitor -from bump_pydantic.helpers import match_glob +from bump_pydantic.glob_helpers import match_glob app = Typer(invoke_without_command=True, add_completion=False) diff --git a/tests/unit/test_glob.py b/tests/unit/test_glob.py index d5c0e71..f2be49b 100644 --- a/tests/unit/test_glob.py +++ b/tests/unit/test_glob.py @@ -4,7 +4,7 @@ import pytest -from bump_pydantic.helpers import glob_to_re, match_glob +from bump_pydantic.glob_helpers import glob_to_re, match_glob class TestGlob: From f8f418efaa5b87c48c429960cde38e00bebbfe1b Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Mon, 17 Jul 2023 13:38:51 -0700 Subject: [PATCH 14/15] Rename test_glob.py to test_glob_helpers.py --- tests/unit/{test_glob.py => test_glob_helpers.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/unit/{test_glob.py => test_glob_helpers.py} (99%) diff --git a/tests/unit/test_glob.py b/tests/unit/test_glob_helpers.py similarity index 99% rename from tests/unit/test_glob.py rename to tests/unit/test_glob_helpers.py index f2be49b..edbd0b8 100644 --- a/tests/unit/test_glob.py +++ b/tests/unit/test_glob_helpers.py @@ -7,7 +7,7 @@ from bump_pydantic.glob_helpers import glob_to_re, match_glob -class TestGlob: +class TestGlobHelpers: match_glob_values: list[tuple[str, Path, bool]] = [ ("foo", Path("foo"), True), ("foo", Path("bar"), False), From d43e64b0a81b7525ffd7cf95fbb76987f9aed3b8 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 18 Jul 2023 07:38:37 +0200 Subject: [PATCH 15/15] Add message when all files were filtered --- bump_pydantic/main.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bump_pydantic/main.py b/bump_pydantic/main.py index 7f8ef19..62259ff 100644 --- a/bump_pydantic/main.py +++ b/bump_pydantic/main.py @@ -71,9 +71,10 @@ def main( filtered_files = [file for file in all_files if not any(match_glob(file, pattern) for pattern in ignore)] files = [str(file.relative_to(".")) for file in filtered_files] - console.log(f"Found {len(files)} files to process") - - if not files: + if files: + console.log(f"Found {len(files)} files to process") + else: + console.log("No files to process.") raise Exit() providers = {FullyQualifiedNameProvider, ScopeProvider}