Skip to content
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
55 changes: 41 additions & 14 deletions release_tools/semverup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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()
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions releases/unreleased/semverup-pre-release-label-option.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: Semverup pre-release label option
category: added
author: Jose Javier Merchante <jjmerchante@bitergia.com>
issue: null
notes: >
Include a new option in semverup command to allow defining
the pre-release label.
164 changes: 139 additions & 25 deletions tests/test_semverup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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),
Expand All @@ -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"):
Expand Down Expand Up @@ -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()