Source code for semantic_release.cli

"""CLI
"""
import logging
import os
import sys
from pathlib import Path

import click
import click_log

from semantic_release import ci_checks
from semantic_release.errors import GitError, ImproperConfigurationError

from .changelog import markdown_changelog
from .dist import build_dists, remove_dists, should_build, should_remove_dist
from .history import (
    evaluate_version_bump,
    get_current_release_version,
    get_current_version,
    get_new_version,
    get_previous_release_version,
    get_previous_version,
    set_new_version,
)
from .history.logs import generate_changelog
from .hvcs import (
    check_build_status,
    check_token,
    get_domain,
    get_token,
    post_changelog,
    upload_to_release,
)
from .pre_commit import run_pre_commit, should_run_pre_commit
from .repository import ArtifactRepo
from .settings import config, overload_configuration
from .vcs_helpers import (
    checkout,
    commit_new_version,
    get_current_head_hash,
    get_repository_owner_and_name,
    push_new_version,
    tag_new_version,
    update_additional_files,
    update_changelog_file,
)

logger = logging.getLogger("semantic_release")

TOKEN_VARS = [
    "github_token_var",
    "gitlab_token_var",
    "pypi_pass_var",
    "pypi_token_var",
    "pypi_user_var",
    "repository_user_var",
    "repository_pass_var",
]

COMMON_OPTIONS = [
    click_log.simple_verbosity_option(logger),
    click.option(
        "--major", "force_level", flag_value="major", help="Force major version."
    ),
    click.option(
        "--minor", "force_level", flag_value="minor", help="Force minor version."
    ),
    click.option(
        "--patch", "force_level", flag_value="patch", help="Force patch version."
    ),
    click.option("--prerelease", is_flag=True, help="Creates a prerelease version."),
    click.option(
        "--prerelease-patch/--no-prerelease-patch",
        "prerelease_patch",
        default=True,
        show_default=True,
        help="whether or not prerelease always gets at least a patch-level bump",
    ),
    click.option("--post", is_flag=True, help="Post changelog."),
    click.option("--retry", is_flag=True, help="Retry the same release, do not bump."),
    click.option(
        "--noop",
        is_flag=True,
        help="No-operations mode, finds the new version number without changing it.",
    ),
    click.option(
        "--define",
        "-D",
        multiple=True,
        help='setting="value", override a configuration value.',
    ),
    overload_configuration,
]


