"""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 print_version(
*,
current=False,
force_level=None,
prerelease=False,
prerelease_patch=True,
**kwargs,
):
"""
Print the current or new version to standard output.
"""
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:
print(str(e), file=sys.stderr)
return False
if current:
print(current_version, end="")
return True
# Find what the new version number should be
level_bump = evaluate_version_bump(current_version, force_level)
new_version = get_new_version(
current_version,
current_release_version,
level_bump,
prerelease,
prerelease_patch,
)
if should_bump_version(
current_version=current_version,
new_version=new_version,
current_release_version=current_release_version,
prerelease=prerelease,
):
print(new_version, end="")
return True
print("No release will be made.", file=sys.stderr)
return False
[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)