Skip to content

Commit 95ce7ec

Browse files
authored
feat(cmd-version): add file replacement variant for version_variables (python-semantic-release#1391)
Add support for entire file replacement when pattern is specified as `*`. This allows users to configure version stamping for files that contain only a version number (e.g., VERSION files). Implements: python-semantic-release#1375 * docs(configuration): modify `version_variables` definition to include new file replacement * test(cmd-version): add version stamp test for a version file * test(version): add unit test for file declaration
1 parent 0343194 commit 95ce7ec

File tree

7 files changed

+631
-9
lines changed

7 files changed

+631
-9
lines changed

docs/configuration/configuration.rst

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,6 +1326,10 @@ colon-separated definition with either 2 or 3 parts. The 2-part definition inclu
13261326
the file path and the variable name. Newly with v9.20.0, it also accepts
13271327
an optional 3rd part to allow configuration of the format type.
13281328
1329+
As of ${NEW_RELEASE_TAG}, the ``version_variables`` option also supports entire file
1330+
replacement by using an asterisk (``*``) as the pattern/variable name. This is useful
1331+
for files that contain only a version number, such as ``VERSION`` files.
1332+
13291333
**Available Format Types**
13301334
13311335
- ``nf``: Number format (ex. ``1.2.3``)
@@ -1348,6 +1352,9 @@ version numbers.
13481352
"src/semantic_release/__init__.py:__version__", # Implied Default: Number format
13491353
"docs/conf.py:version:nf", # Number format for sphinx docs
13501354
"kustomization.yml:newTag:tf", # Tag format
1355+
# File replacement (entire file content is replaced with version)
1356+
"VERSION:*:nf", # Replace entire file with number format
1357+
"RELEASE:*:tf", # Replace entire file with tag format
13511358
]
13521359
13531360
First, the ``__version__`` variable in ``src/semantic_release/__init__.py`` will be updated
@@ -1370,7 +1377,7 @@ with the next version using the `SemVer`_ number format because of the explicit
13701377
- version = "0.1.0"
13711378
+ version = "0.2.0"
13721379
1373-
Lastly, the ``newTag`` variable in ``kustomization.yml`` will be updated with the next version
1380+
Then, the ``newTag`` variable in ``kustomization.yml`` will be updated with the next version
13741381
with the next version using the configured :ref:`config-tag_format` because the definition
13751382
included ``tf``.
13761383
@@ -1383,10 +1390,34 @@ included ``tf``.
13831390
- newTag: v0.1.0
13841391
+ newTag: v0.2.0
13851392
1393+
Next, the entire content of the ``VERSION`` file will be replaced with the next version
1394+
using the `SemVer`_ number format (because of the ``*`` pattern and ``nf`` format type).
1395+
1396+
.. code-block:: diff
1397+
1398+
diff a/VERSION b/VERSION
1399+
1400+
- 0.1.0
1401+
+ 0.2.0
1402+
1403+
Finally, the entire content of the ``RELEASE`` file will be replaced with the next version
1404+
using the configured :ref:`config-tag_format` (because of the ``*`` pattern and ``tf`` format type).
1405+
1406+
.. code-block:: diff
1407+
1408+
diff a/RELEASE b/RELEASE
1409+
1410+
- v0.1.0
1411+
+ v0.2.0
1412+
13861413
**How It works**
13871414
1388-
Each version variable will be transformed into a Regular Expression that will be used
1389-
to substitute the version number in the file. The replacement algorithm is **ONLY** a
1415+
Each version variable will be transformed into either a Regular Expression (for pattern-based
1416+
replacement) or a file replacement operation (when using the ``*`` pattern).
1417+
1418+
**Pattern-Based Replacement**
1419+
1420+
When a variable name is specified (not ``*``), the replacement algorithm is **ONLY** a
13901421
pattern match and replace. It will **NOT** evaluate the code nor will PSR understand
13911422
any internal object structures (ie. ``file:object.version`` will not work).
13921423
@@ -1420,6 +1451,24 @@ regardless of file extension because it looks for a matching pattern string.
14201451
TOML files as it actually will interpret the TOML file and replace the version
14211452
number before writing the file back to disk.
14221453
1454+
**File Replacement**
1455+
1456+
When the pattern/variable name is specified as an asterisk (``*``), the entire file content
1457+
will be replaced with the version string. This is useful for files that contain only a
1458+
version number, such as ``VERSION`` files or similar single-line version storage files.
1459+
1460+
The file replacement operation:
1461+
1462+
1. Reads the current file content if it exists (any whitespace is stripped)
1463+
2. Sets or replaces the entire file content with the new version string
1464+
3. Writes the new version back to the file (with only a single trailing newline)
1465+
1466+
The format type (``nf`` or ``tf``) determines whether the version is written as a
1467+
plain number (e.g., ``1.2.3``) or with the :ref:`config-tag_format` prefix/suffix
1468+
(e.g., ``v1.2.3``).
1469+
1470+
**Examples of Pattern-Based Replacement**
1471+
14231472
This is a comprehensive list (but not all variations) of examples where the following versions
14241473
will be matched and replaced by the new version:
14251474

