Skip to content

fix(version): increase version_variable flexibility w/ quotes (ie. json, yaml, etc) #1028

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
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
29 changes: 29 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1102,4 +1102,33 @@ specified in ``file:variable`` format. For example:
"docs/conf.py:version",
]

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

.. important::
The Regular Expression expects a version value to exist in the file to be replaced.
It cannot be an empty string or a non-semver compliant string. If this is the very
first time you are using PSR, we recommend you set the version to ``0.0.0``. This
may become more flexible in the future with resolution of issue `#941`_.

.. _#941: https://github.com/python-semantic-release/python-semantic-release/issues/941

Given the pattern matching nature of this feature, the Regular Expression is able to
support most file formats as a variable declaration in most languages is very similar.
We specifically support Python, YAML, and JSON as these have been the most common
requests. This configuration option will also work regardless of file extension
because its only a pattern match.

.. note::
This will also work for TOML but we recommend using :ref:`config-version_toml` for
TOML files as it actually will interpret the TOML file and replace the version
number before writing the file back to disk.

.. warning::
If the file (ex. JSON) you are replacing has two of the same variable name in it,
this pattern match will not be able to differentiate between the two and will replace
both. This is a limitation of the pattern matching and not a bug.

**Default:** ``[]``
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ docs = [
]
test = [
"coverage[toml] ~= 7.0",
"pyyaml ~= 6.0",
"pytest ~= 8.3",
"pytest-env ~= 1.0",
"pytest-xdist ~= 3.0",
Expand Down Expand Up @@ -86,8 +87,8 @@ env = [
]
addopts = [
# TO DEBUG in single process, swap auto to 0
"-nauto",
# "-n0",
# "-nauto",
"-n0",
"-ra",
"--diff-symbols",
"--cache-clear",
Expand Down
13 changes: 12 additions & 1 deletion semantic_release/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,18 @@ def from_raw_config( # noqa: C901
try:
path, variable = decl.split(":", maxsplit=1)
# VersionDeclarationABC handles path existence check
search_text = rf"(?x){variable}\s*(:=|[:=])\s*(?P<quote>['\"])(?P<version>{SEMVER_REGEX.pattern})(?P=quote)" # noqa: E501
search_text = str.join(
"",
[
# Supports optional matching quotations around variable name
# Negative lookbehind to ensure we don't match part of a variable name
f"""(?x)(?P<quote1>['"])?(?<![\\w.-]){variable}(?P=quote1)?""",
# Supports walrus, equals sign, or colon as assignment operator ignoring whitespace separation
r"\s*(:=|[:=])\s*",
# Supports optional matching quotations around version number of a SEMVER pattern
f"""(?P<quote2>['"])?(?P<version>{SEMVER_REGEX.pattern})(?P=quote2)?""",
],
)
pd = PatternVersionDeclaration(path, search_text)
except ValueError as exc:
log.exception("Invalid variable declaration %r", decl)
Expand Down
6 changes: 0 additions & 6 deletions tests/command_line/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from unittest.mock import MagicMock

import pytest
from click.testing import CliRunner
from requests_mock import ANY

from semantic_release.cli import config as cli_config_module
Expand Down Expand Up @@ -40,11 +39,6 @@ class RetrieveRuntimeContextFn(Protocol):
def __call__(self, repo: Repo) -> RuntimeContext: ...


@pytest.fixture
def cli_runner() -> CliRunner:
return CliRunner(mix_stderr=False)


@pytest.fixture
def post_mocker(requests_mock: Mocker) -> Mocker:
"""Patch all POST requests, mocking a response body for VCS release creation."""
Expand Down
6 changes: 6 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import TYPE_CHECKING

import pytest
from click.testing import CliRunner
from git import Commit, Repo

from tests.fixtures import *
Expand All @@ -28,6 +29,11 @@ class TeardownCachedDirFn(Protocol):
def __call__(self, directory: Path) -> Path: ...


@pytest.fixture
def cli_runner() -> CliRunner:
return CliRunner(mix_stderr=False)


@pytest.fixture(scope="session")
def default_netrc_username() -> str:
return "username"
Expand Down
219 changes: 219 additions & 0 deletions tests/scenario/test_version_stamp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
from __future__ import annotations

import importlib.util
import json
from pathlib import Path
from textwrap import dedent
from typing import TYPE_CHECKING

import pytest
import yaml

from semantic_release.cli.commands.main import main

from tests.const import MAIN_PROG_NAME, VERSION_SUBCMD
from tests.fixtures.repos.trunk_based_dev.repo_w_no_tags import (
repo_with_no_tags_angular_commits,
)
from tests.util import assert_successful_exit_code

if TYPE_CHECKING:
from click.testing import CliRunner

from tests.fixtures.example_project import UpdatePyprojectTomlFn


@pytest.mark.usefixtures(repo_with_no_tags_angular_commits.__name__)
def test_stamp_version_variables_python(
cli_runner: CliRunner,
update_pyproject_toml: UpdatePyprojectTomlFn,
) -> None:
new_version = "0.1.0"
target_file = Path("src/example/_version.py")

# Set configuration to modify the python file
update_pyproject_toml(
"tool.semantic_release.version_variables", [f"{target_file}:__version__"]
)

# Use the version command and prevent any action besides stamping the version
cli_cmd = [
MAIN_PROG_NAME,
VERSION_SUBCMD,
"--no-changelog",
"--no-commit",
"--no-tag",
]

# Act
result = cli_runner.invoke(main, cli_cmd[1:])

# Check the result
assert_successful_exit_code(result, cli_cmd)

# Load python module for reading the version (ensures the file is valid)
spec = importlib.util.spec_from_file_location("example._version", str(target_file))
module = importlib.util.module_from_spec(spec) # type: ignore
spec.loader.exec_module(module) # type: ignore

# Check the version was updated
assert new_version == module.__version__


@pytest.mark.usefixtures(repo_with_no_tags_angular_commits.__name__)
def test_stamp_version_variables_yaml(
cli_runner: CliRunner,
update_pyproject_toml: UpdatePyprojectTomlFn,
) -> None:
orig_version = "0.0.0"
new_version = "0.1.0"
target_file = Path("example.yml")
orig_yaml = dedent(
f"""\
---
package: example
version: {orig_version}
date-released: 1970-01-01
"""
)
# Write initial text in file
target_file.write_text(orig_yaml)

# Set configuration to modify the yaml file
update_pyproject_toml(
"tool.semantic_release.version_variables", [f"{target_file}:version"]
)

# Use the version command and prevent any action besides stamping the version
cli_cmd = [
MAIN_PROG_NAME,
VERSION_SUBCMD,
"--no-changelog",
"--no-commit",
"--no-tag",
]

# Act
result = cli_runner.invoke(main, cli_cmd[1:])

# Check the result
assert_successful_exit_code(result, cli_cmd)

# Read content
resulting_yaml_obj = yaml.safe_load(target_file.read_text())

# Check the version was updated
assert new_version == resulting_yaml_obj["version"]

# Check the rest of the content is the same (by reseting the version & comparing)
resulting_yaml_obj["version"] = orig_version

assert yaml.safe_load(orig_yaml) == resulting_yaml_obj


@pytest.mark.usefixtures(repo_with_no_tags_angular_commits.__name__)
def test_stamp_version_variables_yaml_cff(
cli_runner: CliRunner,
update_pyproject_toml: UpdatePyprojectTomlFn,
) -> None:
orig_version = "0.0.0"
new_version = "0.1.0"
target_file = Path("CITATION.cff")
# Derived format from python-semantic-release/python-semantic-release#962
orig_yaml = dedent(
f"""\
---
cff-version: 1.2.0
message: "If you use this software, please cite it as below."
authors:
- family-names: Doe
given-names: Jon
orcid: https://orcid.org/1234-6666-2222-5555
title: "My Research Software"
version: {orig_version}
date-released: 1970-01-01
"""
)
# Write initial text in file
target_file.write_text(orig_yaml)

# Set configuration to modify the yaml file
update_pyproject_toml(
"tool.semantic_release.version_variables", [f"{target_file}:version"]
)

# Use the version command and prevent any action besides stamping the version
cli_cmd = [
MAIN_PROG_NAME,
VERSION_SUBCMD,
"--no-changelog",
"--no-commit",
"--no-tag",
]

# Act
result = cli_runner.invoke(main, cli_cmd[1:])

# Check the result
assert_successful_exit_code(result, cli_cmd)

# Read content
resulting_yaml_obj = yaml.safe_load(target_file.read_text())

# Check the version was updated
assert new_version == resulting_yaml_obj["version"]

# Check the rest of the content is the same (by reseting the version & comparing)
resulting_yaml_obj["version"] = orig_version

assert yaml.safe_load(orig_yaml) == resulting_yaml_obj


@pytest.mark.usefixtures(repo_with_no_tags_angular_commits.__name__)
def test_stamp_version_variables_json(
cli_runner: CliRunner,
update_pyproject_toml: UpdatePyprojectTomlFn,
) -> None:
orig_version = "0.0.0"
new_version = "0.1.0"
target_file = Path("plugins.json")
orig_json = {
"id": "test-plugin",
"version": orig_version,
"meta": {
"description": "Test plugin",
},
}
# Write initial text in file
target_file.write_text(json.dumps(orig_json, indent=4))

# Set configuration to modify the json file
update_pyproject_toml(
"tool.semantic_release.version_variables", [f"{target_file}:version"]
)

# Use the version command and prevent any action besides stamping the version
cli_cmd = [
MAIN_PROG_NAME,
VERSION_SUBCMD,
"--no-changelog",
"--no-commit",
"--no-tag",
]

# Act
result = cli_runner.invoke(main, cli_cmd[1:])

# Check the result
assert_successful_exit_code(result, cli_cmd)

# Read content
resulting_json_obj = json.loads(target_file.read_text())

# Check the version was updated
assert new_version == resulting_json_obj["version"]

# Check the rest of the content is the same (by reseting the version & comparing)
resulting_json_obj["version"] = orig_version

assert orig_json == resulting_json_obj