diff --git a/.gitignore b/.gitignore index ebe387dd..836cc513 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports +*/**/.pytest_cache/ htmlcov/ .tox/ .coverage @@ -129,3 +130,8 @@ Session.vim # Auto-generated tag files tags + +\.idea/ + +\.DS_Store + diff --git a/bumpversion/__init__.py b/bumpversion/__init__.py index 57972aa2..b9eaaf95 100644 --- a/bumpversion/__init__.py +++ b/bumpversion/__init__.py @@ -14,12 +14,15 @@ import argparse +from argparse import _AppendAction import os import re import sre_constants import subprocess import warnings import io +import operator +import logging from string import Formatter from datetime import datetime from difflib import unified_diff @@ -29,6 +32,7 @@ import codecs from bumpversion.version_part import VersionPart, NumericVersionPartConfiguration, ConfiguredVersionPartConfiguration +from bumpversion.functions import NumericFunction if sys.version_info[0] == 2: sys.stdout = codecs.getwriter('utf-8')(sys.stdout) @@ -40,11 +44,11 @@ sys.version.split("\n")[0].split(" ")[0], ) -import logging + logger = logging.getLogger("bumpversion.logger") logger_list = logging.getLogger("bumpversion.list") -from argparse import _AppendAction + class DiscardDefaultIfSpecifiedAppendAction(_AppendAction): ''' @@ -59,11 +63,13 @@ def __call__(self, parser, namespace, values, option_string=None): super(DiscardDefaultIfSpecifiedAppendAction, self).__call__( parser, namespace, values, option_string=None) + time_context = { 'now': datetime.now(), 'utcnow': datetime.utcnow(), } + class BaseVCS(object): @classmethod @@ -203,12 +209,14 @@ def tag(cls, sign, name, message): command += ['--message', message] subprocess.check_output(command) + VCS = [Git, Mercurial] def prefixed_environ(): return dict((("${}".format(key), value) for key, value in os.environ.items())) + class ConfiguredFile(object): def __init__(self, path, versionconfig): @@ -301,30 +309,41 @@ def __str__(self): def __repr__(self): return ''.format(self.path) + class IncompleteVersionRepresenationException(Exception): def __init__(self, message): self.message = message + class MissingValueForSerializationException(Exception): def __init__(self, message): self.message = message + class WorkingDirectoryIsDirtyException(Exception): def __init__(self, message): self.message = message + class MercurialDoesNotSupportSignedTagsException(Exception): def __init__(self, message): self.message = message + +class UnkownPart(Exception): + def __init__(self, message): + self.message = message + + def keyvaluestring(d): return ", ".join("{}={}".format(k, v) for k, v in sorted(d.items())) + class Version(object): - def __init__(self, values, original=None): + def __init__(self, values, order): self._values = dict(values) - self.original = original + self.order = order def __getitem__(self, key): return self._values[key] @@ -336,7 +355,78 @@ def __iter__(self): return iter(self._values) def __repr__(self): - return ''.format(keyvaluestring(self._values)) + by_part = ", ".join("{}={}".format(k, v) for k, v in self.items()) + return '<{}:{}>'.format(self.__class__, by_part) + + def __hash__(self): + return hash(tuple((k, v) for k, v in self.items())) + + def _compare(self, other, method, strict=True): + """ + When comparing versions we need to compare the three parts before we can decide if there is a difference. + Non-strict comparators need to be treated differently as they can not fail if the initial parts are equal + :param other: the other Version + :param method: the compare method + :param strict: if the comparsion is strict + :return: + """ + if set(self.order).difference(other.order): + raise TypeError("Versions use different parts, cant compare them.") + for (x, y) in zip(self.values(), other.values()): + if x == y: + continue + else: + return method(x, y) + return not strict + # try: + + # + # for vals in ((v, other[k]) for k, v in self.items()): + # if vals[0] == vals[1]: + # continue + # return method(vals[0], vals[1]) + # return not strict + # except KeyError: + # + + def __eq__(self, other): + if self is other: + return True + if not isinstance(other, Version): + return False + try: + return all(v == other[k] for k, v in self.items()) + except KeyError: + raise TypeError("Versions use different parts, cant compare them.") + + def __ne__(self, other): + return not self == other + + def __le__(self, other): + return self._compare(other, operator.lt, False) + + def __ge__(self, other): + return self._compare(other, operator.gt, False) + + def __lt__(self, other): + return self._compare(other, operator.lt) + + def __gt__(self, other): + return self._compare(other, operator.gt) + + def items(self): + for k in self.order: + try: + yield k, self._values[k] + except KeyError: + raise StopIteration + + def values(self): + for k in self.order: + try: + yield self._values[k] + except KeyError: + raise StopIteration def bump(self, part_name, order): bumped = False @@ -354,10 +444,11 @@ def bump(self, part_name, order): else: new_values[label] = self._values[label].copy() - new_version = Version(new_values) + new_version = Version(new_values, self.order) return new_version + class VersionConfig(object): """ @@ -410,8 +501,7 @@ def parse(self, version_string): for key, value in match.groupdict().items(): _parsed[key] = VersionPart(value, self.part_configs.get(key)) - - v = Version(_parsed, version_string) + v = Version(_parsed, [o for o in self.order()]) logger.info("Parsed the following values: %s" % keyvaluestring(v._values)) @@ -472,7 +562,6 @@ def _serialize(self, version, serialize_format, context, raise_if_incomplete=Fal return serialized - def _choose_serialize_format(self, version, context): chosen = None @@ -504,6 +593,7 @@ def serialize(self, version, context): # logger.info("Serialized to '{}'".format(serialized)) return serialized + OPTIONAL_ARGUMENTS_THAT_TAKE_VALUES = [ '--config-file', '--current-version', @@ -540,6 +630,7 @@ def split_args_in_optional_and_positional(args): return (positionals, args) + def main(original_args=None): positionals, args = split_args_in_optional_and_positional( @@ -756,10 +847,16 @@ def main(original_args=None): if not 'new_version' in defaults and known_args.current_version: try: if current_version and len(positionals) > 0: - logger.info("Attempting to increment part '{}'".format(positionals[0])) - new_version = current_version.bump(positionals[0], vc.order()) - logger.info("Values are now: " + keyvaluestring(new_version._values)) - defaults['new_version'] = vc.serialize(new_version, context) + part = positionals[0] + logger.info("Attempting to increment part '{}'".format(part)) + if part in vc.order(): + logger.info("Bumped part found in parse parts") + new_version = current_version.bump(part, vc.order()) + logger.info("Values are now: " + keyvaluestring(new_version._values)) + defaults['new_version'] = vc.serialize(new_version, context) + else: + logger.info("Bumped part not found in parse parts") + raise UnkownPart("Bumped part not found in parse parts.") except MissingValueForSerializationException as e: logger.info("Opportunistic finding of new_version failed: " + e.message) except IncompleteVersionRepresenationException as e: diff --git a/bumpversion/functions.py b/bumpversion/functions.py index b00f726a..c44f5ee5 100644 --- a/bumpversion/functions.py +++ b/bumpversion/functions.py @@ -82,8 +82,11 @@ def __init__(self, values, optional_value=None, first_value=None): def bump(self, value): try: - return self._values[self._values.index(value)+1] + return self._values[self.index(value) + 1] except IndexError: raise ValueError( "The part has already the maximum value among {} and cannot be bumped.".format(self._values)) + def index(self, value): + return self._values.index(value) + diff --git a/bumpversion/version_part.py b/bumpversion/version_part.py index 6f5c1082..3e342fbd 100644 --- a/bumpversion/version_part.py +++ b/bumpversion/version_part.py @@ -1,4 +1,6 @@ from bumpversion.functions import NumericFunction, ValuesFunction +import operator + class PartConfiguration(object): function_cls = NumericFunction @@ -35,10 +37,8 @@ class VersionPart(object): def __init__(self, value, config=None): self._value = value - if config is None: config = NumericVersionPartConfiguration() - self.config = config @property @@ -63,8 +63,37 @@ def __repr__(self): self.value ) + def __hash__(self): + return hash(self.value) + + def _compare(self, other, method): + if self.config.function_cls is not other.config.function_cls: + raise TypeError("Versions use different part specific configuration, cant compare them.") + if self.config.function_cls is NumericFunction: + return method(self.value, other.value) + else: + # Compare order + idx1 = self.config.function.index(self.value) + idx2 = other.config.function.index(other.value) + return method(idx1, idx2) + def __eq__(self, other): - return self.value == other.value + return self._compare(other, operator.eq) + + def __lt__(self, other): + return self._compare(other, operator.lt) + + def __le__(self, other): + return self._compare(other, operator.le) + + def __ge__(self, other): + return self._compare(other, operator.ge) + + def __gt__(self, other): + return self._compare(other, operator.gt) + + def __ne__(self, other): + return not self == other def null(self): return VersionPart(self.config.first_value, self.config) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7b798d36..8936de19 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -19,7 +19,7 @@ import bumpversion from bumpversion import main, DESCRIPTION, WorkingDirectoryIsDirtyException, \ - split_args_in_optional_and_positional + split_args_in_optional_and_positional, UnkownPart def _get_subprocess_env(): env = os.environ.copy() @@ -327,6 +327,19 @@ def test_bump_version(tmpdir): assert '1.0.1' == tmpdir.join("file5").read() +def test_bump_version_unkown_part(tmpdir): + + tmpdir.join("file5").write("1.0.0") + tmpdir.chdir() + with pytest.raises(UnkownPart): + with mock.patch("bumpversion.logger") as logger: + main(['bugfix', '--current-version', '1.0.0', 'file5']) + + actual_log ="\n".join(_mock_calls_to_string(logger)[4:]) + + assert 'Bumped part not found in parse parts' in actual_log + + def test_bump_version_custom_parse(tmpdir): tmpdir.join("file6").write("XXX1;0;0") @@ -991,6 +1004,7 @@ def test_log_no_config_file_info_message(tmpdir, capsys): info|Parsing version '1.0.0' using regexp '(?P\d+)\.(?P\d+)\.(?P\d+)'| info|Parsed the following values: major=1, minor=0, patch=0| info|Attempting to increment part 'patch'| + info|Bumped part found in parse parts| info|Values are now: major=1, minor=0, patch=1| info|Parsing version '1.0.1' using regexp '(?P\d+)\.(?P\d+)\.(?P\d+)'| info|Parsed the following values: major=1, minor=0, patch=1| @@ -1083,6 +1097,7 @@ def test_complex_info_logging(tmpdir, capsys): info|Parsing version '0.4' using regexp '(?P\d+)\.(?P\d+)(\.(?P\d+))?'| info|Parsed the following values: major=0, minor=4, patch=0| info|Attempting to increment part 'patch'| + info|Bumped part found in parse parts| info|Values are now: major=0, minor=4, patch=1| info|Parsing version '0.4.1' using regexp '(?P\d+)\.(?P\d+)(\.(?P\d+))?'| info|Parsed the following values: major=0, minor=4, patch=1| @@ -1151,6 +1166,7 @@ def test_subjunctive_dry_run_logging(tmpdir, vcs): info|Parsing version '0.8' using regexp '(?P\d+)\.(?P\d+)(\.(?P\d+))?'| info|Parsed the following values: major=0, minor=8, patch=0| info|Attempting to increment part 'patch'| + info|Bumped part found in parse parts| info|Values are now: major=0, minor=8, patch=1| info|Dry run active, won't touch any files.| info|Parsing version '0.8.1' using regexp '(?P\d+)\.(?P\d+)(\.(?P\d+))?'| @@ -1222,6 +1238,7 @@ def test_log_commitmessage_if_no_commit_tag_but_usable_vcs(tmpdir, vcs): info|Parsing version '0.3.3' using regexp '(?P\d+)\.(?P\d+)\.(?P\d+)'| info|Parsed the following values: major=0, minor=3, patch=3| info|Attempting to increment part 'patch'| + info|Bumped part found in parse parts| info|Values are now: major=0, minor=3, patch=4| info|Parsing version '0.3.4' using regexp '(?P\d+)\.(?P\d+)\.(?P\d+)'| info|Parsed the following values: major=0, minor=3, patch=4| @@ -1561,6 +1578,7 @@ def test_search_replace_to_avoid_updating_unconcerned_lines(tmpdir, capsys): info|Parsing version '1.5.6' using regexp '(?P\d+)\.(?P\d+)\.(?P\d+)'| info|Parsed the following values: major=1, minor=5, patch=6| info|Attempting to increment part 'minor'| + info|Bumped part found in parse parts| info|Values are now: major=1, minor=6, patch=0| info|Parsing version '1.6.0' using regexp '(?P\d+)\.(?P\d+)\.(?P\d+)'| info|Parsed the following values: major=1, minor=6, patch=0| diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 00000000..37444393 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals, print_function +import pytest + +from bumpversion import VersionConfig +from bumpversion.version_part import ConfiguredVersionPartConfiguration + + +def test_compare_versions_numeric(): + + version_parse = '(?P\d+)\.(?P\d+)\.(?P\d+)' + version_serialize = [str('{major}.{minor}.{patch}')] + vc = VersionConfig(version_parse, version_serialize, None, None) + v1 = vc.parse(version_string="1.0.0") + v2 = vc.parse(version_string="1.0.0") + assert v1 == v2 + assert not (v1 != v2) + assert not (v1 < v2) + assert not (v1 > v2) + assert v1 <= v2 + assert v1 >= v2 + assert v2 == v1 + assert not (v2 != v1) + assert not (v2 < v1) + assert not (v2 > v1) + assert v2 <= v1 + assert v2 >= v1 + v2 = vc.parse(version_string="1.0.1") + assert not (v1 == v2) + assert v1 != v2 + assert v1 < v2 + assert not (v1 > v2) + assert v1 <= v2 + assert not (v1 >= v2) + assert not (v2 == v1) + assert v2 != v1 + assert not (v2 < v1) + assert v2 > v1 + assert not (v2 <= v1) + assert v2 >= v1 + v1 = vc.parse(version_string="1.2.1") + v2 = vc.parse(version_string="1.3.1") + assert not (v1 == v2) + assert v1 != v2 + assert v1 < v2 + assert not (v1 > v2) + assert v1 <= v2 + assert not (v1 >= v2) + assert not (v2 == v1) + assert v2 != v1 + assert not (v2 < v1) + assert v2 > v1 + assert not (v2 <= v1) + assert v2 >= v1 + v1 = vc.parse(version_string="1.2.4") + v2 = vc.parse(version_string="1.3.1") + assert not (v1 == v2) + assert v1 != v2 + assert v1 < v2 + assert not (v1 > v2) + assert v1 <= v2 + assert not (v1 >= v2) + assert not (v2 == v1) + assert v2 != v1 + assert not (v2 < v1) + assert v2 > v1 + assert not (v2 <= v1) + assert v2 >= v1 + v1 = vc.parse(version_string="1.2.4") + v2 = vc.parse(version_string="2.3.1") + assert not (v1 == v2) + assert v1 != v2 + assert v1 < v2 + assert not (v1 > v2) + assert v1 <= v2 + assert not (v1 >= v2) + assert not (v2 == v1) + assert v2 != v1 + assert not (v2 < v1) + assert v2 > v1 + assert not (v2 <= v1) + assert v2 >= v1 + v1 = vc.parse(version_string="3.2.4") + v2 = vc.parse(version_string="2.3.1") + assert not (v1 == v2) + assert v1 != v2 + assert not (v1 < v2) + assert v1 > v2 + assert not (v1 <= v2) + assert v1 >= v2 + assert not (v2 == v1) + assert v2 != v1 + assert v2 < v1 + assert not (v2 > v1) + assert v2 <= v1 + assert not (v2 >= v1) + + +def test_compare_versions_values(): + + version_parse = '(?P\d+)\.(?P\d+)\.(?P.+)' + version_serialize = [str('{major}.{minor}.{release}')] + pc = ConfiguredVersionPartConfiguration(values=['witty-warthog', 'ridiculous-rat', 'marvelous-mantis']) + part_configs = {'release': pc} + vc = VersionConfig(version_parse, version_serialize, None, None, part_configs=part_configs) + v1 = vc.parse(version_string="1.0.witty-warthog") + v2 = vc.parse(version_string="1.0.witty-warthog") + assert v1 == v2 + assert not (v1 != v2) + assert not (v1 < v2) + assert not (v1 > v2) + assert v1 <= v2 + assert v1 >= v2 + assert v2 == v1 + assert not (v2 != v1) + assert not (v2 < v1) + assert not (v2 > v1) + assert v2 <= v1 + assert v2 >= v1 + v2 = vc.parse(version_string="1.0.ridiculous-rat") + assert not (v1 == v2) + assert v1 != v2 + assert v1 < v2 + assert not (v1 > v2) + assert v1 <= v2 + assert not (v1 >= v2) + assert not (v2 == v1) + assert v2 != v1 + assert not (v2 < v1) + assert v2 > v1 + assert not (v2 <= v1) + assert v2 >= v1 + v1 = vc.parse(version_string="1.2.marvelous-mantis") + v2 = vc.parse(version_string="1.3.ridiculous-rat") + assert not (v1 == v2) + assert v1 != v2 + assert v1 < v2 + assert not (v1 > v2) + assert v1 <= v2 + assert not (v1 >= v2) + assert not (v2 == v1) + assert v2 != v1 + assert not (v2 < v1) + assert v2 > v1 + assert not (v2 <= v1) + assert v2 >= v1 \ No newline at end of file