Skip to content

Commit 420e3bd

Browse files
committed
Adds glob_exclude file specification parameter.
User can prune the files resolved via the `glob` parameter. Fixes #184
1 parent a7052ef commit 420e3bd

File tree

7 files changed

+121
-5
lines changed

7 files changed

+121
-5
lines changed

bumpversion/config/models.py

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class FileChange(BaseModel):
3333
ignore_missing_file: bool
3434
filename: Optional[str] = None
3535
glob: Optional[str] = None # Conflicts with filename. If both are specified, glob wins
36+
glob_exclude: Optional[List[str]] = None
3637
key_path: Optional[str] = None # If specified, and has an appropriate extension, will be treated as a data file
3738

3839
def __hash__(self):

bumpversion/config/utils.py

+22-2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from __future__ import annotations
44

5+
import fnmatch
56
import glob
6-
from typing import Dict, List
7+
import re
8+
from typing import Dict, List, Pattern
79

810
from bumpversion.config.models import FileChange
911
from bumpversion.exceptions import BumpVersionError
@@ -48,6 +50,21 @@ def get_all_part_configs(config_dict: dict) -> Dict[str, VersionComponentSpec]:
4850
return part_configs
4951

5052

53+
def glob_exclude_pattern(glob_excludes: List[str]) -> Pattern:
54+
"""Convert a list of glob patterns to a regular expression that matches excluded files."""
55+
glob_excludes = glob_excludes or []
56+
patterns = []
57+
58+
for pat in glob_excludes:
59+
if not pat:
60+
continue
61+
elif pat.endswith("/"):
62+
patterns.append(f"{pat}**") # assume they mean exclude every file in that directory
63+
else:
64+
patterns.append(pat)
65+
return re.compile("|".join([fnmatch.translate(pat) for pat in patterns])) if patterns else re.compile(r"^$")
66+
67+
5168
def resolve_glob_files(file_cfg: FileChange) -> List[FileChange]:
5269
"""
5370
Return a list of file configurations that match the glob pattern.
@@ -58,8 +75,11 @@ def resolve_glob_files(file_cfg: FileChange) -> List[FileChange]:
5875
Returns:
5976
A list of resolved file configurations according to the pattern.
6077
"""
61-
files = []
78+
files: List[FileChange] = []
79+
exclude_matcher = glob_exclude_pattern(file_cfg.glob_exclude or [])
6280
for filename_glob in glob.glob(file_cfg.glob, recursive=True):
81+
if exclude_matcher.match(filename_glob):
82+
continue
6383
new_file_cfg = file_cfg.model_copy()
6484
new_file_cfg.filename = filename_glob
6585
new_file_cfg.glob = None

docs/reference/configuration.md

+14
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,20 @@ The glob pattern specifying the files to modify.
602602

