Skip to content

Commit

Permalink
Adds glob_exclude file specification parameter.
Browse files Browse the repository at this point in the history
User can prune the files resolved via the `glob` parameter.

Fixes #184
  • Loading branch information
coordt committed May 1, 2024
1 parent a7052ef commit 420e3bd
Show file tree
Hide file tree
Showing 7 changed files with 121 additions and 5 deletions.
1 change: 1 addition & 0 deletions bumpversion/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class FileChange(BaseModel):
ignore_missing_file: bool
filename: Optional[str] = None
glob: Optional[str] = None # Conflicts with filename. If both are specified, glob wins
glob_exclude: Optional[List[str]] = None
key_path: Optional[str] = None # If specified, and has an appropriate extension, will be treated as a data file

def __hash__(self):
Expand Down
24 changes: 22 additions & 2 deletions bumpversion/config/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

from __future__ import annotations

import fnmatch
import glob
from typing import Dict, List
import re
from typing import Dict, List, Pattern

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


def glob_exclude_pattern(glob_excludes: List[str]) -> Pattern:
"""Convert a list of glob patterns to a regular expression that matches excluded files."""
glob_excludes = glob_excludes or []
patterns = []

for pat in glob_excludes:
if not pat:
continue
elif pat.endswith("/"):
patterns.append(f"{pat}**") # assume they mean exclude every file in that directory
else:
patterns.append(pat)
return re.compile("|".join([fnmatch.translate(pat) for pat in patterns])) if patterns else re.compile(r"^$")


def resolve_glob_files(file_cfg: FileChange) -> List[FileChange]:
"""
Return a list of file configurations that match the glob pattern.
Expand All @@ -58,8 +75,11 @@ def resolve_glob_files(file_cfg: FileChange) -> List[FileChange]:
Returns:
A list of resolved file configurations according to the pattern.
"""
files = []
files: List[FileChange] = []
exclude_matcher = glob_exclude_pattern(file_cfg.glob_exclude or [])
for filename_glob in glob.glob(file_cfg.glob, recursive=True):
if exclude_matcher.match(filename_glob):
continue
new_file_cfg = file_cfg.model_copy()
new_file_cfg.filename = filename_glob
new_file_cfg.glob = None
Expand Down
14 changes: 14 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,20 @@ The glob pattern specifying the files to modify.

‡ 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.

### glob_exclude

::: field-list
required
: No

default
: empty

type
: list of string

A list of glob patterns to exclude from the files found via the `glob` parameter. Does nothing if `filename` is specified.


### parse

