diff --git a/appveyor.yml b/appveyor.yml index 19653ef..ccb7b5e 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -29,6 +29,7 @@ init: - ps: "ls C:/Python*" install: + - "%PYTHON%/python.exe -m pip install --upgrade wheel pip setuptools virtualenv" - "%PYTHON%/python.exe -m pip install tox" - "%PYTHON%/python.exe -m pip install -e ." diff --git a/bumpversion/cli.py b/bumpversion/cli.py index 9ccc4cc..d63f054 100644 --- a/bumpversion/cli.py +++ b/bumpversion/cli.py @@ -4,6 +4,7 @@ import argparse from datetime import datetime import io +import itertools import logging import os import re @@ -66,6 +67,58 @@ ] +def main(original_args=None): + # determine configuration based on command-line arguments + # and on-disk configuration files + args, known_args, root_parser, positionals = _parse_arguments_phase_1(original_args) + _setup_logging(known_args.list, known_args.verbose) + vcs_info = _determine_vcs_usability() + defaults = _determine_current_version(vcs_info) + explicit_config = None + if hasattr(known_args, "config_file"): + explicit_config = known_args.config_file + config_file = _determine_config_file(explicit_config) + config, config_file_exists, config_newlines, part_configs, files = _load_configuration( + config_file, explicit_config, defaults, + ) + known_args, parser2, remaining_argv = _parse_arguments_phase_2( + args, known_args, defaults, root_parser + ) + version_config = _setup_versionconfig(known_args, part_configs) + current_version = version_config.parse(known_args.current_version) + context = dict( + itertools.chain(time_context.items(), prefixed_environ().items(), vcs_info.items()) + ) + + # calculate the desired new version + new_version = _assemble_new_version( + context, current_version, defaults, known_args.current_version, positionals, version_config + ) + args, file_names = _parse_arguments_phase_3(remaining_argv, positionals, defaults, parser2) + new_version = _parse_new_version(args, new_version, version_config) + + # replace version in target files + vcs = _determine_vcs_dirty(VCS, defaults) + files.extend( + ConfiguredFile(file_name, version_config) + for file_name + in (file_names or positionals[1:]) + ) + _check_files_contain_version(files, current_version, context) + _replace_version_in_files(files, current_version, new_version, args.dry_run, context) + _log_list(config, args.new_version) + + # store the new version + _update_config_file( + config, config_file, config_newlines, config_file_exists, args.new_version, args.dry_run, + ) + + # commit and tag + if vcs: + context = _commit_to_vcs(files, context, config_file, config_file_exists, vcs, args) + _tag_in_vcs(vcs, context, args) + + def split_args_in_optional_and_positional(args): # manually parsing positional arguments because stupid argparse can't mix # positional and optional arguments @@ -89,233 +142,224 @@ def split_args_in_optional_and_positional(args): return (positionals, args) -def main(original_args=None): - +def _parse_arguments_phase_1(original_args): positionals, args = split_args_in_optional_and_positional( sys.argv[1:] if original_args is None else original_args ) - if len(positionals[1:]) > 2: warnings.warn( - "Giving multiple files on the command line will be deprecated," - " please use [bumpversion:file:...] in a config file.", + "Giving multiple files on the command line will be deprecated, " + "please use [bumpversion:file:...] in a config file.", PendingDeprecationWarning, ) - - parser1 = argparse.ArgumentParser(add_help=False) - - parser1.add_argument( + root_parser = argparse.ArgumentParser(add_help=False) + root_parser.add_argument( "--config-file", metavar="FILE", default=argparse.SUPPRESS, required=False, help="Config file to read most of the variables from (default: .bumpversion.cfg)", ) - - parser1.add_argument( + root_parser.add_argument( "--verbose", action="count", default=0, help="Print verbose logging to stderr", required=False, ) - - parser1.add_argument( + root_parser.add_argument( "--list", action="store_true", default=False, help="List machine readable information", required=False, ) - - parser1.add_argument( + root_parser.add_argument( "--allow-dirty", action="store_true", default=False, help="Don't abort if working directory is dirty", required=False, ) + known_args, _ = root_parser.parse_known_args(args) + return args, known_args, root_parser, positionals - known_args, remaining_argv = parser1.parse_known_args(args) +def _setup_logging(show_list, verbose): logformatter = logging.Formatter("%(message)s") - if not logger_list.handlers: ch2 = logging.StreamHandler(sys.stdout) ch2.setFormatter(logformatter) logger_list.addHandler(ch2) - - if known_args.list: + if show_list: logger_list.setLevel(logging.DEBUG) - try: - log_level = [logging.WARNING, logging.INFO, logging.DEBUG][known_args.verbose] + log_level = [logging.WARNING, logging.INFO, logging.DEBUG][verbose] except IndexError: log_level = logging.DEBUG - root_logger = logging.getLogger('') root_logger.setLevel(log_level) - logger.debug("Starting %s", DESCRIPTION) - defaults = {} - vcs_info = {} +def _determine_vcs_usability(): + vcs_info = {} for vcs in VCS: if vcs.is_usable(): vcs_info.update(vcs.latest_tag_info()) + return vcs_info + +def _determine_current_version(vcs_info): + defaults = {} if "current_version" in vcs_info: defaults["current_version"] = vcs_info["current_version"] + return defaults - explicit_config = hasattr(known_args, "config_file") +def _determine_config_file(explicit_config): if explicit_config: - config_file = known_args.config_file - elif not os.path.exists(".bumpversion.cfg") and os.path.exists("setup.cfg"): - config_file = "setup.cfg" - else: - config_file = ".bumpversion.cfg" + return explicit_config + if not os.path.exists(".bumpversion.cfg") and os.path.exists("setup.cfg"): + return "setup.cfg" + return ".bumpversion.cfg" + +def _load_configuration(config_file, explicit_config, defaults): # setup.cfg supports interpolation - for compatibility we must do the same. if os.path.basename(config_file) == "setup.cfg": config = ConfigParser("") else: config = RawConfigParser("") - # don't transform keys to lowercase (which would be the default) config.optionxform = lambda option: option - config.add_section("bumpversion") - config_file_exists = os.path.exists(config_file) - part_configs = {} + if not config_file_exists: + message = "Could not read config file at {}".format(config_file) + if explicit_config: + raise argparse.ArgumentTypeError(message) + logger.info(message) + return config, config_file_exists, None, {}, [] - files = [] + logger.info("Reading config file %s:", config_file) - if config_file_exists: + with io.open(config_file, "rt", encoding="utf-8") as config_fp: + config_content = config_fp.read() + config_newlines = config_fp.newlines - logger.info("Reading config file %s:", config_file) - # TODO: this is a DEBUG level log - with io.open(config_file, "rt", encoding="utf-8") as f: - logger.info(f.read()) - config_new_lines = f.newlines + # TODO: this is a DEBUG level log + logger.info(config_content) - try: - # TODO: we're reading the config file twice. - config.read_file(io.open(config_file, "rt", encoding="utf-8")) - except AttributeError: - # python 2 standard ConfigParser doesn't have read_file, - # only deprecated readfp - config.readfp(io.open(config_file, "rt", encoding="utf-8")) - - log_config = StringIO() - config.write(log_config) - - if "files" in dict(config.items("bumpversion")): - warnings.warn( - "'files =' configuration will be deprecated, please use [bumpversion:file:...]", - PendingDeprecationWarning, - ) + try: + config.read_string(config_content) + except AttributeError: + # python 2 standard ConfigParser doesn't have read_string, + # only deprecated readfp + config.readfp(io.open(config_file, "rt", encoding="utf-8")) - defaults.update(dict(config.items("bumpversion"))) + log_config = StringIO() + config.write(log_config) - for listvaluename in ("serialize",): - try: - value = config.get("bumpversion", listvaluename) - defaults[listvaluename] = list( - filter(None, (x.strip() for x in value.splitlines())) - ) - except NoOptionError: - pass # no default value then ;) + if config.has_option("bumpversion", "files"): + warnings.warn( + "'files =' configuration will be deprecated, please use [bumpversion:file:...]", + PendingDeprecationWarning, + ) - for boolvaluename in ("commit", "tag", "dry_run"): - try: - defaults[boolvaluename] = config.getboolean( - "bumpversion", boolvaluename - ) - except NoOptionError: - pass # no default value then ;) + defaults.update(dict(config.items("bumpversion"))) - for section_name in config.sections(): + for listvaluename in ("serialize",): + try: + value = config.get("bumpversion", listvaluename) + defaults[listvaluename] = list( + filter(None, (x.strip() for x in value.splitlines())) + ) + except NoOptionError: + pass # no default value then ;) - section_name_match = re.compile("^bumpversion:(file|part):(.+)").match( - section_name + for boolvaluename in ("commit", "tag", "dry_run"): + try: + defaults[boolvaluename] = config.getboolean( + "bumpversion", boolvaluename ) + except NoOptionError: + pass # no default value then ;) - if not section_name_match: - continue + part_configs = {} + files = [] + file_or_part = re.compile("^bumpversion:(file|part):(.+)") + for section_name in config.sections(): + section_name_match = file_or_part.match(section_name) - section_prefix, section_value = section_name_match.groups() + if not section_name_match: + continue - section_config = dict(config.items(section_name)) + section_prefix, section_value = section_name_match.groups() - if section_prefix == "part": + section_config = dict(config.items(section_name)) - ThisVersionPartConfiguration = NumericVersionPartConfiguration + if section_prefix == "part": + ThisVersionPartConfiguration = NumericVersionPartConfiguration - if "values" in section_config: - section_config["values"] = list( - filter( - None, - (x.strip() for x in section_config["values"].splitlines()), - ) + if "values" in section_config: + section_config["values"] = list( + filter( + None, + (x.strip() for x in section_config["values"].splitlines()), ) - ThisVersionPartConfiguration = ConfiguredVersionPartConfiguration - - part_configs[section_value] = ThisVersionPartConfiguration( - **section_config ) + ThisVersionPartConfiguration = ConfiguredVersionPartConfiguration - elif section_prefix == "file": - - filename = section_value + part_configs[section_value] = ThisVersionPartConfiguration( + **section_config + ) - if "serialize" in section_config: - section_config["serialize"] = list( - filter( - None, - ( - x.strip().replace("\\n", "\n") - for x in section_config["serialize"].splitlines() - ), - ) + elif section_prefix == "file": + filename = section_value + + if "serialize" in section_config: + section_config["serialize"] = list( + filter( + None, + ( + x.strip().replace("\\n", "\n") + for x in section_config["serialize"].splitlines() + ), ) + ) - section_config["part_configs"] = part_configs + section_config["part_configs"] = part_configs - if "parse" not in section_config: - section_config["parse"] = defaults.get( - "parse", r"(?P\d+)\.(?P\d+)\.(?P\d+)" - ) + if "parse" not in section_config: + section_config["parse"] = defaults.get( + "parse", r"(?P\d+)\.(?P\d+)\.(?P\d+)" + ) - if "serialize" not in section_config: - section_config["serialize"] = defaults.get( - "serialize", [str("{major}.{minor}.{patch}")] - ) + if "serialize" not in section_config: + section_config["serialize"] = defaults.get( + "serialize", [str("{major}.{minor}.{patch}")] + ) - if "search" not in section_config: - section_config["search"] = defaults.get( - "search", "{current_version}" - ) + if "search" not in section_config: + section_config["search"] = defaults.get( + "search", "{current_version}" + ) - if "replace" not in section_config: - section_config["replace"] = defaults.get("replace", "{new_version}") + if "replace" not in section_config: + section_config["replace"] = defaults.get("replace", "{new_version}") - files.append(ConfiguredFile(filename, VersionConfig(**section_config))) + files.append(ConfiguredFile(filename, VersionConfig(**section_config))) - else: - message = "Could not read config file at {}".format(config_file) - if explicit_config: - raise argparse.ArgumentTypeError(message) - logger.info(message) + return config, config_file_exists, config_newlines, part_configs, files + +def _parse_arguments_phase_2(args, known_args, defaults, root_parser): parser2 = argparse.ArgumentParser( - prog="bumpversion", add_help=False, parents=[parser1] + prog="bumpversion", add_help=False, parents=[root_parser] ) parser2.set_defaults(**defaults) - parser2.add_argument( "--current-version", metavar="VERSION", @@ -349,51 +393,51 @@ def main(original_args=None): help="Template for complete string to replace", default=defaults.get("replace", "{new_version}"), ) - known_args, remaining_argv = parser2.parse_known_args(args) defaults.update(vars(known_args)) assert isinstance(known_args.serialize, list), "Argument `serialize` must be a list" - context = dict( - list(time_context.items()) - + list(prefixed_environ().items()) - + list(vcs_info.items()) - ) + return known_args, parser2, remaining_argv + +def _setup_versionconfig(known_args, part_configs): try: - vc = VersionConfig( + version_config = VersionConfig( parse=known_args.parse, serialize=known_args.serialize, search=known_args.search, replace=known_args.replace, part_configs=part_configs, ) - except sre_constants.error as e: + except sre_constants.error: # TODO: use re.error here mayhaps, also: should we log? sys.exit(1) + return version_config - current_version = ( - vc.parse(known_args.current_version) if known_args.current_version else None - ) +def _assemble_new_version( + context, current_version, defaults, arg_current_version, positionals, version_config +): new_version = None - - if "new_version" not in defaults and known_args.current_version: + if "new_version" not in defaults and arg_current_version: try: if current_version and positionals: logger.info("Attempting to increment part '%s'", positionals[0]) - new_version = current_version.bump(positionals[0], vc.order()) + new_version = current_version.bump(positionals[0], version_config.order()) logger.info("Values are now: %s", keyvaluestring(new_version._values)) - defaults["new_version"] = vc.serialize(new_version, context) + defaults["new_version"] = version_config.serialize(new_version, context) except MissingValueForSerializationException as e: logger.info("Opportunistic finding of new_version failed: %s", e.message) except IncompleteVersionRepresentationException as e: logger.info("Opportunistic finding of new_version failed: %s", e.message) except KeyError as e: logger.info("Opportunistic finding of new_version failed") + return new_version + +def _parse_arguments_phase_3(remaining_argv, positionals, defaults, parser2): parser3 = argparse.ArgumentParser( prog="bumpversion", description=DESCRIPTION, @@ -401,9 +445,7 @@ def main(original_args=None): conflict_handler="resolve", parents=[parser2], ) - parser3.set_defaults(**defaults) - parser3.add_argument( "--current-version", metavar="VERSION", @@ -423,9 +465,7 @@ def main(original_args=None): help="New version that should be in the files", required="new_version" not in defaults, ) - commitgroup = parser3.add_mutually_exclusive_group() - commitgroup.add_argument( "--commit", action="store_true", @@ -440,9 +480,7 @@ def main(original_args=None): help="Do not commit to version control", default=argparse.SUPPRESS, ) - taggroup = parser3.add_mutually_exclusive_group() - taggroup.add_argument( "--tag", action="store_true", @@ -457,7 +495,6 @@ def main(original_args=None): help="Do not create a tag in version control", default=argparse.SUPPRESS, ) - signtagsgroup = parser3.add_mutually_exclusive_group() signtagsgroup.add_argument( "--sign-tags", @@ -473,14 +510,12 @@ def main(original_args=None): help="Do not sign tags if created", default=argparse.SUPPRESS, ) - parser3.add_argument( "--tag-name", metavar="TAG_NAME", help="Tag name (only works with --tag)", default=defaults.get("tag_name", "v{new_version}"), ) - parser3.add_argument( "--tag-message", metavar="TAG_MESSAGE", @@ -490,7 +525,6 @@ def main(original_args=None): "tag_message", "Bump version: {current_version} → {new_version}" ), ) - parser3.add_argument( "--message", "-m", @@ -500,88 +534,91 @@ def main(original_args=None): "message", "Bump version: {current_version} → {new_version}" ), ) - file_names = [] if "files" in defaults: assert defaults["files"] is not None file_names = defaults["files"].split(" ") - parser3.add_argument("part", help="Part of the version to be bumped.") parser3.add_argument( "files", metavar="file", nargs="*", help="Files to change", default=file_names ) - args = parser3.parse_args(remaining_argv + positionals) if args.dry_run: logger.info("Dry run active, won't touch any files.") + return args, file_names + + +def _parse_new_version(args, new_version, vc): if args.new_version: new_version = vc.parse(args.new_version) - logger.info("New version will be '%s'", args.new_version) + return new_version - file_names = file_names or positionals[1:] - for file_name in file_names: - files.append(ConfiguredFile(file_name, vc)) +def _determine_vcs_dirty(possible_vcses, defaults): + for vcs in possible_vcses: + if not vcs.is_usable(): + continue - for vcs in VCS: - if vcs.is_usable(): - try: - vcs.assert_nondirty() - except WorkingDirectoryIsDirtyException as e: - if not defaults["allow_dirty"]: - logger.warning( - "%s\n\nUse --allow-dirty to override this if you know what you're doing.", - e.message - ) - raise - break - else: - vcs = None + try: + vcs.assert_nondirty() + except WorkingDirectoryIsDirtyException as e: + if not defaults["allow_dirty"]: + logger.warning( + "%s\n\nUse --allow-dirty to override this if you know what you're doing.", + e.message, + ) + raise - # make sure files exist and contain version string + return vcs + return None + + +def _check_files_contain_version(files, current_version, context): + # make sure files exist and contain version string logger.info( "Asserting files %s contain the version string...", - ", ".join([str(f) for f in files]) + ", ".join([str(f) for f in files]), ) - for f in files: f.should_contain_version(current_version, context) + +def _replace_version_in_files(files, current_version, new_version, dry_run, context): # change version string in files for f in files: - f.replace(current_version, new_version, context, args.dry_run) - - commit_files = [f.path for f in files] + f.replace(current_version, new_version, context, dry_run) - config.set("bumpversion", "new_version", args.new_version) +def _log_list(config, new_version): + config.set("bumpversion", "new_version", new_version) for key, value in config.items("bumpversion"): logger_list.info("%s=%s", key, value) - config.remove_option("bumpversion", "new_version") - config.set("bumpversion", "current_version", args.new_version) +def _update_config_file( + config, config_file, config_newlines, config_file_exists, new_version, dry_run, +): + config.set("bumpversion", "current_version", new_version) new_config = StringIO() - try: - write_to_config_file = (not args.dry_run) and config_file_exists + write_to_config_file = (not dry_run) and config_file_exists logger.info( "%s to config file %s:", "Would write" if not write_to_config_file else "Writing", - config_file + config_file, ) config.write(new_config) logger.info(new_config.getvalue()) if write_to_config_file: - with io.open(config_file, "wt", encoding="utf-8", newline=config_new_lines) as f: + with io.open(config_file, "wt", encoding="utf-8", newline=config_newlines) as f: f.write(new_config.getvalue()) except UnicodeEncodeError: @@ -590,25 +627,20 @@ def main(original_args=None): "Update with `pip install --upgrade configparser`." ) + +def _commit_to_vcs(files, context, config_file, config_file_exists, vcs, args): + commit_files = [f.path for f in files] if config_file_exists: commit_files.append(config_file) - - if not vcs: - return - assert vcs.is_usable(), "Did find '{}' unusable, unable to commit.".format( vcs.__name__ ) - do_commit = args.commit and not args.dry_run - do_tag = args.tag and not args.dry_run - logger.info( "%s %s commit", "Would prepare" if not do_commit else "Preparing", vcs.__name__, ) - for path in commit_files: logger.info( "%s changes in file '%s' to %s", @@ -620,28 +652,25 @@ def main(original_args=None): if do_commit: vcs.add_path(path) - vcs_context = { - "current_version": args.current_version, - "new_version": args.new_version, - } - vcs_context.update(time_context) - vcs_context.update(prefixed_environ()) - - commit_message = args.message.format(**vcs_context) - + context["current_version"] = args.current_version + context["new_version"] = args.new_version + commit_message = args.message.format(**context) logger.info( "%s to %s with message '%s'", "Would commit" if not do_commit else "Committing", vcs.__name__, commit_message, ) - if do_commit: vcs.commit(message=commit_message) + return context + +def _tag_in_vcs(vcs, context, args): sign_tags = args.sign_tags - tag_name = args.tag_name.format(**vcs_context) - tag_message = args.tag_message.format(**vcs_context) + tag_name = args.tag_name.format(**context) + tag_message = args.tag_message.format(**context) + do_tag = args.tag and not args.dry_run logger.info( "%s '%s' %s in %s and %s", "Would tag" if not do_tag else "Tagging", @@ -650,6 +679,5 @@ def main(original_args=None): vcs.__name__, "signing" if sign_tags else "not signing", ) - if do_tag: vcs.tag(sign_tags, tag_name, tag_message) diff --git a/bumpversion/version_part.py b/bumpversion/version_part.py index 56724ed..fdc460b 100644 --- a/bumpversion/version_part.py +++ b/bumpversion/version_part.py @@ -162,6 +162,8 @@ def order(self): return labels_for_format(self.serialize_formats[0]) def parse(self, version_string): + if not version_string: + return None regexp_one_line = "".join( [l.split("#")[0].strip() for l in self.parse_regex.pattern.splitlines()]