[docs]def common_options(func): """ Decorator that adds all the options in COMMON_OPTIONS """ for option in reversed(COMMON_OPTIONS): func = option(func) return func
[docs]def version( *, retry=False, noop=False, force_level=None, prerelease=False, prerelease_patch=True, **kwargs, ): """ Detect the new version according to git log and semver. Write the new version number and commit it, unless the noop option is True. """ if retry: logger.info("Retrying publication of the same version") else: logger.info("Creating new version") # Get the current version number try: current_version = get_current_version() current_release_version = get_current_release_version() logger.info( f"Current version: {current_version}, Current release version: {current_release_version}" ) except GitError as e: logger.error(str(e)) return False # Find what the new version number should be level_bump = evaluate_version_bump(current_release_version, force_level) new_version = get_new_version( current_version, current_release_version, level_bump, prerelease, prerelease_patch, ) if not should_bump_version( current_version=current_version, new_version=new_version, current_release_version=current_release_version, prerelease=prerelease, retry=retry, noop=noop, ): return False if retry: # No need to make changes to the repo, we're just retrying. return True # Bump the version bump_version(new_version, level_bump) return True
[docs]def should_bump_version( *, current_version, current_release_version, new_version, prerelease, retry=False, noop=False, ): match_version = current_version if prerelease else current_release_version """Test whether the version should be bumped.""" if new_version == match_version and not retry: logger.info("No release will be made.") return False if noop: logger.warning( "No operation mode. Should have bumped " f"from {current_version} to {new_version}" ) return False if config.get("check_build_status"): logger.info("Checking build status...") owner, name = get_repository_owner_and_name() if not check_build_status(owner, name, get_current_head_hash()): logger.warning("The build failed, cancelling the release") return False logger.info("The build was a success, continuing the release") return True
[docs]def bump_version(new_version, level_bump): """ Set the version to the given `new_version`. Edit in the source code, commit and create a git tag. """ logger.info(f"Bumping with a {level_bump} version to {new_version}") if config.get("version_source") == "tag_only": tag_new_version(new_version) # we are done, no need for file changes if we are using # tags as version source return set_new_version(new_version) if config.get( "commit_version_number", config.get("version_source") == "commit", ): commit_new_version(new_version) if config.get("version_source") == "tag" or config.get("tag_commit"): tag_new_version(new_version)
[docs]def changelog(*, unreleased=False, noop=False, post=False, prerelease=False, **kwargs): """ Generate the changelog since the last release. :raises ImproperConfigurationError: if there is no current version """ current_version = get_current_version() if current_version is None: raise ImproperConfigurationError( "Unable to get the current version. " "Make sure semantic_release.version_variable " "is setup correctly" ) previous_version = get_previous_version(current_version) # Generate the changelog if unreleased: log = generate_changelog(current_version, None) else: log = generate_changelog(previous_version, current_version) owner, name = get_repository_owner_and_name() # print is used to keep the changelog on stdout, separate from log messages print(markdown_changelog(owner, name, current_version, log, header=False)) # Post changelog to HVCS if enabled if not noop and post: if check_token(): logger.info("Posting changelog to HVCS") post_changelog( owner, name, current_version, markdown_changelog(owner, name, current_version, log, header=False), ) else: logger.error("Missing token: cannot post changelog to HVCS")
[docs]def publish( retry: bool = False, noop: bool = False, prerelease: bool = False, prerelease_patch=True, **kwargs, ): """Run the version task, then push to git and upload to an artifact repository / GitHub Releases.""" current_version = get_current_version() current_release_version = get_current_release_version() logger.info( f"Current version: {current_version}, Current release version: {current_release_version}" ) verbose = logger.isEnabledFor(logging.DEBUG) if retry: logger.info("Retry is on") # The "new" version will actually be the current version, and the # "current" version will be the previous version. level_bump = None new_version = current_version current_version = get_previous_release_version(current_version) else: # Calculate the new version level_bump = evaluate_version_bump( current_release_version, kwargs.get("force_level") ) new_version = get_new_version( current_version, current_release_version, level_bump, prerelease, prerelease_patch, ) owner, name = get_repository_owner_and_name() branch = config.get("branch") logger.debug(f"Running publish on branch {branch}") ci_checks.check(branch) checkout(branch) if should_bump_version( current_version=current_version, new_version=new_version, current_release_version=current_release_version, prerelease=prerelease, retry=retry, noop=noop, ): log = generate_changelog(current_version) changelog_md = markdown_changelog( owner, name, new_version, log, header=False, previous_version=current_version, ) if should_run_pre_commit(): logger.info("Running pre-commit command") run_pre_commit() if not retry: update_changelog_file(new_version, changelog_md) update_additional_files() bump_version(new_version, level_bump) # A new version was released logger.info("Pushing new version") push_new_version( auth_token=get_token(), owner=owner, name=name, branch=branch, domain=get_domain(), ) # Get config options for uploads dist_path = config.get("dist_path") upload_release = config.get("upload_to_release") if should_build(): # We need to run the command to build wheels for releasing logger.info("Building distributions") if should_remove_dist(): # Remove old distributions before building remove_dists(dist_path) build_dists() if ArtifactRepo.upload_enabled(): logger.info("Uploading to artifact Repository") ArtifactRepo(Path(dist_path)).upload( noop=noop, verbose=verbose, skip_existing=retry ) if check_token(): # Update changelog on HVCS logger.info("Posting changelog to HVCS") try: post_changelog(owner, name, new_version, changelog_md) except GitError: logger.error("Posting changelog failed") else: logger.warning("Missing token: cannot post changelog to HVCS") # Upload to GitHub Releases if upload_release: if check_token(): logger.info("Uploading to HVCS release") upload_to_release(owner, name, new_version, dist_path) logger.info("Upload to HVCS is complete") else: logger.warning("Missing token: cannot upload to HVCS") # Remove distribution files as they are no longer needed if should_remove_dist(): logger.info("Removing distribution files") remove_dists(dist_path) logger.info("Publish has finished")
# else: Since version shows a message on failure, we do not need to print another.
[docs]def filter_output_for_secrets(message): """Remove secrets from cli output.""" output = message for token_var in TOKEN_VARS: secret_name = config.get(token_var) secret = os.environ.get(secret_name) if secret != "" and secret is not None: output = output.replace(secret, f"${secret_name}") return output
[docs]def entry(): # Move flags to after the command ARGS = sorted(sys.argv[1:], key=lambda x: 1 if x.startswith("--") else -1) if ARGS and not ARGS[0].startswith("print-"): # print-* command output should not be polluted with logging. click_log.basic_config() main(args=ARGS)
# # Making the CLI commands. # We have a level of indirection to the logical commands # so we can successfully mock them during testing # @click.group() @common_options def main(**kwargs): logger.debug(f"Main args: {kwargs}") message = "" for token_var in TOKEN_VARS: secret_name = config.get(token_var) message += f'{secret_name}="{os.environ.get(secret_name)}",' logger.debug(f"Environment: {filter_output_for_secrets(message)}") obj = {} for key in [ "check_build_status", "commit_subject", "commit_message", "commit_parser", "patch_without_tag", "major_on_zero", "upload_to_pypi", "upload_to_repository", "version_source", "no_git_tag", ]: obj[key] = config.get(key) logger.debug(f"Main config: {obj}") @main.command(name="publish", help=publish.__doc__) @common_options def cmd_publish(**kwargs): try: return publish(**kwargs) except Exception as error: logger.error(filter_output_for_secrets(str(error))) exit(1) @main.command(name="changelog", help=changelog.__doc__) @common_options @click.option( "--unreleased/--released", help="Decides whether to show the released or unreleased changelog.", ) def cmd_changelog(**kwargs): try: return changelog(**kwargs) except Exception as error: logger.error(filter_output_for_secrets(str(error))) exit(1) @main.command(name="version", help=version.__doc__) @common_options def cmd_version(**kwargs): try: return version(**kwargs) except Exception as error: logger.error(filter_output_for_secrets(str(error))) exit(1) @main.command(name="print-version", help=print_version.__doc__) @common_options @click.option( "--current/--next", default=False, help="Choose to output next version (default) or current one.", ) def cmd_print_version(**kwargs): try: return print_version(**kwargs) except Exception as error: print(filter_output_for_secrets(str(error)), file=sys.stderr) exit(1)