Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions docs/configuration/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1326,6 +1326,10 @@ colon-separated definition with either 2 or 3 parts. The 2-part definition inclu
the file path and the variable name. Newly with v9.20.0, it also accepts
an optional 3rd part to allow configuration of the format type.

As of v10.6.0, the ``version_variables`` option also supports entire file replacement
by using an asterisk (``*``) as the pattern/variable name. This is useful for files
that contain only a version number, such as ``VERSION`` files.

**Available Format Types**

- ``nf``: Number format (ex. ``1.2.3``)
Expand All @@ -1348,6 +1352,9 @@ version numbers.
"src/semantic_release/__init__.py:__version__", # Implied Default: Number format
"docs/conf.py:version:nf", # Number format for sphinx docs
"kustomization.yml:newTag:tf", # Tag format
# File replacement (entire file content is replaced with version)
"VERSION:*:nf", # Replace entire file with number format
"VERSION_TAG:*:tf", # Replace entire file with tag format
]

First, the ``__version__`` variable in ``src/semantic_release/__init__.py`` will be updated
Expand All @@ -1370,7 +1377,7 @@ with the next version using the `SemVer`_ number format because of the explicit
- version = "0.1.0"
+ version = "0.2.0"

Lastly, the ``newTag`` variable in ``kustomization.yml`` will be updated with the next version
Then, the ``newTag`` variable in ``kustomization.yml`` will be updated with the next version
with the next version using the configured :ref:`config-tag_format` because the definition
included ``tf``.

Expand All @@ -1383,10 +1390,34 @@ included ``tf``.
- newTag: v0.1.0
+ newTag: v0.2.0

Next, the entire content of the ``VERSION`` file will be replaced with the next version
using the `SemVer`_ number format (because of the ``*`` pattern and ``nf`` format type).

.. code-block:: diff

diff a/VERSION b/VERSION

- 0.1.0
+ 0.2.0

Finally, the entire content of the ``VERSION_TAG`` file will be replaced with the next version
using the configured :ref:`config-tag_format` (because of the ``*`` pattern and ``tf`` format type).

.. code-block:: diff

diff a/VERSION_TAG b/VERSION_TAG

- v0.1.0
+ v0.2.0

**How It works**

Each version variable will be transformed into a Regular Expression that will be used
to substitute the version number in the file. The replacement algorithm is **ONLY** a
Each version variable will be transformed into either a Regular Expression (for pattern-based
replacement) or a file replacement operation (when using the ``*`` pattern).

**Pattern-Based Replacement**

When a variable name is specified (not ``*``), the replacement algorithm is **ONLY** a
pattern match and replace. It will **NOT** evaluate the code nor will PSR understand
any internal object structures (ie. ``file:object.version`` will not work).

Expand Down Expand Up @@ -1420,6 +1451,24 @@ regardless of file extension because it looks for a matching pattern string.
TOML files as it actually will interpret the TOML file and replace the version
number before writing the file back to disk.

**File Replacement**

When the pattern/variable name is specified as an asterisk (``*``), the entire file content
will be replaced with the version string. This is useful for files that contain only a
version number, such as ``VERSION`` files or similar single-line version storage files.

The file replacement operation:

1. Reads the current file content (any whitespace is stripped)
2. Replaces the entire file content with the new version string
3. Writes the new version back to the file (without any additional whitespace)

The format type (``nf`` or ``tf``) determines whether the version is written as a
plain number (e.g., ``1.2.3``) or with the :ref:`config-tag_format` prefix/suffix
(e.g., ``v1.2.3``).

**Examples of Pattern-Based Replacement**

This is a comprehensive list (but not all variations) of examples where the following versions
will be matched and replaced by the new version:

Expand Down
23 changes: 18 additions & 5 deletions src/semantic_release/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
)
from semantic_release.globals import logger
from semantic_release.helpers import dynamic_import
from semantic_release.version.declarations.file import FileVersionDeclaration
from semantic_release.version.declarations.i_version_replacer import IVersionReplacer
from semantic_release.version.declarations.pattern import PatternVersionDeclaration
from semantic_release.version.declarations.toml import TomlVersionDeclaration
Expand Down Expand Up @@ -757,12 +758,24 @@ def from_raw_config( # noqa: C901
) from err