src/semantic_release/cli/config.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
)
5858
from semantic_release.globals import logger
5959
from semantic_release.helpers import dynamic_import
60+
from semantic_release.version.declarations.file import FileVersionDeclaration
6061
from semantic_release.version.declarations.i_version_replacer import IVersionReplacer
6162
from semantic_release.version.declarations.pattern import PatternVersionDeclaration
6263
from semantic_release.version.declarations.toml import TomlVersionDeclaration
@@ -757,12 +758,22 @@ def from_raw_config( # noqa: C901
757758
) from err
758759

759760
try:
760-
version_declarations.extend(
761-
PatternVersionDeclaration.from_string_definition(
762-
definition, raw.tag_format
761+
for definition in iter(raw.version_variables or ()):
762+
# Check if this is a file replacement definition (pattern is "*")
763+
parts = definition.split(":", maxsplit=2)
764+
if len(parts) >= 2 and parts[1] == "*":
765+
# Use FileVersionDeclaration for entire file replacement
766+
version_declarations.append(
767+
FileVersionDeclaration.from_string_definition(definition)
768+
)
769+
continue
770+
771+
# Use PatternVersionDeclaration for pattern-based replacement
772+
version_declarations.append(
773+
PatternVersionDeclaration.from_string_definition(
774+
definition, raw.tag_format
775+
)
763776
)
764-
for definition in iter(raw.version_variables or ())
765-
)
766777
except ValueError as err:
767778
raise InvalidConfiguration(
768779
str.join(

src/semantic_release/version/declaration.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from semantic_release.globals import logger
1111
from semantic_release.version.declarations.enum import VersionStampType
12+
from semantic_release.version.declarations.file import FileVersionDeclaration
1213
from semantic_release.version.declarations.i_version_replacer import IVersionReplacer
1314
from semantic_release.version.declarations.pattern import PatternVersionDeclaration
1415
from semantic_release.version.declarations.toml import TomlVersionDeclaration
@@ -19,11 +20,12 @@
1920

2021
# Globals
2122
__all__ = [
23+
"FileVersionDeclaration",
2224
"IVersionReplacer",
23-
"VersionStampType",
2425
"PatternVersionDeclaration",
2526
"TomlVersionDeclaration",
2627
"VersionDeclarationABC",
28+
"VersionStampType",
2729
]
2830

2931

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
from typing import TYPE_CHECKING
5+
6+
from deprecated.sphinx import deprecated
7+
8+
from semantic_release.globals import logger
9+
from semantic_release.version.declarations.enum import VersionStampType
10+
from semantic_release.version.declarations.i_version_replacer import IVersionReplacer
11+
12+
if TYPE_CHECKING: # pragma: no cover
13+
from semantic_release.version.version import Version
14+
15+
16+
class FileVersionDeclaration(IVersionReplacer):
17+
"""
18+
IVersionReplacer implementation that replaces the entire file content
19+
with the version string.
20+
21+
This is useful for files that contain only a version number, such as
22+
VERSION files or similar single-line version storage files.
23+
"""
24+
25+
def __init__(self, path: Path | str, stamp_format: VersionStampType) -> None:
26+
self._content: str | None = None
27+
self._path = Path(path).resolve()
28+
self._stamp_format = stamp_format
29+
30+
@property
31+
def content(self) -> str:
32+
"""A cached property that stores the content of the configured source file."""
33+
if self._content is None:
34+
logger.debug("No content stored, reading from source file %s", self._path)
35+
36+
if not self._path.exists():
37+
logger.debug(
38+
f"path {self._path!r} does not exist, assuming empty content"
39+
)
40+
self._content = ""
41+
else:
42+
self._content = self._path.read_text()
43+
44+
return self._content
45+
46+
@content.deleter
47+
def content(self) -> None:
48+
self._content = None
49+
50+
@deprecated(
51+
version="10.6.0",
52+
reason="Function is unused and will be removed in a future release",
53+
)
54+
def parse(self) -> set[Version]:
55+
raise NotImplementedError # pragma: no cover
56+
57+
def replace(self, new_version: Version) -> str:
58+
"""
59+
Replace the file content with the new version string.
60+
61+
:param new_version: The new version number as a `Version` instance
62+
:return: The new content (just the version string)
63+
"""
64+
new_content = (
65+
new_version.as_tag()
66+
if self._stamp_format == VersionStampType.TAG_FORMAT
67+
else str(new_version)
68+
)
69+
70+
logger.debug(
71+
"Replacing entire file content: path=%r old_content=%r new_content=%r",
72+
self._path,
73+
self.content.strip(),
74+
new_content,
75+
)
76+
77+
return new_content
78+
79+
def update_file_w_version(
80+
self, new_version: Version, noop: bool = False
81+
) -> Path | None:
82+
if noop:
83+
if not self._path.exists():
84+
logger.warning(
85+
f"FILE NOT FOUND: file '{self._path}' does not exist but it will be created"
86+
)
87+
88+
return self._path
89+
90+
new_content = self.replace(new_version)
91+
if new_content == self.content.strip():
92+
return None
93+
94+
self._path.write_text(f"{new_content}\n")
95+
del self.content
96+
97+
return self._path
98+
99+
@classmethod
100+
def from_string_definition(cls, replacement_def: str) -> FileVersionDeclaration:
101+
"""
102+
Create an instance of self from a string representing one item
103+
of the "version_variables" list in the configuration.
104+
105+
This method expects a definition in the format:
106+
"file:*:format_type"
107+
108+
where:
109+
- file is the path to the file
110+
- * is the literal asterisk character indicating file replacement
111+
- format_type is either "nf" (number format) or "tf" (tag format)
112+
"""
113+
parts = replacement_def.split(":", maxsplit=2)
114+
115+
if len(parts) <= 1:
116+
raise ValueError(
117+
f"Invalid replacement definition {replacement_def!r}, missing ':'"
118+
)
119+
120+
if len(parts) == 2:
121+
# apply default version_type of "number_format" (ie. "1.2.3")
122+
parts = [*parts, VersionStampType.NUMBER_FORMAT.value]
123+
124+
path, pattern, version_type = parts
125+
126+
# Validate that the pattern is exactly "*"
127+
if pattern != "*":
128+
raise ValueError(
129+
f"Invalid pattern {pattern!r} for FileVersionDeclaration, expected '*'"
130+
)
131+
132+
try:
133+
stamp_type = VersionStampType(version_type)
134+
except ValueError as err:
135+
raise ValueError(
136+
str.join(
137+
" ",
138+
[
139+
"Invalid stamp type, must be one of:",
140+
str.join(", ", [e.value for e in VersionStampType]),
141+
],
142+
)
143+
) from err
144+
145+
return cls(path, stamp_type)

src/semantic_release/version/declarations/i_version_replacer.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from abc import ABCMeta, abstractmethod
44
from typing import TYPE_CHECKING
55

6+
from deprecated.sphinx import deprecated
7+
68
if TYPE_CHECKING: # pragma: no cover
79
from pathlib import Path
810

@@ -32,6 +34,10 @@ def __subclasshook__(cls, subclass: type) -> bool:
3234
)
3335
)
3436

37+
@deprecated(
38+
version="9.20.0",
39+
reason="Function is unused and will be removed in a future release",
40+
)
3541
@abstractmethod
3642
def parse(self) -> set[Version]:
3743
"""

0 commit comments

Comments
 (0)