Skip to content

Commit 0210d74

Browse files
committed
Adds regular expression searching ability.
- Search strings are treated as regular expressions after the initial substitution
1 parent a0481b7 commit 0210d74

File tree

3 files changed

+135
-35
lines changed

3 files changed

+135
-35
lines changed

bumpversion/files.py

+63-31
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Methods for changing files."""
22
import glob
33
import logging
4+
import re
45
from difflib import context_diff
56
from typing import List, MutableMapping, Optional
67

@@ -30,6 +31,22 @@ def __init__(
3031
self.version_config = VersionConfig(
3132
self.parse, self.serialize, self.search, self.replace, version_config.part_configs
3233
)
34+
self._newlines: Optional[str] = None
35+
36+
def get_file_contents(self) -> str:
37+
"""Return the contents of the file."""
38+
with open(self.path, "rt", encoding="utf-8") as f:
39+
contents = f.read()
40+
self._newlines = f.newlines[0] if isinstance(f.newlines, tuple) else f.newlines
41+
return contents
42+
43+
def write_file_contents(self, contents: str) -> None:
44+
"""Write the contents of the file."""
45+
if self._newlines is None:
46+
_ = self.get_file_contents()
47+
48+
with open(self.path, "wt", encoding="utf-8", newline=self._newlines) as f:
49+
f.write(contents)
3350

3451
def contains_version(self, version: Version, context: MutableMapping) -> bool:
3552
"""
@@ -73,51 +90,58 @@ def contains(self, search: str) -> bool:
7390
if not search:
7491
return False
7592

76-
with open(self.path, "rt", encoding="utf-8") as f:
77-
search_lines = search.splitlines()
78-
lookbehind = []
79-
80-
for lineno, line in enumerate(f.readlines()):
81-
lookbehind.append(line.rstrip("\n"))
82-
83-
if len(lookbehind) > len(search_lines):
84-
lookbehind = lookbehind[1:]
85-
86-
if (
87-
search_lines[0] in lookbehind[0]
88-
and search_lines[-1] in lookbehind[-1]
89-
and search_lines[1:-1] == lookbehind[1:-1]
90-
):
91-
logger.info(
92-
"Found '%s' in %s at line %s: %s",
93-
search,
94-
self.path,
95-
lineno - (len(lookbehind) - 1),
96-
line.rstrip(),
97-
)
98-
return True
93+
f = self.get_file_contents()
94+
search_lines = search.splitlines()
95+
lookbehind = []
96+
97+
for lineno, line in enumerate(f.splitlines(keepends=True)):
98+
lookbehind.append(line.rstrip("\n"))
99+
100+
if len(lookbehind) > len(search_lines):
101+
lookbehind = lookbehind[1:]
102+
103+
if (
104+
search_lines[0] in lookbehind[0]
105+
and search_lines[-1] in lookbehind[-1]
106+
and search_lines[1:-1] == lookbehind[1:-1]
107+
):
108+
logger.info(
109+
"Found '%s' in %s at line %s: %s",
110+
search,
111+
self.path,
112+
lineno - (len(lookbehind) - 1),
113+
line.rstrip(),
114+
)
115+
return True
99116
return False
100117

101118
def replace_version(
102119
self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False
103120
) -> None:
104121
"""Replace the current version with the new version."""
105-
with open(self.path, "rt", encoding="utf-8") as f:
106-
file_content_before = f.read()
107-
file_new_lines = f.newlines[0] if isinstance(f.newlines, tuple) else f.newlines
122+
file_content_before = self.get_file_contents()
108123

109124
context["current_version"] = self.version_config.serialize(current_version, context)
110125
if new_version:
111126
context["new_version"] = self.version_config.serialize(new_version, context)
127+
re_context = {key: re.escape(str(value)) for key, value in context.items()}
112128

113-
search_for = self.version_config.search.format(**context)
129+
search_for = self.version_config.search.format(**re_context)
130+
search_for_re = self.compile_regex(search_for)
114131
replace_with = self.version_config.replace.format(**context)
115132

116-
file_content_after = file_content_before.replace(search_for, replace_with)
133+
if search_for_re:
134+
file_content_after = search_for_re.sub(replace_with, file_content_before)
135+
else:
136+
file_content_after = file_content_before.replace(search_for, replace_with)
117137

118138
if file_content_before == file_content_after and current_version.original:
119139
search_for_original_formatted = self.version_config.search.format(current_version=current_version.original)
120-
file_content_after = file_content_before.replace(search_for_original_formatted, replace_with)
140+
search_for_original_formatted_re = self.compile_regex(re.escape(search_for_original_formatted))
141+
if search_for_original_formatted_re:
142+
file_content_after = search_for_original_formatted_re.sub(replace_with, file_content_before)
143+
else:
144+
file_content_after = file_content_before.replace(search_for_original_formatted, replace_with)
121145