Expand Down
3 changes: 3 additions & 0 deletions tests/fixtures/basic_cfg_expected.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
'excluded_paths': [],
'files': [{'filename': 'setup.py',
'glob': None,
'glob_exclude': None,
'ignore_missing_file': False,
'ignore_missing_version': False,
'key_path': None,
Expand All @@ -16,6 +17,7 @@
'{major}.{minor}.{patch}')},
{'filename': 'bumpversion/__init__.py',
'glob': None,
'glob_exclude': None,
'ignore_missing_file': False,
'ignore_missing_version': False,
'key_path': None,
Expand All @@ -27,6 +29,7 @@
'{major}.{minor}.{patch}')},
{'filename': 'CHANGELOG.md',
'glob': None,
'glob_exclude': None,
'ignore_missing_file': False,
'ignore_missing_version': False,
'key_path': None,
Expand Down
3 changes: 3 additions & 0 deletions tests/fixtures/basic_cfg_expected.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ excluded_paths:
files:
- filename: "setup.py"
glob: null
glob_exclude: null
ignore_missing_file: false
ignore_missing_version: false
key_path: null
Expand All @@ -19,6 +20,7 @@ files:
- "{major}.{minor}.{patch}"
- filename: "bumpversion/__init__.py"
glob: null
glob_exclude: null
ignore_missing_file: false
ignore_missing_version: false
key_path: null
Expand All @@ -31,6 +33,7 @@ files:
- "{major}.{minor}.{patch}"
- filename: "CHANGELOG.md"
glob: null
glob_exclude: null
ignore_missing_file: false
ignore_missing_version: false
key_path: null
Expand Down
3 changes: 3 additions & 0 deletions tests/fixtures/basic_cfg_expected_full.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
{
"filename": "setup.py",
"glob": null,
"glob_exclude": null,
"ignore_missing_file": false,
"ignore_missing_version": false,
"key_path": null,
Expand All @@ -23,6 +24,7 @@
{
"filename": "bumpversion/__init__.py",
"glob": null,
"glob_exclude": null,
"ignore_missing_file": false,
"ignore_missing_version": false,
"key_path": null,
Expand All @@ -38,6 +40,7 @@
{
"filename": "CHANGELOG.md",
"glob": null,
"glob_exclude": null,
"ignore_missing_file": false,
"ignore_missing_version": false,
"key_path": null,
Expand Down
78 changes: 75 additions & 3 deletions tests/test_config/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"""Tests of the configuration utilities."""

from pathlib import Path
from typing import Any
from typing import Any, List

import tomlkit
import pytest
from pytest import param

from bumpversion.config.utils import get_all_file_configs, get_all_part_configs, resolve_glob_files
from bumpversion.config.utils import get_all_file_configs, resolve_glob_files, glob_exclude_pattern
from bumpversion.config.models import FileChange
from bumpversion.config import DEFAULTS
from tests.conftest import inside_dir
Expand Down Expand Up @@ -37,7 +37,7 @@ def test_uses_defaults_for_missing_keys(self, tmp_path: Path):

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

Expand Down Expand Up @@ -113,3 +113,75 @@ def test_all_attributes_are_copied(self, tmp_path: Path):
assert resolved_file.ignore_missing_version is True
assert resolved_file.ignore_missing_file is True
assert resolved_file.regex is True

def test_excludes_configured_patterns(self, tmp_path: Path):
"""Test that excludes configured patterns work."""
file1 = tmp_path.joinpath("setup.cfg")
file2 = tmp_path.joinpath("subdir/setup.cfg")
file1.touch()
file2.parent.mkdir()
file2.touch()

file_cfg = FileChange(
filename=None,
glob="**/*.cfg",
glob_exclude=["subdir/**"],
key_path=None,
parse=r"v(?P<major>\d+)",
serialize=("v{major}",),
search="v{current_version}",
replace="v{new_version}",
ignore_missing_version=True,
ignore_missing_file=True,
regex=True,
)
with inside_dir(tmp_path):
resolved_files = resolve_glob_files(file_cfg)

assert len(resolved_files) == 1


class TestGlobExcludePattern:
"""Tests for the glob_exclude_pattern function."""

@pytest.mark.parametrize(["empty_pattern"], [param([], id="empty list"), param(None, id="None")])
def test_empty_list_returns_empty_string_pattern(self, empty_pattern: Any):
"""When passed an empty list, it should return a pattern that only matches an empty string."""
assert glob_exclude_pattern(empty_pattern).pattern == r"^$"

@pytest.mark.parametrize(
["patterns", "expected"],
[
param(["foo.txt", ""], "(?s:foo\\.txt)\\Z", id="empty string"),
param(["foo.txt", None], "(?s:foo\\.txt)\\Z", id="None value"),
param(["foo.txt", "", "bar.txt"], "(?s:foo\\.txt)\\Z|(?s:bar\\.txt)\\Z", id="Empty string in the middle"),
],
)
def test_empty_values_are_excluded(self, patterns: List, expected: str):
"""Empty values are excluded from the compiled pattern."""
assert glob_exclude_pattern(patterns).pattern == expected

def test_list_of_empty_patterns_return_empty_string_pattern(self):
"""When passed a list of empty strings, it should return a pattern that only matches an empty string."""
assert glob_exclude_pattern(["", "", None]).pattern == r"^$"

def test_trailing_slash_appends_stars(self):
"""
When a string has a trailing slash, two asterisks are appended.
`fnmatch.translate` converts `**` to `.*`
"""
assert glob_exclude_pattern(["foo/"]).pattern == "(?s:foo/.*)\\Z"

@pytest.mark.parametrize(
["file_path", "excluded"],
[param("node_modules/foo/file.js", True), param("build/foo/file.js", True), param("code/file.js", False)],
)
def test_output_pattern_matches_files(self, file_path: str, excluded: bool):
"""The output pattern should match file paths appropriately."""
exclude_matcher = glob_exclude_pattern(["node_modules/", "build/"])

if excluded:
assert exclude_matcher.match(file_path)
else:
assert not exclude_matcher.match(file_path)

0 comments on commit 420e3bd

Please sign in to comment.