603603
‡ This is only used with TOML configuration, and is only required if [`filename`](#filename) is _not_ specified. INI-style configuration files specify the glob pattern as part of the grouping.
604604

605+
### glob_exclude
606+
607+
::: field-list
608+
required
609+
: No
610+
611+
default
612+
: empty
613+
614+
type
615+
: list of string
616+
617+
A list of glob patterns to exclude from the files found via the `glob` parameter. Does nothing if `filename` is specified.
618+
605619

606620
### parse
607621

tests/fixtures/basic_cfg_expected.txt

+3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
'excluded_paths': [],
66
'files': [{'filename': 'setup.py',
77
'glob': None,
8+
'glob_exclude': None,
89
'ignore_missing_file': False,
910
'ignore_missing_version': False,
1011
'key_path': None,
@@ -16,6 +17,7 @@
1617
'{major}.{minor}.{patch}')},
1718
{'filename': 'bumpversion/__init__.py',
1819
'glob': None,
20+
'glob_exclude': None,
1921
'ignore_missing_file': False,
2022
'ignore_missing_version': False,
2123
'key_path': None,
@@ -27,6 +29,7 @@
2729
'{major}.{minor}.{patch}')},
2830
{'filename': 'CHANGELOG.md',
2931
'glob': None,
32+
'glob_exclude': None,
3033
'ignore_missing_file': False,
3134
'ignore_missing_version': False,
3235
'key_path': None,

tests/fixtures/basic_cfg_expected.yaml

+3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ excluded_paths:
77
files:
88
- filename: "setup.py"
99
glob: null
10+
glob_exclude: null
1011
ignore_missing_file: false
1112
ignore_missing_version: false
1213
key_path: null
@@ -19,6 +20,7 @@ files:
1920
- "{major}.{minor}.{patch}"
2021
- filename: "bumpversion/__init__.py"
2122
glob: null
23+
glob_exclude: null
2224
ignore_missing_file: false
2325
ignore_missing_version: false
2426
key_path: null
@@ -31,6 +33,7 @@ files:
3133
- "{major}.{minor}.{patch}"
3234
- filename: "CHANGELOG.md"
3335
glob: null
36+
glob_exclude: null
3437
ignore_missing_file: false
3538
ignore_missing_version: false
3639
key_path: null

tests/fixtures/basic_cfg_expected_full.json

+3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
{
99
"filename": "setup.py",
1010
"glob": null,
11+
"glob_exclude": null,
1112
"ignore_missing_file": false,
1213
"ignore_missing_version": false,
1314
"key_path": null,
@@ -23,6 +24,7 @@
2324
{
2425
"filename": "bumpversion/__init__.py",
2526
"glob": null,
27+
"glob_exclude": null,
2628
"ignore_missing_file": false,
2729
"ignore_missing_version": false,
2830
"key_path": null,
@@ -38,6 +40,7 @@
3840
{
3941
"filename": "CHANGELOG.md",
4042
"glob": null,
43+
"glob_exclude": null,
4144
"ignore_missing_file": false,
4245
"ignore_missing_version": false,
4346
"key_path": null,

tests/test_config/test_utils.py

+75-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
"""Tests of the configuration utilities."""
22

33
from pathlib import Path
4-
from typing import Any
4+
from typing import Any, List
55

66
import tomlkit
77
import pytest
88
from pytest import param
99

10-
from bumpversion.config.utils import get_all_file_configs, get_all_part_configs, resolve_glob_files
10+
from bumpversion.config.utils import get_all_file_configs, resolve_glob_files, glob_exclude_pattern
1111
from bumpversion.config.models import FileChange
1212
from bumpversion.config import DEFAULTS
1313
from tests.conftest import inside_dir
@@ -37,7 +37,7 @@ def test_uses_defaults_for_missing_keys(self, tmp_path: Path):
3737

3838
for key in FileChange.model_fields.keys():
3939
global_key = key if key != "ignore_missing_file" else "ignore_missing_files"
40-
if key not in ["filename", "glob", "key_path"]:
40+
if key not in ["filename", "glob", "key_path", "glob_exclude"]:
4141
file_val = getattr(file_configs[0], key)
4242
assert file_val == DEFAULTS[global_key]
4343

@@ -113,3 +113,75 @@ def test_all_attributes_are_copied(self, tmp_path: Path):
113113
assert resolved_file.ignore_missing_version is True
114114
assert resolved_file.ignore_missing_file is True
115115
assert resolved_file.regex is True
116+
117+
def test_excludes_configured_patterns(self, tmp_path: Path):
118+
"""Test that excludes configured patterns work."""
119+
file1 = tmp_path.joinpath("setup.cfg")
120+
file2 = tmp_path.joinpath("subdir/setup.cfg")
121+
file1.touch()
122+
file2.parent.mkdir()
123+
file2.touch()
124+
125+
file_cfg = FileChange(
126+
filename=None,
127+
glob="**/*.cfg",
128+
glob_exclude=["subdir/**"],
129+
key_path=None,
130+
parse=r"v(?P<major>\d+)",
131+
serialize=("v{major}",),
132+
search="v{current_version}",
133+
replace="v{new_version}",
134+
ignore_missing_version=True,
135+
ignore_missing_file=True,
136+
regex=True,
137+
)
138+
with inside_dir(tmp_path):
139+
resolved_files = resolve_glob_files(file_cfg)
140+
141+
assert len(resolved_files) == 1
142+
143+
144+
class TestGlobExcludePattern:
145+
"""Tests for the glob_exclude_pattern function."""
146+
147+
@pytest.mark.parametrize(["empty_pattern"], [param([], id="empty list"), param(None, id="None")])
148+
def test_empty_list_returns_empty_string_pattern(self, empty_pattern: Any):
149+
"""When passed an empty list, it should return a pattern that only matches an empty string."""
150+
assert glob_exclude_pattern(empty_pattern).pattern == r"^$"
151+
152+
@pytest.mark.parametrize(
153+
["patterns", "expected"],
154+
[
155+
param(["foo.txt", ""], "(?s:foo\\.txt)\\Z", id="empty string"),
156+
param(["foo.txt", None], "(?s:foo\\.txt)\\Z", id="None value"),
157+
param(["foo.txt", "", "bar.txt"], "(?s:foo\\.txt)\\Z|(?s:bar\\.txt)\\Z", id="Empty string in the middle"),
158+
],
159+
)
160+
def test_empty_values_are_excluded(self, patterns: List, expected: str):
161+
"""Empty values are excluded from the compiled pattern."""
162+
assert glob_exclude_pattern(patterns).pattern == expected
163+
164+
def test_list_of_empty_patterns_return_empty_string_pattern(self):
165+
"""When passed a list of empty strings, it should return a pattern that only matches an empty string."""
166+
assert glob_exclude_pattern(["", "", None]).pattern == r"^$"
167+
168+
def test_trailing_slash_appends_stars(self):
169+
"""
170+
When a string has a trailing slash, two asterisks are appended.
171+
172+
`fnmatch.translate` converts `**` to `.*`
173+
"""
174+
assert glob_exclude_pattern(["foo/"]).pattern == "(?s:foo/.*)\\Z"
175+
176+
@pytest.mark.parametrize(
177+
["file_path", "excluded"],
178+
[param("node_modules/foo/file.js", True), param("build/foo/file.js", True), param("code/file.js", False)],
179+
)
180+
def test_output_pattern_matches_files(self, file_path: str, excluded: bool):
181+
"""The output pattern should match file paths appropriately."""
182+
exclude_matcher = glob_exclude_pattern(["node_modules/", "build/"])
183+
184+
if excluded:
185+
assert exclude_matcher.match(file_path)
186+
else:
187+
assert not exclude_matcher.match(file_path)

0 commit comments

Comments
 (0)