From 2be8b0fd3ae13630d408609b4ef63d72af6779f3 Mon Sep 17 00:00:00 2001 From: Jose Javier Merchante Date: Thu, 9 Oct 2025 09:46:02 +0200 Subject: [PATCH] Add pre-release label option for versioning Introduce a new option to specify a pre-release label when updating version numbers. This allows users to customize the identifier used for pre-release versions. By default, the current pre-release label is used, or 'rc' if none is defined. Signed-off-by: Jose Javier Merchante --- release_tools/semverup.py | 55 ++++-- .../semverup-pre-release-label-option.yml | 8 + tests/test_semverup.py | 164 +++++++++++++++--- 3 files changed, 188 insertions(+), 39 deletions(-) create mode 100644 releases/unreleased/semverup-pre-release-label-option.yml diff --git a/release_tools/semverup.py b/release_tools/semverup.py index ed24916..ed9bba3 100755 --- a/release_tools/semverup.py +++ b/release_tools/semverup.py @@ -54,9 +54,12 @@ help="Increase only the defined version.") @click.option('--pre-release', is_flag=True, help="Create a new release candidate version.") +@click.option('--pre-release-label', + default=None, + help="Label to use for the pre-release version. Use the current label or rc by default.") @click.option('--current-version', help="Use the given version instead of the version file.") -def semverup(dry_run, bump_version, pre_release, current_version): +def semverup(dry_run, bump_version, pre_release, pre_release_label, current_version): """Increment version number following semver specification. This script will bump up the version number of a package in a @@ -93,9 +96,21 @@ def semverup(dry_run, bump_version, pre_release, current_version): increase the pre-release part of the version. If '--pre-release' is not used, it will remove any pre-release metadata from the version. + You can also use '--pre-release-label' to customize the identifier used for + the pre-release version. By default, is the current pre-release identifier, + or 'rc' if not defined. + + If you use a different identifier than the current pre-release identifier, and + has a lower precedence, the command will fail. For example, if the current version + is 1.0.0-rc.1, and you try to create an alpha version, it will fail because alpha + has lower precedence than rc. + More info about semver specification can be found in the next link: https://semver.org/. """ + if pre_release_label and not pre_release: + raise click.ClickException("--pre-release-label option requires --pre-release to be set") + try: project = Project(os.getcwd()) except RepositoryError as e: @@ -115,9 +130,9 @@ def semverup(dry_run, bump_version, pre_release, current_version): # Determine the new version and produce the output if bump_version: - new_version = get_next_version(current_version, bump_version, pre_release) + new_version = get_next_version(current_version, bump_version, pre_release, pre_release_label) else: - new_version = determine_new_version_number(project, current_version, pre_release) + new_version = determine_new_version_number(project, current_version, pre_release, pre_release_label) if not dry_run: # Get the pyproject file @@ -181,13 +196,13 @@ def read_version_number(filepath): return version -def get_next_version(current_version, bump_version, do_prerelease=False): +def get_next_version(current_version, bump_version, do_prerelease=False, pre_label=None): """Increment version number based on bump_version choice and do_prerelease""" if current_version.prerelease: - next_version = _get_next_version_from_prerelease(current_version, bump_version, do_prerelease) + next_version = _get_next_version_from_prerelease(current_version, bump_version, do_prerelease, pre_label) else: - next_version = _get_next_version_from_final_release(current_version, bump_version, do_prerelease) + next_version = _get_next_version_from_final_release(current_version, bump_version, do_prerelease, pre_label) if not next_version: msg = "no changes found; version number not updated" @@ -196,11 +211,23 @@ def get_next_version(current_version, bump_version, do_prerelease=False): return next_version -def _get_next_version_from_prerelease(current_version, bump_version, do_prerelease): - """Determine the next version number when the current version is a release candidate""" +def _get_next_version_from_prerelease(current_version, bump_version, do_prerelease, pre_label): + """Determine the next version number when the current version is a prerelease""" next_version = None + # Check if the new pre-release label is different from the current one + current_pre_label = current_version.prerelease.split('.')[0] + if not pre_label: + pre_label = current_pre_label + elif pre_label != current_pre_label: + # Labels are compared in lexical order (https://semver.org/#spec-item-11) + if current_pre_label > pre_label: + msg = f"cannot change pre-release label from {current_pre_label} to {pre_label} due to lower precedence" + raise click.ClickException(msg) + # Update the label and reset the number to 0 (e.g. 0.1.0-alpha.2 >> 0.1.0-beta.0) + current_version = current_version.replace(prerelease=f"{pre_label}.0") + if bump_version == 'MINOR' and current_version.patch != 0: # 0.1.1-rc.2 >> 0.2.0(-rc.1) next_version = current_version.bump_minor() @@ -211,10 +238,10 @@ def _get_next_version_from_prerelease(current_version, bump_version, do_prerelea if do_prerelease: if next_version: # New version and do prerelease - next_version = next_version.bump_prerelease() + next_version = next_version.bump_prerelease(token=pre_label) elif bump_version: # e.g. 0.2.0-rc.1 and minor changelog and do prerelease >> 0.2.0-rc.2 - next_version = current_version.bump_prerelease() + next_version = current_version.bump_prerelease(token=pre_label) else: # Remove prerelease metadata from the version if next_version: @@ -225,7 +252,7 @@ def _get_next_version_from_prerelease(current_version, bump_version, do_prerelea return next_version -def _get_next_version_from_final_release(current_version, bump_version, do_prerelease): +def _get_next_version_from_final_release(current_version, bump_version, do_prerelease, pre_label): """Determine the next version number when the current version is a final release""" next_version = None @@ -242,12 +269,12 @@ def _get_next_version_from_final_release(current_version, bump_version, do_prere if next_version and do_prerelease: # 0.2.1 >> 0.2.1-rc.1 - next_version = next_version.bump_prerelease() + next_version = next_version.bump_prerelease(token=pre_label or 'rc') return next_version -def determine_new_version_number(project, current_version, prerelease): +def determine_new_version_number(project, current_version, prerelease, pre_label): """Guess the next version number.""" entries = read_unreleased_changelog_entries(project) @@ -277,7 +304,7 @@ def determine_new_version_number(project, current_version, prerelease): else: bump_version = None - next_version = get_next_version(current_version, bump_version, prerelease) + next_version = get_next_version(current_version, bump_version, prerelease, pre_label) if not next_version: msg = "no changes found; version number not updated" diff --git a/releases/unreleased/semverup-pre-release-label-option.yml b/releases/unreleased/semverup-pre-release-label-option.yml new file mode 100644 index 0000000..c92993b --- /dev/null +++ b/releases/unreleased/semverup-pre-release-label-option.yml @@ -0,0 +1,8 @@ +--- +title: Semverup pre-release label option +category: added +author: Jose Javier Merchante +issue: null +notes: > + Include a new option in semverup command to allow defining + the pre-release label. diff --git a/tests/test_semverup.py b/tests/test_semverup.py index 1cabba4..a63e852 100644 --- a/tests/test_semverup.py +++ b/tests/test_semverup.py @@ -65,6 +65,9 @@ INVALID_CURRENT_VERSION = ( r"Error: version number 'invalid' is not a valid semver string" ) +INVALID_PRERELEASE_LABEL = ( + r"Error: cannot change pre-release label from .+ to .+ due to lower precedence" +) class TestSemVerUp(unittest.TestCase): @@ -857,30 +860,42 @@ def test_remove_prerelease_part_force_version(self, mock_project): def test_get_next_version(self): """Check multiple version changes based on inputs""" - # arg__version, arg_bump_version, arg_prerelease, expected + # arg__version, arg_bump_version, arg_prerelease, prerelease_label, expected test_cases = [ - ('1.1.3', 'PATCH', False, '1.1.4'), - ('1.1.3', 'MINOR', False, '1.2.0'), - ('1.1.3', 'MAJOR', False, '2.0.0'), - ('0.0.1', 'PATCH', True, '0.0.2-rc.1'), - ('0.0.1', 'MINOR', True, '0.1.0-rc.1'), - ('0.0.1', 'MAJOR', True, '1.0.0-rc.1'), - ('0.1.0', 'PATCH', True, '0.1.1-rc.1'), - ('0.1.0', 'MINOR', True, '0.2.0-rc.1'), - ('0.1.0', 'MAJOR', True, '1.0.0-rc.1'), - ('1.0.2-rc.1', 'PATCH', True, '1.0.2-rc.2'), - ('1.0.2-rc.1', 'MINOR', True, '1.1.0-rc.1'), - ('1.0.2-rc.1', 'MAJOR', True, '2.0.0-rc.1'), - ('1.2.0-rc.1', 'PATCH', True, '1.2.0-rc.2'), - ('1.2.0-rc.1', 'MINOR', True, '1.2.0-rc.2'), - ('1.2.0-rc.1', 'MAJOR', True, '2.0.0-rc.1'), - ('1.0.0-rc.1', 'PATCH', True, '1.0.0-rc.2'), - ('1.0.0-rc.1', 'MINOR', True, '1.0.0-rc.2'), - ('1.0.0-rc.1', 'MAJOR', True, '1.0.0-rc.2'), - ('1.1.0-rc.1', 'PATCH', False, '1.1.0'), - ('1.1.0-rc.1', 'MINOR', False, '1.1.0'), - ('1.1.0-rc.1', 'MAJOR', False, '2.0.0'), - ('1.1.0-rc.1', None, False, '1.1.0'), + ('1.1.3', 'PATCH', False, None, '1.1.4'), + ('1.1.3', 'MINOR', False, None, '1.2.0'), + ('1.1.3', 'MAJOR', False, None, '2.0.0'), + ('0.0.1', 'PATCH', True, None, '0.0.2-rc.1'), + ('0.0.1', 'MINOR', True, None, '0.1.0-rc.1'), + ('0.0.1', 'MAJOR', True, None, '1.0.0-rc.1'), + ('0.1.0', 'PATCH', True, None, '0.1.1-rc.1'), + ('0.1.0', 'MINOR', True, None, '0.2.0-rc.1'), + ('0.1.0', 'MAJOR', True, None, '1.0.0-rc.1'), + ('1.0.2-rc.1', 'PATCH', True, None, '1.0.2-rc.2'), + ('1.0.2-rc.1', 'MINOR', True, None, '1.1.0-rc.1'), + ('1.0.2-rc.1', 'MAJOR', True, None, '2.0.0-rc.1'), + ('1.2.0-rc.1', 'PATCH', True, None, '1.2.0-rc.2'), + ('1.2.0-rc.1', 'MINOR', True, None, '1.2.0-rc.2'), + ('1.2.0-rc.1', 'MAJOR', True, None, '2.0.0-rc.1'), + ('1.0.0-rc.1', 'PATCH', True, None, '1.0.0-rc.2'), + ('1.0.0-rc.1', 'MINOR', True, None, '1.0.0-rc.2'), + ('1.0.0-rc.1', 'MAJOR', True, None, '1.0.0-rc.2'), + ('1.1.0-rc.1', 'PATCH', False, None, '1.1.0'), + ('1.1.0-rc.1', 'MINOR', False, None, '1.1.0'), + ('1.1.0-rc.1', 'MAJOR', False, None, '2.0.0'), + ('1.1.0-rc.1', None, False, None, '1.1.0'), + ('1.1.3', 'PATCH', True, 'alpha', '1.1.4-alpha.1'), + ('1.1.3', 'MINOR', True, 'alpha', '1.2.0-alpha.1'), + ('1.1.3', 'MAJOR', True, 'alpha', '2.0.0-alpha.1'), + ('1.1.3-alpha.1', 'PATCH', True, None, '1.1.3-alpha.2'), + ('1.1.3-alpha.1', 'MINOR', True, None, '1.2.0-alpha.1'), + ('1.1.3-alpha.1', 'MAJOR', True, None, '2.0.0-alpha.1'), + ('1.1.3-alpha.1', 'PATCH', True, 'beta', '1.1.3-beta.1'), + ('1.1.3-alpha.1', 'MINOR', True, 'beta', '1.2.0-beta.1'), + ('1.1.3-alpha.1', 'MAJOR', True, 'beta', '2.0.0-beta.1'), + ('1.1.3-alpha.1', 'PATCH', False, None, '1.1.3'), + ('1.1.3-alpha.1', 'MINOR', False, None, '1.2.0'), + ('1.1.3-alpha.1', 'MAJOR', False, None, '2.0.0'), ] tests_no_changes = [ ('1.1.0', None, True), @@ -891,8 +906,9 @@ def test_get_next_version(self): in_version = semver.Version.parse(case[0]) version = semverup.get_next_version(current_version=in_version, bump_version=case[1], - do_prerelease=case[2]) - self.assertEqual(str(version), case[3]) + do_prerelease=case[2], + pre_label=case[3]) + self.assertEqual(str(version), case[4]) for case in tests_no_changes: with self.assertRaisesRegex(click.ClickException, "no changes found; version number not updated"): @@ -979,6 +995,104 @@ def test_pyproject_pep621_format_version_update(self, mock_project): version = self.read_version_number_from_pyproject_pep621(project_file) self.assertEqual(version, "0.2.0") + @unittest.mock.patch('release_tools.semverup.Project') + def test_entries_and_prerelease_label(self, mock_project): + """ + Check when calling semverup with changelog entries, --pre-release, and + --pre-release-label options, a release candidate is generated + """ + runner = click.testing.CliRunner() + + with runner.isolated_filesystem() as fs: + version_file = os.path.join(fs, '_version.py') + mock_project.return_value.version_file = version_file + + project_file = os.path.join(fs, 'pyproject.toml') + mock_project.return_value.pyproject_file = project_file + + dirpath = os.path.join(fs, 'releases', 'unreleased') + mock_project.return_value.unreleased_changes_path = dirpath + + self.setup_files(version_file, project_file, "0.8.10") + self.setup_unreleased_entries(dirpath, only_fixed=True) + + # Run the script command + result = runner.invoke(semverup.semverup, args=['--pre-release', + '--pre-release-label', 'beta']) + self.assertEqual(result.exit_code, 0) + self.assertEqual(result.stdout, "0.8.11-beta.1\n") + + # Version changed in files + version = self.read_version_number(version_file) + self.assertEqual(version, "0.8.11-beta.1") + + version = self.read_version_number_from_pyproject(project_file) + self.assertEqual(version, "0.8.11-beta.1") + + @unittest.mock.patch('release_tools.semverup.Project') + def test_force_version_prerelease_label(self, mock_project): + """ + Check when calling semverup with --bump-version, --pre-release, and + --pre-release-label options, a release candidate is created with the right label + """ + runner = click.testing.CliRunner() + + with runner.isolated_filesystem() as fs: + version_file = os.path.join(fs, '_version.py') + mock_project.return_value.version_file = version_file + + project_file = os.path.join(fs, 'pyproject.toml') + mock_project.return_value.pyproject_file = project_file + + self.setup_files(version_file, project_file, "0.8.10") + + # Run the script command for major + result = runner.invoke(semverup.semverup, ['--bump-version', 'major', '--pre-release', + '--pre-release-label', 'alpha']) + self.assertEqual(result.exit_code, 0) + self.assertEqual(result.stdout, "1.0.0-alpha.1\n") + + # Version changed in files + version = self.read_version_number(version_file) + self.assertEqual(version, "1.0.0-alpha.1") + + version = self.read_version_number_from_pyproject(project_file) + self.assertEqual(version, "1.0.0-alpha.1") + + @unittest.mock.patch('release_tools.semverup.Project') + def test_fail_prerelease_label_lower_precedence(self, mock_project): + """ + Check when calling semverup with --bump-version, --pre-release, and + --pre-release-label options, with a label with lower precedence (alpha instead + of rc), it fails. + """ + runner = click.testing.CliRunner() + + with runner.isolated_filesystem() as fs: + version_file = os.path.join(fs, '_version.py') + mock_project.return_value.version_file = version_file + + project_file = os.path.join(fs, 'pyproject.toml') + mock_project.return_value.pyproject_file = project_file + + self.setup_files(version_file, project_file, "1.0.0-rc.1") + + # Run the script + + result = runner.invoke(semverup.semverup, ['--bump-version', 'major', '--pre-release', + '--pre-release-label', 'alpha']) + self.assertEqual(result.exit_code, 1) + + lines = result.stderr.split('\n') + self.assertRegex(lines[-2], INVALID_PRERELEASE_LABEL) + + # Version didn't change in files + version = self.read_version_number(version_file) + self.assertEqual(version, "1.0.0-rc.1") + + version = self.read_version_number_from_pyproject(project_file) + self.assertEqual(version, "1.0.0-rc.1") + if __name__ == '__main__': unittest.main()