try:
version_declarations.extend(
PatternVersionDeclaration.from_string_definition(
definition, raw.tag_format
for definition in iter(raw.version_variables or ()):
# Check if this is a file replacement definition (pattern is "*")
parts = definition.split(":", maxsplit=2)
if len(parts) >= 2 and parts[1] == "*":
# Use FileVersionDeclaration for entire file replacement
version_declarations.append(
FileVersionDeclaration.from_string_definition(
definition, raw.tag_format
)
)
continue

# Use PatternVersionDeclaration for pattern-based replacement
version_declarations.append(
PatternVersionDeclaration.from_string_definition(
definition, raw.tag_format
)
)
for definition in iter(raw.version_variables or ())
)
except ValueError as err:
raise InvalidConfiguration(
str.join(
Expand Down
4 changes: 3 additions & 1 deletion src/semantic_release/version/declaration.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from semantic_release.globals import logger
from semantic_release.version.declarations.enum import VersionStampType
from semantic_release.version.declarations.file import FileVersionDeclaration
from semantic_release.version.declarations.i_version_replacer import IVersionReplacer
from semantic_release.version.declarations.pattern import PatternVersionDeclaration
from semantic_release.version.declarations.toml import TomlVersionDeclaration
Expand All @@ -19,11 +20,12 @@

# Globals
__all__ = [
"FileVersionDeclaration",
"IVersionReplacer",
"VersionStampType",
"PatternVersionDeclaration",
"TomlVersionDeclaration",
"VersionDeclarationABC",
"VersionStampType",
]


Expand Down
157 changes: 157 additions & 0 deletions src/semantic_release/version/declarations/file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
from __future__ import annotations

from pathlib import Path

from semantic_release.cli.util import noop_report
from semantic_release.globals import logger
from semantic_release.version.declarations.enum import VersionStampType
from semantic_release.version.declarations.i_version_replacer import IVersionReplacer
from semantic_release.version.version import Version


class FileVersionDeclaration(IVersionReplacer):
"""
IVersionReplacer implementation that replaces the entire file content
with the version string.

This is useful for files that contain only a version number, such as
VERSION files or similar single-line version storage files.
"""

def __init__(self, path: Path | str, stamp_format: VersionStampType) -> None:
self._content: str | None = None
self._path = Path(path).resolve()
self._stamp_format = stamp_format

@property
def content(self) -> str:
"""A cached property that stores the content of the configured source file."""
if self._content is None:
logger.debug("No content stored, reading from source file %s", self._path)

if not self._path.exists():
raise FileNotFoundError(f"path {self._path!r} does not exist")

self._content = self._path.read_text()

return self._content

@content.deleter
def content(self) -> None:
self._content = None

def parse(self) -> set[Version]:
"""
Parse and return the version from the file content.

Returns a set containing the parsed version, or an empty set
if the content cannot be parsed as a valid version.
"""
versions = set()
try:
version = Version.parse(self.content.strip())
versions.add(version)
except Exception: # noqa: BLE001
# If parsing fails, return empty set
logger.debug(
"Unable to parse version from file %s, content: %r",
self._path,
self.content.strip(),
)

return versions

def replace(self, new_version: Version) -> str:
"""
Replace the file content with the new version string.

:param new_version: The new version number as a `Version` instance
:return: The new content (just the version string)
"""
new_content = (
new_version.as_tag()
if self._stamp_format == VersionStampType.TAG_FORMAT
else str(new_version)
)

logger.debug(
"Replacing entire file content: path=%r old_content=%r new_content=%r",
self._path,
self.content.strip(),
new_content,
)

return new_content

def update_file_w_version(
self, new_version: Version, noop: bool = False
) -> Path | None:
if noop:
if not self._path.exists():
noop_report(
f"FILE NOT FOUND: cannot stamp version in non-existent file {self._path}",
)
return None

return self._path

new_content = self.replace(new_version)
if new_content == self.content.strip():
return None

self._path.write_text(new_content)
del self.content

return self._path

@classmethod
def from_string_definition(
cls,
replacement_def: str,
tag_format: str, # noqa: ARG003
) -> FileVersionDeclaration:
"""
Create an instance of self from a string representing one item
of the "version_variables" list in the configuration.

This method expects a definition in the format:
"file:*:format_type"

where:
- file is the path to the file
- * is the literal asterisk character indicating file replacement
- format_type is either "nf" (number format) or "tf" (tag format)
"""
parts = replacement_def.split(":", maxsplit=2)

if len(parts) <= 1:
raise ValueError(
f"Invalid replacement definition {replacement_def!r}, missing ':'"
)

if len(parts) == 2:
# apply default version_type of "number_format" (ie. "1.2.3")
parts = [*parts, VersionStampType.NUMBER_FORMAT.value]

path, pattern, version_type = parts

# Validate that the pattern is exactly "*"
if pattern != "*":
raise ValueError(
f"Invalid pattern {pattern!r} for FileVersionDeclaration, expected '*'"
)

try:
stamp_type = VersionStampType(version_type)
except ValueError as err:
raise ValueError(
str.join(
" ",
[
"Invalid stamp type, must be one of:",
str.join(", ", [e.value for e in VersionStampType]),
],
)
) from err

return cls(path, stamp_type)
Loading
Loading