122146
if file_content_before != file_content_after:
123147
logger.info("%s file %s:", "Would change" if dry_run else "Changing", self.path)
@@ -138,8 +162,16 @@ def replace_version(
138162
logger.info("%s file %s", "Would not change" if dry_run else "Not changing", self.path)
139163

140164
if not dry_run: # pragma: no-coverage
141-
with open(self.path, "wt", encoding="utf-8", newline=file_new_lines) as f:
142-
f.write(file_content_after)
165+
self.write_file_contents(file_content_after)
166+
167+
def compile_regex(self, pattern: str) -> Optional[re.Pattern]:
168+
"""Compile the regex if it is valid, otherwise return None."""
169+
try:
170+
search_for_re = re.compile(pattern)
171+
return search_for_re
172+
except re.error as e:
173+
logger.error("Invalid regex '%s' for file %s: %s. Treating it as a regular string.", pattern, self.path, e)
174+
return None
143175

144176
def __str__(self) -> str: # pragma: no-coverage
145177
return self.path

docsrc/reference/search-and-replace-config.md

+24-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
# Searching and replace configuration
1+
# Search and replace configuration
22

3-
Bump-my-version uses [template strings](https://docs.python.org/3/library/string.html#format-string-syntax) to search the configured files for the old or current version and replace the text with the new version.
3+
Bump-my-version uses a combination of [template strings](https://docs.python.org/3/library/string.html#format-string-syntax) using a [formatting context](formatting-context.md) and regular expressions to search the configured files for the old or current version and replace the text with the new version.
44

5-
You can configure the search or replace templates globally and within each `tool.bumpversion.files` entry in your configuration.
5+
## Using template strings
66

7-
The default search template is `{current_version}` to find the version string within the file and replace it with `{new_version}`.
7+
Both the search and replace templates are rendered using the [formatting context](formatting-context.md). However, only the search template is also treated as a regular expression. The replacement fields available in the formatting context are enclosed in curly braces `{}`.
88

99
The search and replace templates can be multiple lines, like so:
1010

@@ -34,3 +34,23 @@ replace = """
3434
[myproject]
3535
version={new_version}"""
3636
```
37+
38+
## Using regular expressions
39+
40+
Only the search template will use [Python's regular expression syntax](https://docs.python.org/3/library/re.html#regular-expression-syntax) with minor changes. The template string is rendered using the formatting context. The resulting string is treated as a regular expression for searching unless configured otherwise.
41+
42+
Curly braces (`{}`) and backslashes (`\`) must be doubled in the regular expression to escape them from the string formatting process.
43+
44+
The following template:
45+
46+
```text
47+
{current_version} date-released: \\d{{4}}-\\d{{2}}-\\d{{2}}
48+
```
49+
50+
Gets rendered to:
51+
52+
```text
53+
1\.2\.3 date-released: \d{4}-\d{2}-\d{2}
54+
```
55+
56+
This string is used as a regular expression pattern to search.

tests/test_files.py

+48
Original file line numberDiff line numberDiff line change
@@ -389,3 +389,51 @@ def test_ignore_missing_version(tmp_path: Path) -> None:
389389

390390
# Assert
391391
assert version_path.read_text() == "1.2.3"
392+
393+
394+
def test_regex_search(tmp_path: Path) -> None:
395+
"""A regex search string is found and replaced."""
396+
# Arrange
397+
version_path = tmp_path / "VERSION"
398+
version_path.write_text("Release: 1234-56-78 '1.2.3'")
399+
400+
overrides = {
401+
"current_version": "1.2.3",
402+
"search": r"Release: \d{{4}}-\d{{2}}-\d{{2}} '{current_version}'",
403+
"replace": r"Release {now:%Y-%m-%d} '{new_version}'",
404+
"files": [{"filename": str(version_path)}],
405+
}
406+
conf, version_config, current_version = get_config_data(overrides)
407+
new_version = current_version.bump("patch", version_config.order)
408+
cfg_files = [files.ConfiguredFile(file_cfg, version_config) for file_cfg in conf.files]
409+
410+
# Act
411+
files.modify_files(cfg_files, current_version, new_version, get_context(conf))
412+
413+
# Assert
414+
now = datetime.now().isoformat()[:10]
415+
assert version_path.read_text() == f"Release {now} '1.2.4'"
416+
417+
418+
def test_bad_regex_search(tmp_path: Path, caplog) -> None:
419+
"""A search string not meant to be a regex is still found and replaced."""
420+
# Arrange
421+
version_path = tmp_path / "VERSION"
422+
version_path.write_text("Score: A+ ( '1.2.3'")
423+
424+
overrides = {
425+
"current_version": "1.2.3",
426+
"search": r"Score: A+ ( '{current_version}'",
427+
"replace": r"Score: A+ ( '{new_version}'",
428+
"files": [{"filename": str(version_path)}],
429+
}
430+
conf, version_config, current_version = get_config_data(overrides)
431+
new_version = current_version.bump("patch", version_config.order)
432+
cfg_files = [files.ConfiguredFile(file_cfg, version_config) for file_cfg in conf.files]
433+
434+
# Act
435+
files.modify_files(cfg_files, current_version, new_version, get_context(conf))
436+
437+
# Assert
438+
assert version_path.read_text() == "Score: A+ ( '1.2.4'"
439+
assert "Invalid regex" in caplog.text

0 commit comments

Comments
 (0)