Skip to content

Commit

Permalink
Adds regular expression searching ability.
Browse files Browse the repository at this point in the history
- Search strings are treated as regular expressions after the initial substitution
  • Loading branch information
coordt committed Jul 14, 2023
1 parent a0481b7 commit 0210d74
Show file tree
Hide file tree
Showing 3 changed files with 135 additions and 35 deletions.
94 changes: 63 additions & 31 deletions bumpversion/files.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Methods for changing files."""
import glob
import logging
import re
from difflib import context_diff
from typing import List, MutableMapping, Optional

Expand Down Expand Up @@ -30,6 +31,22 @@ def __init__(
self.version_config = VersionConfig(
self.parse, self.serialize, self.search, self.replace, version_config.part_configs
)
self._newlines: Optional[str] = None

def get_file_contents(self) -> str:
"""Return the contents of the file."""
with open(self.path, "rt", encoding="utf-8") as f:
contents = f.read()
self._newlines = f.newlines[0] if isinstance(f.newlines, tuple) else f.newlines
return contents

def write_file_contents(self, contents: str) -> None:
"""Write the contents of the file."""
if self._newlines is None:
_ = self.get_file_contents()

with open(self.path, "wt", encoding="utf-8", newline=self._newlines) as f:
f.write(contents)

def contains_version(self, version: Version, context: MutableMapping) -> bool:
"""
Expand Down Expand Up @@ -73,51 +90,58 @@ def contains(self, search: str) -> bool:
if not search:
return False

with open(self.path, "rt", encoding="utf-8") as f:
search_lines = search.splitlines()
lookbehind = []

for lineno, line in enumerate(f.readlines()):
lookbehind.append(line.rstrip("\n"))

if len(lookbehind) > len(search_lines):
lookbehind = lookbehind[1:]

if (
search_lines[0] in lookbehind[0]
and search_lines[-1] in lookbehind[-1]
and search_lines[1:-1] == lookbehind[1:-1]
):
logger.info(
"Found '%s' in %s at line %s: %s",
search,
self.path,
lineno - (len(lookbehind) - 1),
line.rstrip(),
)
return True
f = self.get_file_contents()
search_lines = search.splitlines()
lookbehind = []

for lineno, line in enumerate(f.splitlines(keepends=True)):
lookbehind.append(line.rstrip("\n"))

if len(lookbehind) > len(search_lines):
lookbehind = lookbehind[1:]

if (
search_lines[0] in lookbehind[0]
and search_lines[-1] in lookbehind[-1]
and search_lines[1:-1] == lookbehind[1:-1]
):
logger.info(
"Found '%s' in %s at line %s: %s",
search,
self.path,
lineno - (len(lookbehind) - 1),
line.rstrip(),
)
return True
return False

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

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

search_for = self.version_config.search.format(**context)
search_for = self.version_config.search.format(**re_context)
search_for_re = self.compile_regex(search_for)
replace_with = self.version_config.replace.format(**context)

file_content_after = file_content_before.replace(search_for, replace_with)
if search_for_re:
file_content_after = search_for_re.sub(replace_with, file_content_before)
else:
file_content_after = file_content_before.replace(search_for, replace_with)

if file_content_before == file_content_after and current_version.original:
search_for_original_formatted = self.version_config.search.format(current_version=current_version.original)
file_content_after = file_content_before.replace(search_for_original_formatted, replace_with)
search_for_original_formatted_re = self.compile_regex(re.escape(search_for_original_formatted))
if search_for_original_formatted_re:
file_content_after = search_for_original_formatted_re.sub(replace_with, file_content_before)
else:
file_content_after = file_content_before.replace(search_for_original_formatted, replace_with)

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

if not dry_run: # pragma: no-coverage
with open(self.path, "wt", encoding="utf-8", newline=file_new_lines) as f:
f.write(file_content_after)
self.write_file_contents(file_content_after)

def compile_regex(self, pattern: str) -> Optional[re.Pattern]:
"""Compile the regex if it is valid, otherwise return None."""
try:
search_for_re = re.compile(pattern)
return search_for_re
except re.error as e:
logger.error("Invalid regex '%s' for file %s: %s. Treating it as a regular string.", pattern, self.path, e)
return None

def __str__(self) -> str: # pragma: no-coverage
return self.path
Expand Down
28 changes: 24 additions & 4 deletions docsrc/reference/search-and-replace-config.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Searching and replace configuration
# Search and replace configuration

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

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

The default search template is `{current_version}` to find the version string within the file and replace it with `{new_version}`.
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 `{}`.

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

Expand Down Expand Up @@ -34,3 +34,23 @@ replace = """
[myproject]
version={new_version}"""
```

## Using regular expressions

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.

Curly braces (`{}`) and backslashes (`\`) must be doubled in the regular expression to escape them from the string formatting process.

The following template:

```text
{current_version} date-released: \\d{{4}}-\\d{{2}}-\\d{{2}}
```

Gets rendered to:

```text
1\.2\.3 date-released: \d{4}-\d{2}-\d{2}
```

This string is used as a regular expression pattern to search.
48 changes: 48 additions & 0 deletions tests/test_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,3 +389,51 @@ def test_ignore_missing_version(tmp_path: Path) -> None:

# Assert
assert version_path.read_text() == "1.2.3"


def test_regex_search(tmp_path: Path) -> None:
"""A regex search string is found and replaced."""
# Arrange
version_path = tmp_path / "VERSION"
version_path.write_text("Release: 1234-56-78 '1.2.3'")

overrides = {
"current_version": "1.2.3",
"search": r"Release: \d{{4}}-\d{{2}}-\d{{2}} '{current_version}'",
"replace": r"Release {now:%Y-%m-%d} '{new_version}'",
"files": [{"filename": str(version_path)}],
}
conf, version_config, current_version = get_config_data(overrides)
new_version = current_version.bump("patch", version_config.order)
cfg_files = [files.ConfiguredFile(file_cfg, version_config) for file_cfg in conf.files]

# Act
files.modify_files(cfg_files, current_version, new_version, get_context(conf))

# Assert
now = datetime.now().isoformat()[:10]
assert version_path.read_text() == f"Release {now} '1.2.4'"


def test_bad_regex_search(tmp_path: Path, caplog) -> None:
"""A search string not meant to be a regex is still found and replaced."""
# Arrange
version_path = tmp_path / "VERSION"
version_path.write_text("Score: A+ ( '1.2.3'")

overrides = {
"current_version": "1.2.3",
"search": r"Score: A+ ( '{current_version}'",
"replace": r"Score: A+ ( '{new_version}'",
"files": [{"filename": str(version_path)}],
}
conf, version_config, current_version = get_config_data(overrides)
new_version = current_version.bump("patch", version_config.order)
cfg_files = [files.ConfiguredFile(file_cfg, version_config) for file_cfg in conf.files]

# Act
files.modify_files(cfg_files, current_version, new_version, get_context(conf))

# Assert
assert version_path.read_text() == "Score: A+ ( '1.2.4'"
assert "Invalid regex" in caplog.text

0 comments on commit 0210d74

Please sign in to comment.