diff --git a/.circleci/config.yml b/.circleci/config.yml index db7f071..29afa2c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -44,6 +44,8 @@ jobs: - run: name: run tests command: | + git config user.email "you@example.com" + git config user.name "Your Name" pipenv run pytest --junitxml=test-reports/results.xml pipenv run green -r src - run: @@ -72,6 +74,8 @@ jobs: name: install dependencies # TODO: consider using pipenv command: | + git config user.email "you@example.com" + git config user.name "Your Name" sudo pip install -U pip pipenv sudo pip install -r scripts/requirements.txt pipenv install --dev . diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..71041c1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: daily + timezone: Europe/London + open-pull-requests-limit: 1 + ignore: + - dependency-name: twine + versions: + - 3.3.0 + - 3.4.0 + rebase-strategy: disabled diff --git a/CHANGELOG.md b/CHANGELOG.md index 33f8d1e..3f74060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,31 @@ a list of versions that have been released on PyPI. [//]: # (begin_release_notes) +2.0.1 (2021-08-05) +### Bugfixes + +- Fix bug when having multiple file targets (#1) + + +2.0.0 (2021-05-18) +================== + +### Features + +- Adds better workflow for incrementing patches. (#5) + +- Adds the ability to have "infinite" newsfiles. They no longer require cleaning up, if tags are used to indicate releases. + This requires a workflow where releases are tagged in git, so we can determine the "new news". (#6) + +### Bugfixes + +- Fix SemVer dependency version (#12) + +### Improved Documentation + +- Releasing new version (#10) + + 1.2.0 (2019-11-15) ================== diff --git a/Pipfile b/Pipfile index 2764687..29f1ae9 100644 --- a/Pipfile +++ b/Pipfile @@ -15,9 +15,9 @@ six = "*" coverage = "*" # locked to protect from Py2/3 compatibility break in more recent versions towncrier = "*" -pytest = "==5.2.3" +pytest = "==6.2.4" pytest-cov = "==2.8.1" -pytest-html = "==2.0.0" +pytest-html = "==2.1.1" pyautoversion = {path = ".",editable = true} [requires] diff --git a/README.md b/README.md index 4bdf782..dbe558a 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ Cross-language tool written in Python to automatically version projects using [S [![CircleCI](https://circleci.com/gh/ARMmbed/autoversion.svg?style=svg&circle-token=dd9ec017be37f9b5f0a5b9a785c55c53fcd578c7)](https://circleci.com/gh/ARMmbed/autoversion) + [![PyPI version](https://badge.fury.io/py/pyautoversion.svg)](https://badge.fury.io/py/pyautoversion) @@ -30,32 +31,48 @@ Check your target files, and if they are as you'd expect then you're good to go. For more details about how to use the tool, have a look at the [usage page](./USAGE.md) ``` -usage: auto_version [-h] [--target TARGET] [--bump {major,minor,patch}] - [--news] [--set SET] [--set-patch-count] [--lock] - [--release] [--version] [--config CONFIG] [-v] +usage: auto_version [-h] [--show] + [--bump {major,minor,patch,prerelease,build}] [--news] + [--print-file-triggers] [--set SET] + [--commit-count-as {major,minor,patch,prerelease,build}] + [--lock] [--release] [--version] + [--persist-from {vcs,vcs-latest,source}] + [--persist-to {vcs,source}] [--config CONFIG] [-v] -auto version: a tool to control version numbers +auto version v1.2.0: a tool to control version numbers optional arguments: -h, --help show this help message and exit - --target TARGET Files containing version info. Assumes unique variable - names between files. (default: ['src\\_version.py']). - --bump {major,minor,patch} + --show, --dry-run Don't write anything to disk or vcs. + --bump {major,minor,patch,prerelease,build} Bumps the specified part of SemVer string. Use this locally to correctly modify the version file. --news, --file-triggers Detects need to bump based on presence of files (as specified in config). + --print-file-triggers + Prints a newline separated list of files detected as + bump triggers. --set SET Set the SemVer string. Use this locally to set the project version explicitly. - --set-patch-count Sets the patch number to the commit count. + --commit-count-as {major,minor,patch,prerelease,build} + Use the commit count to set the value of the specified + field. --lock Locks the SemVer string. Lock will remain for another call to autoversion before being cleared. --release Marks as a release build, which flags the build as released. --version Prints the version of auto_version itself (self- version). - --config CONFIG Configuration file path. + --persist-from {vcs,vcs-latest,source} + Where the current version is stored. Looks for each + source in order. (default: source files) + --persist-to {vcs,source} + Where the new version is stored. This could be in + multiple places at once. (default: source files) + --config CONFIG Configuration file path. (default: + C:\Users\adrcab01\OneDrive - + Arm\Documents\GitHub\mbed-targets\pyproject.toml). -v, --verbosity increase output verbosity. can be specified multiple times ``` diff --git a/requirements.txt b/requirements.txt index b7c9825..1dc4903 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ toml -semver +semver~=2.13 +six diff --git a/scripts/release.py b/scripts/release.py index edf3e75..ede1d90 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,52 +1,46 @@ -import os - import datetime import logging +import os import subprocess import sys -from git import Repo, Actor -ENVVAR_TWINE_USERNAME = 'TWINE_USERNAME' +from git import Actor +from git import Repo + +ENVVAR_TWINE_USERNAME = "TWINE_USERNAME" NUMBER_OF_RETRIES = 5 -ENVVAR_TWINE_REPOSITORY = 'TWINE_REPOSITORY' +ENVVAR_TWINE_REPOSITORY = "TWINE_REPOSITORY" -ENVVAR_TWINE_REPOSITORY_URL = 'TWINE_REPOSITORY_URL' +ENVVAR_TWINE_REPOSITORY_URL = "TWINE_REPOSITORY_URL" ENVVAR_BRANCH_NAME = "CIRCLE_BRANCH" ENVVAR_GITHUB_TOKEN = "GH_TOKEN" ENVVAR_GITHUB_TOKEN2 = "GITHUB_TOKEN" -REPO_ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__), '..')) +REPO_ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__), "..")) def change_remote(repo, github_token, branch_name): origin_url = repo.remotes.origin.url - path = origin_url.split('github.com', 1)[1][1:].strip() - new = 'https://{GITHUB_TOKEN}:x-oauth-basic@github.com/%s' % path - logging.info('Rewriting git remote url to: %s' % new) + path = origin_url.split("github.com", 1)[1][1:].strip() + new = "https://{GITHUB_TOKEN}:x-oauth-basic@github.com/%s" % path + logging.info("Rewriting git remote url to: %s" % new) repo.delete_remote(repo.remotes.origin) - new_origin = repo.create_remote('origin', - url=new.format(GITHUB_TOKEN=github_token) - ) + new_origin = repo.create_remote("origin", url=new.format(GITHUB_TOKEN=github_token)) repo.git.fetch() repo.git.checkout(branch_name) - repo.git.branch( - '--set-upstream-to', '%s/%s' % (new_origin, branch_name) - ) + repo.git.branch("--set-upstream-to", "%s/%s" % (new_origin, branch_name)) def commit_release(repo, branch_name, version): logging.info("Committing release...") - modified_files = [f.a_path for f in - repo.index.diff(None)] + repo.untracked_files + modified_files = [f.a_path for f in repo.index.diff(None)] + repo.untracked_files if len(modified_files) == 0: - logging.info( - "No commit to perform as no changes were detected" - ) + logging.info("No commit to perform as no changes were detected") return - repo.git.add('./src/auto_version/__version__.py') - repo.git.add('CHANGELOG.md') - repo.git.add('./docs/news/*') + repo.git.add("./src/auto_version/__version__.py") + repo.git.add("CHANGELOG.md") + repo.git.add("./docs/news/*") repo.git.tag() # Create commit @@ -54,13 +48,11 @@ def commit_release(repo, branch_name, version): repo.config_writer().set_value("user", "name", author.name).release() repo.config_writer().set_value("user", "email", author.email).release() repo.index.commit( - ":checkered_flag: :newspaper: Releasing version %s @ %s\n[ci skip]" % ( - version, - datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M") - ), - author=author + ":checkered_flag: :newspaper: Releasing version %s @ %s\n[ci skip]" + % (version, datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M")), + author=author, ) - repo.create_tag(version, ref=branch_name, message='Release %s' % version) + repo.create_tag(version, ref=branch_name, message="Release %s" % version) retries = NUMBER_OF_RETRIES while retries > 0: get_latest(repo, branch_name) @@ -68,12 +60,12 @@ def commit_release(repo, branch_name, version): return retries = retries - 1 raise Exception("Failed committing new release") - mark_release_as_latest(branch_name, repo) def mark_release_as_latest(branch_name, repo): - logging.info('Marking this commit as latest') - repo.create_tag('latest', force=True) + logging.info("Marking this commit as latest") + repo.create_tag("latest", force=True) + retries = NUMBER_OF_RETRIES while retries > 0: get_latest(repo, branch_name) if push_to_github(branch_name, repo, force=True): @@ -86,12 +78,11 @@ def push_to_github(branch_name, repo, force=False): logging.info("Pushing back to GitHub...") try: if force: - repo.git.push('-f', '--set-upstream', repo.remotes.origin, - branch_name) + repo.git.push("-f", "--set-upstream", repo.remotes.origin, branch_name) else: - repo.git.push('--follow-tags', '--set-upstream', - repo.remotes.origin, - branch_name) + repo.git.push( + "--follow-tags", "--set-upstream", repo.remotes.origin, branch_name + ) return True except Exception as e: logging.error("Push failed: %s" % str(e)) @@ -106,23 +97,30 @@ def get_latest(repo, branch_name): def get_new_version(): - version = subprocess.check_output( - ['python', 'setup.py', '--version']).decode().strip() - if 'dev' in version: - raise Exception('cannot release unversioned project: %s' % version) + version = ( + subprocess.check_output(["python", "setup.py", "--version"]).decode().strip() + ) + if "dev" in version: + raise Exception("cannot release unversioned project: %s" % version) return version def release_to_pypi(twine_repo, twine_username): - logging.info('Releasing to %s as %s' % (twine_repo, twine_username)) - logging.info('Generating a release package') - subprocess.check_call( - ['python', 'setup.py', 'clean', '--all', - 'bdist_wheel', - '--dist-dir', 'release-dist']) - logging.info('Uploading to PyPI') + logging.info("Releasing to %s as %s" % (twine_repo, twine_username)) + logging.info("Generating a release package") subprocess.check_call( - ['python', '-m', 'twine', 'upload', 'release-dist/*']) + [ + "python", + "setup.py", + "clean", + "--all", + "bdist_wheel", + "--dist-dir", + "release-dist", + ] + ) + logging.info("Uploading to PyPI") + subprocess.check_call(["python", "-m", "twine", "upload", "release-dist/*"]) def get_current_branch(repo): @@ -137,26 +135,28 @@ def get_current_branch(repo): def main(): - gh_token = os.getenv(ENVVAR_GITHUB_TOKEN) or os.getenv( - ENVVAR_GITHUB_TOKEN2) + gh_token = os.getenv(ENVVAR_GITHUB_TOKEN) or os.getenv(ENVVAR_GITHUB_TOKEN2) # see: # https://packaging.python.org/tutorials/distributing-packages/#uploading-your-project-to-pypi - twine_repo = os.getenv('%s' % ENVVAR_TWINE_REPOSITORY_URL) or os.getenv( - ENVVAR_TWINE_REPOSITORY) + twine_repo = os.getenv("%s" % ENVVAR_TWINE_REPOSITORY_URL) or os.getenv( + ENVVAR_TWINE_REPOSITORY + ) twine_username = os.getenv(ENVVAR_TWINE_USERNAME) if not gh_token: logging.fatal( - "Neither environment variables [%s] or [%s] (github token) are set. Aborting." % ( - ENVVAR_GITHUB_TOKEN, ENVVAR_GITHUB_TOKEN2) + "Neither environment variables [%s] or [%s] (github token) are set. Aborting." + % (ENVVAR_GITHUB_TOKEN, ENVVAR_GITHUB_TOKEN2) ) sys.exit(1) if not twine_repo: logging.fatal( - "Environment variables [%s/%s] (PyPI repository/URL) and/or [%s] (PyPI username) are not set. Aborting." % ( + "Environment variables [%s/%s] (PyPI repository/URL) and/or [%s] (PyPI username) are not set. Aborting." + % ( ENVVAR_TWINE_REPOSITORY, ENVVAR_TWINE_REPOSITORY_URL, - ENVVAR_TWINE_USERNAME) + ENVVAR_TWINE_USERNAME, + ) ) sys.exit(1) this_repo = Repo(REPO_ROOT) @@ -168,9 +168,9 @@ def main(): get_latest(this_repo, branch_name) commit_release(this_repo, branch_name, new_version) release_to_pypi(twine_repo, twine_username) - logging.info('Done.') + logging.info("Done.") -if __name__ == '__main__': +if __name__ == "__main__": logging.basicConfig(level=logging.INFO) main() diff --git a/scripts/requirements.txt b/scripts/requirements.txt index bc2cda5..406c9d0 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -1,3 +1,3 @@ GitPython -twine==1.11 +twine==3.1.1 setuptools \ No newline at end of file diff --git a/src/auto_version/__version__.py b/src/auto_version/__version__.py index bf8c320..4e5d9e4 100644 --- a/src/auto_version/__version__.py +++ b/src/auto_version/__version__.py @@ -1,4 +1,4 @@ # This project's release version -__version__ = "1.2.0" +__version__ = "2.0.1" # This project's release commit hash -COMMIT = "3d680ed58eea0930e4fa172b8c0261566a8dc3b9" +COMMIT = "e542c61ad254274aaede0eba2f9b3c405c71637c" diff --git a/src/auto_version/auto_version_tool.py b/src/auto_version/auto_version_tool.py index 4152b89..ced2c10 100644 --- a/src/auto_version/auto_version_tool.py +++ b/src/auto_version/auto_version_tool.py @@ -46,8 +46,12 @@ def replace_lines(regexer, handler, lines): result = [] for line in lines: content = line.strip() - replaced = regexer.sub(handler, content) - result.append(line.replace(content, replaced, 1)) + try: + replaced = regexer.sub(handler, content) + except KeyError: + result.append(line) + else: + result.append(line.replace(content, replaced, 1)) return result @@ -101,7 +105,7 @@ def detect_file_triggers(release_commit): """The existence of files matching configured globs will trigger a version bump""" all_valid_trigger_files = set() triggers = set() - for trigger, pattern in config.trigger_patterns.items(): + for pattern, trigger in config.trigger_patterns.items(): matches = glob.glob(pattern) if matches: @@ -182,20 +186,6 @@ def get_lock_behaviour(triggers, all_data, lock): return updates -def get_final_version_string(release_mode, version): - """Generates update dictionary entries for the version string""" - production_version = semver.finalize_version(version) - updates = {} - if release_mode: - updates[Constants.RELEASE_FIELD] = config.RELEASED_VALUE - updates[Constants.VERSION_FIELD] = production_version - updates[Constants.VERSION_STRICT_FIELD] = production_version - else: - updates[Constants.VERSION_FIELD] = version - updates[Constants.VERSION_STRICT_FIELD] = production_version - return updates - - def get_dvcs_info(): """Gets current repository info from git""" cmd = "git rev-list --count HEAD" @@ -208,13 +198,15 @@ def get_dvcs_info(): def get_all_versions_from_tags(tags): + """this is like a reverse match from a template""" # build a regex from our version template - re_safe_placeholder = 10 * "v" + re_safe_placeholder = r"A_PLACEHOLDER_FOR_THE_VERSION_DETECTOR" + re_version_detector = r"(\d+\.\d+\.\d+(-\w+.\d+)?(\+\w+.\d+)?)" tag_re = ( "^" + re.escape( config.TAG_TEMPLATE.replace("{version}", re_safe_placeholder) - ).replace(re_safe_placeholder, "(.*)") + ).replace(re_safe_placeholder, re_version_detector) + "$" ) _LOG.debug("regexing with %r", tag_re) @@ -255,8 +247,12 @@ def get_dvcs_commit_for_version(version, persist_from): _LOG.exception("failed to discover the commit for the last tagged release") -def get_dvcs_latest_tag_semver(): - """Gets the semantically latest tag across the whole repo""" +def get_dvcs_ordered_tag_semvers(): + """Gets the semantically latest tag across the whole repo + + :returns: ordered list of VersionInfo instances + :rtype: list(semver.VersionInfo) + """ tag_glob = config.TAG_TEMPLATE.replace("{version}", "*") cmd = "git tag --list %s" % tag_glob tags = str(subprocess.check_output(shlex.split(cmd)).decode("utf8").strip()) @@ -266,20 +262,64 @@ def get_dvcs_latest_tag_semver(): ordered_versions = sorted( {v for v in set(utils.from_text_or_none(version) for version in matches) if v} ) - result = None - if ordered_versions: - result = ordered_versions.pop() - _LOG.info("latest version found in across all dvcs tags: %s", result) - return result + return ordered_versions -def get_dvcs_ancestor_tag_semver(): - """Gets the latest tag that's an ancestor to the current commit""" - cmd = "git describe --abbrev=0 --tags" - version = str(subprocess.check_output(shlex.split(cmd)).decode("utf8").strip()) - result = utils.from_text_or_none(get_all_versions_from_tags([version])[0]) - _LOG.info("latest version found in dvcs nearest tag: %r", result) - return result +def get_dvcs_repo_latest_version_semver(): + """Gets the most recent version across the whole repo""" + ordered_versions = get_dvcs_ordered_tag_semvers() + version = ordered_versions[-1] if ordered_versions else None + _LOG.info("latest version found across all dvcs tags: %s", version) + return version + + +def get_dvcs_repo_latest_release_semver(): + """Gets the most recent release across the whole repo""" + ordered_versions = get_dvcs_ordered_tag_semvers() + for version in reversed(ordered_versions): # type: semver.VersionInfo + if utils.is_release(version): + break + else: + version = None + _LOG.info("latest release found across all dvcs tags: %s", version) + return version + + +def get_dvcs_previous_version_semver(): + """Gets the latest version that's an ancestor to the current commit""" + ordered_versions = get_dvcs_ordered_tag_semvers() + for version in reversed(ordered_versions): # type: semver.VersionInfo + if is_ancestor(version): + break + else: + version = None + _LOG.info("previous version found in ancestral tags: %r", version) + return version + + +def get_dvcs_previous_release_semver(): + """Gets the latest release that's an ancestor to the current commit""" + ordered_versions = get_dvcs_ordered_tag_semvers() + for version in reversed(ordered_versions): # type: semver.VersionInfo + if utils.is_release(version) and is_ancestor(version): + break + else: + version = None + _LOG.info("previous release found in ancestral tags: %r", version) + return version + + +def is_ancestor(version): + try: + # if "--is-ancestor" returns exit code 0, then it is an ancestor and we can stop looking + release_tag = config.TAG_TEMPLATE.replace("{version}", str(version)) + subprocess.check_output( + ["git", "merge-base", "--is-ancestor", release_tag, "HEAD"] + ) + except subprocess.CalledProcessError: + pass + else: + return True def add_dvcs_tag(version): @@ -299,10 +339,14 @@ def get_current_version(persist_from): if source == Constants.FROM_SOURCE: all_data = read_targets(config.targets) version = utils.get_semver_from_source(all_data) - elif source == Constants.FROM_VCS_LATEST: - version = get_dvcs_latest_tag_semver() - elif source == Constants.FROM_VCS_ANCESTOR: - version = get_dvcs_ancestor_tag_semver() + elif source == Constants.FROM_VCS_PREVIOUS_VERSION: + version = get_dvcs_previous_version_semver() + elif source == Constants.FROM_VCS_PREVIOUS_RELEASE: + version = get_dvcs_previous_release_semver() + elif source == Constants.FROM_VCS_LATEST_VERSION: + version = get_dvcs_repo_latest_version_semver() + elif source == Constants.FROM_VCS_LATEST_RELEASE: + version = get_dvcs_repo_latest_release_semver() if version: break return version @@ -318,6 +362,24 @@ def get_overrides(updates, commit_count_as): return overrides +def load_config(config_path): + get_or_create_config(config_path, config) + + for k, v in config.regexers.items(): + config.regexers[k] = re.compile(v) + + # a forward-mapping of the configured aliases + # giving : + # if a value occurs multiple times, we take the last set value + # TODO: the 'forward aliases' things is way overcomplicated + # would be better to rework the config to have keys set-or-None + # since there's only a finite set of valid keys we operate on + config._forward_aliases.clear() + for k, v in config.key_aliases.items(): + config._forward_aliases[v] = k + return config + + def main( set_to=None, commit_count_as=None, @@ -325,6 +387,7 @@ def main( bump=None, lock=None, enable_file_triggers=None, + incr_from_release=None, config_path=None, persist_from=None, persist_to=None, @@ -340,7 +403,7 @@ def main( Write out new version and any other requested variables :param set_to: explicitly set semver to this version string - :param set_patch_count: sets the patch number to the commit count + :param commit_count_as: uses the commit count for the specified sigfig :param release: marks with a production flag just sets a single flag as per config :param bump: string indicating major/minor/patch @@ -349,6 +412,14 @@ def main( lock only removed if a version bump would have occurred :param enable_file_triggers: whether to enable bumping based on file triggers bumping occurs once if any file(s) exist that match the config + :param incr_from_release: dynamically generates the bump by comparing the + proposed triggers for the current version, with the significance of the previous release + to ensure e.g. adding new major changes to a prerelease should probably trigger a new major version + specifically, the bump is: + if (max trigger sigfig) > (max sigfig since release): + (max trigger sigfig) + else + (min trigger sigfig) :param config_path: path to config file :param extra_updates: :return: @@ -356,70 +427,76 @@ def main( updates = {} persist_to = persist_to or [Constants.TO_SOURCE] persist_from = persist_from or [Constants.FROM_SOURCE] - get_or_create_config(config_path, config) - - for k, v in config.regexers.items(): - config.regexers[k] = re.compile(v) - - # a forward-mapping of the configured aliases - # giving : - # if a value occurs multiple times, we take the last set value - # TODO: the 'forward aliases' things is way overcomplicated - # would be better to rework the config to have keys set-or-None - # since there's only a finite set of valid keys we operate on - config._forward_aliases.clear() - for k, v in config.key_aliases.items(): - config._forward_aliases[v] = k + load_config(config_path) all_data = {} + last_release_semver = None + if incr_from_release: + if (Constants.FROM_VCS_PREVIOUS_VERSION in persist_from) or ( + Constants.FROM_VCS_PREVIOUS_RELEASE in persist_from + ): + last_release_semver = get_dvcs_previous_release_semver() + else: + last_release_semver = get_dvcs_repo_latest_release_semver() + _LOG.debug("found previous full release: %s", last_release_semver) current_semver = get_current_version(persist_from) release_commit = get_dvcs_commit_for_version(current_semver, persist_from) - new_semver = current_semver = str(current_semver) triggers = get_all_triggers(bump, enable_file_triggers, release_commit) updates.update(get_lock_behaviour(triggers, all_data, lock)) updates.update(get_dvcs_info()) + new_version = current_semver if set_to: _LOG.debug("setting version directly: %s", set_to) # parse it - validation failure will raise a ValueError - semver.parse(set_to) - new_semver = set_to + new_version = semver.parse_version_info(set_to) if not lock: warnings.warn( "After setting version manually, does it need locking for a CI flow, to avoid an extraneous increment?", UserWarning, ) elif triggers: - # only use triggers if the version is not set directly + # use triggers if the version is not set directly _LOG.debug("auto-incrementing version (triggers: %s)", triggers) overrides = get_overrides(updates, commit_count_as) - new_semver = utils.make_new_semver(current_semver, triggers, **overrides) + new_version = utils.make_new_semver( + current_semver, last_release_semver, triggers, **overrides + ) - updates.update(get_final_version_string(release_mode=release, version=new_semver)) + release_string = semver.finalize_version(str(new_version)) + release_version = semver.parse_version_info(release_string) + if release: + new_version = release_version + updates[Constants.RELEASE_FIELD] = config.RELEASED_VALUE + updates[Constants.VERSION_FIELD] = release_string + updates[Constants.VERSION_STRICT_FIELD] = release_string + else: + updates[Constants.VERSION_FIELD] = str(new_version) + updates[Constants.VERSION_STRICT_FIELD] = release_string # write out the individual parts of the version - updates.update(semver.parse(new_semver)) + updates.update(new_version._asdict()) # only rewrite a field that the user has specified in the configuration - native_updates = { + source_file_updates = { native: updates[key] for native, key in config.key_aliases.items() if key in updates } # finally, add in commandline overrides - native_updates.update(extra_updates) + source_file_updates.update(extra_updates) if not dry_run: if Constants.TO_SOURCE in persist_to: - write_targets(config.targets, **native_updates) + write_targets(config.targets, **source_file_updates) if Constants.TO_VCS in persist_to: add_dvcs_tag(updates[Constants.VERSION_FIELD]) else: _LOG.warning("dry run: no changes were made") - return current_semver, new_semver, native_updates + return str(current_semver), str(new_version), source_file_updates def parse_other_args(others): @@ -464,6 +541,7 @@ def main_from_cli(): release=args.release, bump=args.bump, enable_file_triggers=args.file_triggers, + incr_from_release=args.incr_from_release, config_path=args.config, dry_run=args.show, persist_from=args.persist_from, diff --git a/src/auto_version/cli.py b/src/auto_version/cli.py index 158cc7f..2c4d095 100644 --- a/src/auto_version/cli.py +++ b/src/auto_version/cli.py @@ -32,6 +32,11 @@ def get_cli(): dest="file_triggers", help="Detects need to bump based on presence of files (as specified in config).", ) + parser.add_argument( + "--incr-from-release", + action="store_true", + help="Automatically sets version number based on SCIENCE (see docs). Requires use of VCS tags.", + ) parser.add_argument( "--print-file-triggers", action="store_true", @@ -68,8 +73,10 @@ def get_cli(): "--persist-from", choices={ Constants.FROM_SOURCE, - Constants.FROM_VCS_ANCESTOR, - Constants.FROM_VCS_LATEST, + Constants.FROM_VCS_PREVIOUS_VERSION, + Constants.FROM_VCS_PREVIOUS_RELEASE, + Constants.FROM_VCS_LATEST_VERSION, + Constants.FROM_VCS_LATEST_RELEASE, }, action="append", default=[], diff --git a/src/auto_version/config.py b/src/auto_version/config.py index a6df16d..e7c8175 100644 --- a/src/auto_version/config.py +++ b/src/auto_version/config.py @@ -25,8 +25,10 @@ class Constants(object): # source and destination control FROM_SOURCE = "source" - FROM_VCS_ANCESTOR = "vcs" - FROM_VCS_LATEST = "vcs-latest" + FROM_VCS_PREVIOUS_VERSION = "vcs-prev-version" + FROM_VCS_PREVIOUS_RELEASE = "vcs-prev-release" + FROM_VCS_LATEST_VERSION = "vcs-global-version" + FROM_VCS_LATEST_RELEASE = "vcs-global-release" TO_SOURCE = "source" TO_VCS = "vcs" @@ -56,19 +58,24 @@ class AutoVersionConfig(object): targets = [os.path.join("src", "_version.py")] regexers = { ".json": r"""^\s*[\"]?(?P[\w:]+)[\"]?\s*:[\t ]*[\"']?(?P((\\\")?[^\r\n\t\f\v\",](\\\")?)+)[\"']?,?""", # noqa + ".yaml": r"""^\s*[\"']?(?P[\w]+)[\"']?\s*:\s*[\"']?(?P[\w\-.+\\\/:]*[^'\",\[\]#\s]).*""", # noqa + ".yml": r"""^\s*[\"']?(?P[\w]+)[\"']?\s*:\s*[\"']?(?P[\w\-.+\\\/:]*[^'\",\[\]#\s]).*""", # noqa ".py": r"""^\s*['\"]?(?P\w+)['\"]?\s*[=:]\s*['\"]?(?P[^\r\n\t\f\v\"']+)['\"]?,?""", # noqa ".cs": r"""^(\w*\s+)*(?P\w+)\s?[=:]\s*['\"]?(?P[^\r\n\t\f\v\"']+)['\"].*""", # noqa ".csproj": r"""^<(?P\w+)>(?P\S+)<\/\w+>""", # noqa ".properties": r"""^\s*(?P\w+)\s*=[\t ]*(?P[^\r\n\t\f\v\"']+)?""", # noqa } trigger_patterns = { - SemVerSigFig.major: os.path.join("docs", "news", "*.major"), - SemVerSigFig.minor: os.path.join("docs", "news", "*.feature"), - SemVerSigFig.patch: os.path.join("docs", "news", "*.bugfix"), + os.path.join("docs", "news", "*.major"): SemVerSigFig.major, + os.path.join("docs", "news", "*.feature"): SemVerSigFig.minor, + os.path.join("docs", "news", "*.bugfix"): SemVerSigFig.patch, } PRERELEASE_TOKEN = "pre" BUILD_TOKEN = "build" TAG_TEMPLATE = "release/{version}" + MIN_NONE_RELEASE_SIGFIG = ( + "prerelease" + ) # the minimum significant figure to increment is this isn't a release @classmethod def _deflate(cls): diff --git a/src/auto_version/replacement_handler.py b/src/auto_version/replacement_handler.py index a3e5275..5cba1cd 100644 --- a/src/auto_version/replacement_handler.py +++ b/src/auto_version/replacement_handler.py @@ -17,19 +17,19 @@ def __init__(self, **params): self.missing = set(params.keys()) def __call__(self, match): - """Given a regex Match Object, return the entire replacement string""" + """Given a regex Match Object, return the entire replacement string + + :raises KeyError: + """ original = match.string key = match.group(Constants.KEY_GROUP) - replacement = self.params.get(key) - if replacement is None: # if this isn't a key we are interested in replacing - replaced = original - else: - start, end = match.span(Constants.VALUE_GROUP) - if start < 0: - # when there's a match but zero-length for the value group, we insert it at the end - # of the line just after the last non-whitespace character - # e.g. blah=\n --> blah=text\n - start = end = len(original.rstrip()) + replacement = self.params[key] # if there's nothing in the lookup, raise KeyError + start, end = match.span(Constants.VALUE_GROUP) + if start < 0: + # when there's a match but zero-length for the value group, we insert it at the end + # of the line just after the last non-whitespace character + # e.g. blah=\n --> blah=text\n + start = end = len(original.rstrip()) + if key in self.missing: self.missing.remove(key) - replaced = "".join([original[:start], str(replacement), original[end:]]) - return replaced + return "".join([original[:start], str(replacement), original[end:]]) diff --git a/src/auto_version/tests/double_target.toml b/src/auto_version/tests/double_target.toml new file mode 100644 index 0000000..2b374f7 --- /dev/null +++ b/src/auto_version/tests/double_target.toml @@ -0,0 +1,15 @@ +[AutoVersionConfig] +CONFIG_NAME = 'example' +PRERELEASE_TOKEN = 'dev' +targets = ["example.py", "example2.py"] + +[AutoVersionConfig.key_aliases] +VERSION = "VERSION_KEY" +VERSION_AGAIN = "VERSION_KEY" +STRICT_VERSION = "VERSION_KEY_STRICT" +LOCK = "VERSION_LOCK" +RELEASE = "RELEASE_FIELD" + +[AutoVersionConfig.trigger_patterns] +# this will trigger on existence of any python file +"*.py" = "minor" diff --git a/src/auto_version/tests/example.toml b/src/auto_version/tests/example.toml index 642e704..94f9187 100644 --- a/src/auto_version/tests/example.toml +++ b/src/auto_version/tests/example.toml @@ -12,4 +12,4 @@ RELEASE = "RELEASE_FIELD" [AutoVersionConfig.trigger_patterns] # this will trigger on existence of any python file -minor = "*.py" +"*.py" = "minor" diff --git a/src/auto_version/tests/example2.py b/src/auto_version/tests/example2.py new file mode 100644 index 0000000..f5e9279 --- /dev/null +++ b/src/auto_version/tests/example2.py @@ -0,0 +1,6 @@ +LOCK = False +RELEASE = True +VERSION = "19.99.0" +VERSION_AGAIN = "19.99.0" +STRICT_VERSION = "19.99.0" +UNRELATED_STRING = "apple" diff --git a/src/auto_version/tests/test_autoversion.py b/src/auto_version/tests/test_autoversion.py index 6a6dca1..b9bec47 100644 --- a/src/auto_version/tests/test_autoversion.py +++ b/src/auto_version/tests/test_autoversion.py @@ -7,9 +7,12 @@ import subprocess import unittest +import semver import six from auto_version import auto_version_tool +from auto_version import utils from auto_version.auto_version_tool import extract_keypairs +from auto_version.auto_version_tool import get_all_versions_from_tags from auto_version.auto_version_tool import main from auto_version.auto_version_tool import replace_lines from auto_version.config import AutoVersionConfig as config @@ -69,19 +72,21 @@ def test_dev(self): self.assertEqual( updates, { - "VERSION": "19.99.0-dev.1", - "VERSION_AGAIN": "19.99.0-dev.1", - "STRICT_VERSION": "19.99.0", + "VERSION": "19.99.1-dev.1", + "VERSION_AGAIN": "19.99.1-dev.1", + "STRICT_VERSION": "19.99.1", }, ) def test_build(self): + # can't just tag a build onto something that's already a release version + self.call(set_to="19.99.0+build.1") old, new, updates = self.call(bump="build") self.assertEqual( updates, { - "VERSION": "19.99.0+build.1", - "VERSION_AGAIN": "19.99.0+build.1", + "VERSION": "19.99.0+build.2", + "VERSION_AGAIN": "19.99.0+build.2", "STRICT_VERSION": "19.99.0", }, ) @@ -124,7 +129,130 @@ def test_custom_field_set(self): self.assertEqual(updates["UNRELATED_STRING"], "apple") -@unittest.skipIf(os.getenv('CI', False), "Running on CI") +class TestMultiFileBumps(unittest.TestCase): + call = functools.partial(main, config_path="double_target.toml") + + @classmethod + def setUpClass(cls): + dir = os.path.dirname(__file__) + os.chdir(os.path.abspath(dir)) + + def tearDown(self): + self.call(set_to="19.99.0") + + def test_bump_patch(self): + old, new, updates = self.call(bump="patch", release=True) + self.assertEqual( + updates, + { + "RELEASE": True, + "VERSION": "19.99.1", + "VERSION_AGAIN": "19.99.1", + "STRICT_VERSION": "19.99.1", + }, + ) + with open("example2.py", "r") as f: + second_file = f.read() + self.assertEqual(second_file, '''LOCK = False +RELEASE = True +VERSION = "19.99.1" +VERSION_AGAIN = "19.99.1" +STRICT_VERSION = "19.99.1" +UNRELATED_STRING = "apple" +''') + + +class TestUtils(unittest.TestCase): + def test_is_release(self): + self.assertTrue(utils.is_release(semver.parse_version_info("1.2.3"))) + self.assertFalse(utils.is_release(semver.parse_version_info("1.2.3-RC.1"))) + self.assertFalse(utils.is_release(semver.parse_version_info("1.2.3+abc"))) + + def test_sigfig_max(self): + self.assertEqual("minor", utils.max_sigfig(["minor", "patch"])) + + def test_sigfig_min(self): + self.assertEqual("minor", utils.min_sigfig(["minor", "major"])) + + def test_sigfig_compare_gt(self): + self.assertFalse(utils.sigfig_gt("minor", "major")) + self.assertFalse(utils.sigfig_gt("minor", "minor")) + self.assertTrue(utils.sigfig_gt("major", "patch")) + + def test_sigfig_compare_lt(self): + self.assertTrue(utils.sigfig_lt("minor", "major")) + self.assertFalse(utils.sigfig_lt("minor", "minor")) + self.assertFalse(utils.sigfig_lt("major", "patch")) + + def test_semver_diff(self): + self.assertEqual( + "minor", + utils.semver_diff( + semver.parse_version_info("1.2.3"), semver.parse_version_info("1.3.5") + ), + ) + self.assertEqual( + "patch", + utils.semver_diff( + semver.parse_version_info("1.2.3"), + semver.parse_version_info("1.2.4-RC.1"), + ), + ) + self.assertEqual( + None, + utils.semver_diff( + semver.parse_version_info("1.2.3"), semver.parse_version_info("1.2.3") + ), + ) + + +class TestNewSemVerLogic(unittest.TestCase): + """Unit testing the core logic that determines a bump""" + + @classmethod + def setUpClass(cls): + test_dir = os.path.dirname(__file__) + auto_version_tool.load_config(os.path.join(test_dir, "example.toml")) + + def check(self, previous, current, bumps, expect): + previous = semver.parse_version_info(previous) if previous else None + self.assertEqual( + expect, + str( + utils.make_new_semver( + semver.parse_version_info(current), previous, bumps + ) + ), + ) + + def test_release_bump(self): + self.check(None, "1.2.3", {"minor"}, "1.3.0-dev.1") + + def test_no_history_bump(self): + self.check(None, "1.2.3", {"prerelease"}, "1.2.4-dev.1") + + # this would be wrong, because you can't pre-release something that's released + # self.check(None, "1.2.3", ["prerelease"], "1.2.3-dev.1") + + def test_no_history_pre_bump(self): + self.check(None, "1.2.3-dev.1", {"prerelease"}, "1.2.3-dev.2") + + def test_release_bump_with_history(self): + self.check("1.2.2", "1.2.3", {"minor"}, "1.3.0-dev.1") + + def test_candidate_bump_with_history_less(self): + # the bump is less significant than the original RC increment + self.check("1.0.0", "1.1.0-dev.3", {"patch"}, "1.1.0-dev.4") + + def test_candidate_bump_with_history_same(self): + # the RC has the same significance from the previous release as the bump + self.check("1.2.2", "1.2.3-dev.1", {"patch"}, "1.2.3-dev.2") + + def test_candidate_bump_with_history_more(self): + # the bump is more significant than the previous release, so perform that bump + self.check("1.2.2", "1.2.3-dev.1", {"minor"}, "1.3.0-dev.1") + + class TestVCSTags(unittest.TestCase): call = functools.partial(main, config_path="example.toml") @@ -140,61 +268,140 @@ def tearDownClass(cls): def setUp(self): cmd = "git tag release/4.5.6" subprocess.check_call(shlex.split(cmd)) - - def tearDown(self): - cmd = "git tag --delete release/4.5.6" + cmd = "git tag release/4.5.7-dev.1" subprocess.check_call(shlex.split(cmd)) - try: - cmd = "git tag --delete release/5.0.0-dev.1" - subprocess.check_call(shlex.split(cmd)) - except Exception: - pass - - def test_from_ancestor_tag(self): - """i.e. most immediate ancestor tag""" - bumped = "5.0.0-dev.1" + # todo: build a git tree with a branch, release and RC on that branch + # (to distinguish global vs ancestry tests) + self.addCleanup( + subprocess.check_call, shlex.split("git tag --delete release/4.5.7-dev.1") + ) + self.addCleanup( + subprocess.check_call, shlex.split("git tag --delete release/4.5.6") + ) + + def test_from_ancestor_version(self): + bumped = "4.5.7-dev.1" + old, new, updates = self.call( + persist_from=[Constants.FROM_VCS_PREVIOUS_VERSION] + ) + self.assertEqual( + updates, + { + "VERSION": bumped, + "VERSION_AGAIN": bumped, + "STRICT_VERSION": semver.finalize_version(bumped), + }, + ) + + def test_from_ancestor_release(self): + bumped = "4.5.6" old, new, updates = self.call( - persist_from=[Constants.FROM_VCS_ANCESTOR], bump="major" + persist_from=[Constants.FROM_VCS_PREVIOUS_RELEASE] ) self.assertEqual( updates, - {"VERSION": bumped, "VERSION_AGAIN": bumped, "STRICT_VERSION": "5.0.0"}, + { + "VERSION": bumped, + "VERSION_AGAIN": bumped, + "STRICT_VERSION": semver.finalize_version(bumped), + }, ) def test_from_latest_of_all_time(self): - """i.e. latest version tag across the entire repo - (TODO: but we cant test global tags without making a new branch etc etc) - """ - bumped = "5.0.0-dev.1" - old, new, updates = self.call( - persist_from=[Constants.FROM_VCS_LATEST], bump="major" + bumped = "4.5.7-dev.1" + old, new, updates = self.call(persist_from=[Constants.FROM_VCS_LATEST_VERSION]) + self.assertEqual( + updates, + { + "VERSION": bumped, + "VERSION_AGAIN": bumped, + "STRICT_VERSION": semver.finalize_version(bumped), + }, ) + + def test_from_latest_of_all_time_release(self): + bumped = "4.5.6" + old, new, updates = self.call(persist_from=[Constants.FROM_VCS_LATEST_RELEASE]) self.assertEqual( updates, - {"VERSION": bumped, "VERSION_AGAIN": bumped, "STRICT_VERSION": "5.0.0"}, + { + "VERSION": bumped, + "VERSION_AGAIN": bumped, + "STRICT_VERSION": semver.finalize_version(bumped), + }, ) def test_to_tag(self): - """writes a tag in git - (TODO: but we cant test global tags without making a new branch etc etc) + """writes a tag in to git """ bumped = "5.0.0-dev.1" old, new, updates = self.call( - persist_from=[Constants.FROM_VCS_LATEST], + persist_from=[Constants.FROM_VCS_LATEST_VERSION], persist_to=[Constants.TO_VCS], bump="major", ) + self.addCleanup( + subprocess.check_call, shlex.split("git tag --delete release/5.0.0-dev.1") + ) self.assertEqual( updates, - {"VERSION": bumped, "VERSION_AGAIN": bumped, "STRICT_VERSION": "5.0.0"}, + { + "VERSION": bumped, + "VERSION_AGAIN": bumped, + "STRICT_VERSION": semver.finalize_version(bumped), + }, ) - version = auto_version_tool.get_dvcs_latest_tag_semver() + version = auto_version_tool.get_dvcs_repo_latest_version_semver() self.assertEqual( dict(version._asdict()), dict(major=5, minor=0, patch=0, build=None, prerelease="dev.1"), ) +class TestTagReplacements(unittest.TestCase): + some_tags = [ + "0.0.0", + "0.1.0", + "v0.2.0", + "0.3.0v", + "my_project/0.4.0", + "my_project/0.5.0/releases", + "my_project/0.6.0-RC.2+build-99/releases", + r"£*ORWI\H'#[;'Q", + ] + + @classmethod + def setUpClass(cls): + cls._default_template = config.TAG_TEMPLATE + + @classmethod + def tearDownClass(cls): + config.TAG_TEMPLATE = cls._default_template + + def eval(self, template, tags, expect): + config.TAG_TEMPLATE = template + self.assertEqual(get_all_versions_from_tags(tags), expect) + + def test_empty_tag(self): + self.eval("", self.some_tags, []) + + def test_v_tag(self): + self.eval("v{version}", self.some_tags, ["0.2.0"]) + + def test_plain_tag(self): + self.eval("{version}", self.some_tags, ["0.0.0", "0.1.0"]) + + def test_prefix_tag(self): + self.eval("my_project/{version}", self.some_tags, ["0.4.0"]) + + def test_prefix_suffix_tag(self): + self.eval( + "my_project/{version}/releases", + self.some_tags, + ["0.5.0", "0.6.0-RC.2+build-99"], + ) + + @contextlib.contextmanager def Noop(): """A no-op context manager""" @@ -211,18 +418,40 @@ class BaseReplaceCheck(unittest.TestCase): non_matching = [] # specify example lines that should not match def test_match(self): + """ + Check that for each specified line, a match is triggered + + n.b. a match must include the full length of the line, or nothing at all + + if it includes the full length of the line, there must be two named groups + `KEY` and `VALUE` that contain only the key and value respectively + + :return: + """ for line in self.lines: with self.subTest(line=line) if six.PY3 else Noop(): extracted = extract_keypairs([line], self.regexer) self.assertEqual({self.key: self.value}, extracted) def test_non_match(self): + """ + Check lines that shouldn't trigger any matches + :return: + """ for line in self.non_matching: with self.subTest(line=line) if six.PY3 else Noop(): extracted = extract_keypairs([line], self.regexer) self.assertEqual({}, extracted) def test_replace(self): + """ + takes all the 'lines' and generates an expected value with a simple replacement + (1.2.3.4+dev0 -> 5.6.7.8+dev1) + additionally, explicit replacements can be tested + they are all run through the ReplacementHandler to check + the expected value + """ + replacements = {} replacements.update(self.explicit_replacement) replacements.update( @@ -292,3 +521,18 @@ class XMLRegexTest(BaseReplaceCheck): '\r\n', """\r\n""", ] + + +class YamlRegexTest(BaseReplaceCheck): + regexer = re.compile(config.regexers[".yaml"]) + lines = [ + """ "custom_Key": '1.2.3.4+dev0'\r\n""", + """ custom_Key: 1.2.3.4+dev0""", + """ custom_Key: 1.2.3.4+dev0 # comment""", + ] + explicit_replacement = { + " name: python:3.7.1\r\n": " name: python:3.7.1\r\n", + " custom_Key: 1.2.3.4+dev0 # yay": " custom_Key: 5.6.7.8+dev1 # yay", + " CTEST_ARGS: -L node_cpu\r\n": " CTEST_ARGS: -L node_cpu\r\n", + } + non_matching = ["""entrypoint: [""]\r\n"""] # don't match on empty arrays diff --git a/src/auto_version/utils.py b/src/auto_version/utils.py index a247a35..3a39d32 100644 --- a/src/auto_version/utils.py +++ b/src/auto_version/utils.py @@ -10,7 +10,10 @@ def from_text_or_none(text): - """A version or None""" + """A version or None + + :rtype: semver.VersionInfo | None + """ if text is not None: try: return semver.parse_version_info(text) @@ -60,7 +63,7 @@ def get_semver_from_source(data): if versions: result = versions[0] _LOG.info("latest version found in source: %r", result) - return result + return semver.parse_version_info(result) def get_token_args(sig_fig): @@ -72,41 +75,90 @@ def get_token_args(sig_fig): return token_args -def make_new_semver(version_string, all_triggers, **overrides): +def max_sigfig(sigfigs): + """Given a list of significant figures, return the largest""" + for sig_fig in SemVerSigFig: # iterate sig figs in order of significance + if sig_fig in sigfigs: + return sig_fig + + +def min_sigfig(sigfigs): + """Given a list of significant figures, return the smallest""" + for sig_fig in reversed(SemVerSigFig): # iterate sig figs in order of least significance + if sig_fig in sigfigs: + return sig_fig + + +def semver_diff(semver1, semver2): + """Given some semvers, return the largest difference between them""" + for sig_fig in SemVerSigFig: + if getattr(semver1, sig_fig) != getattr(semver2, sig_fig): + return sig_fig + + +def sigfig_gt(sig_fig1, sig_fig2): + """Returns True if sf1 > sf2""" + return SemVerSigFig.index(sig_fig1) < SemVerSigFig.index(sig_fig2) + + +def sigfig_lt(sig_fig1, sig_fig2): + """Returns True if sf1 < sf2""" + return SemVerSigFig.index(sig_fig1) > SemVerSigFig.index(sig_fig2) + + +def is_release(semver): + """is a semver a release version""" + return not (semver.build or semver.prerelease) + + +def make_new_semver(current_semver, last_release_semver, all_triggers, **overrides): """Defines how to increment semver based on which significant figure is triggered - (most significant takes precendence) - :param version_string: the version to increment - :param all_triggers: major/minor/patch/prerelease + :param current_semver: the version to increment + :param last_release_semver: the previous release version, if available + :param all_triggers: list of major/minor/patch/prerelease :param overrides: explicit values for some or all of the sigfigs :return: """ + version_string = str(current_semver) + + # if the current version isn't a full release + if not is_release(current_semver) and last_release_semver: + # we check to see how important the changes are + # in the triggers, compared to the changes made between the current version and previous release + if sigfig_gt(max_sigfig(all_triggers), semver_diff(current_semver, last_release_semver)): + # here, the changes are more significant than the original RC bump, so we re-bump + pass + else: + # here the changes are same or lesser than the original RC bump, so we only bump prerelease + all_triggers = {SemVerSigFig.prerelease} - # perform an increment using the most-significant trigger - also_prerelease = True - for sig_fig in SemVerSigFig: # iterate sig figs in order of significance - if sig_fig in all_triggers: - if sig_fig in (SemVerSigFig.prerelease, SemVerSigFig.build): - also_prerelease = False - version_string = getattr(semver, "bump_" + sig_fig)( - version_string, **get_token_args(sig_fig) - ) - break - - if also_prerelease: - # if we *didnt* increment sub-patch, then we should do so - # this provides the "devmode template" as previously - # and ensures a simple 'bump' doesn't look like a full release - version_string = semver.bump_prerelease( - version_string, token=config.PRERELEASE_TOKEN + if is_release(current_semver): + # if the current semver is a release, we can't just do a prerelease or build increment + # there *must* be a minimum patch increment, otherwise you could get 2.0.0 -> 2.0.0-RC.1 + all_triggers.add(SemVerSigFig.patch) + + bump_sigfig = max_sigfig(all_triggers) + + if bump_sigfig: + # perform an increment using the most-significant trigger + version_string = getattr(semver, "bump_" + bump_sigfig)( + str(current_semver), **get_token_args(bump_sigfig) ) - # perform any explicit setting of parts + if sigfig_gt(bump_sigfig, SemVerSigFig.prerelease): + # if we *didnt* increment sub-patch already, then we should do so + # this provides the "devmode template" as previously + # and ensures a simple 'bump' doesn't look like a full release + version_string = semver.bump_prerelease( + version_string, token=config.PRERELEASE_TOKEN + ) + + # perform any explicit setting of sigfigs version_info = semver.parse_version_info(version_string) for k, v in overrides.items(): token_args = get_token_args(k) prefix = list(token_args.values()).pop() + "." if token_args else "" setattr(version_info, "_" + k, prefix + str(v)) - version_string = str(version_info) - return version_string + return version_info