├── setup.cfg ├── bumpversion ├── __main__.py ├── version_part.py ├── functions.py └── __init__.py ├── MANIFEST.in ├── .gitignore ├── pytest.ini ├── tox.ini ├── .bumpversion.cfg ├── .travis.yml ├── LICENSE.rst ├── setup.py ├── appveyor.yml ├── tests ├── test_version_part.py ├── test_functions.py └── test_cli.py └── README.rst /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | -------------------------------------------------------------------------------- /bumpversion/__main__.py: -------------------------------------------------------------------------------- 1 | __import__('bumpversion').main() 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE.rst 2 | recursive-include tests *.py 3 | 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tox 2 | _test_run/ 3 | bin/ 4 | include/ 5 | lib/ 6 | .Python 7 | bumpversion.egg-info/ 8 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | minversion = 2.0 3 | norecursedirs= .git .hg .tox build dist tmp* 4 | python_files = test*.py 5 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py34, py33, py32, py27, py27-configparser, pypy 3 | 4 | [testenv] 5 | passenv = HOME 6 | deps= 7 | distribute 8 | pytest 9 | mock 10 | commands= 11 | py.test [] tests 12 | 13 | [testenv:py27-configparser] 14 | basepython= python2.7 15 | deps= 16 | pytest 17 | mock 18 | configparser 19 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | commit = True 3 | tag = True 4 | current_version = 0.5.4-dev 5 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+))? 6 | serialize = 7 | {major}.{minor}.{patch}-{release} 8 | {major}.{minor}.{patch} 9 | 10 | [bumpversion:file:setup.py] 11 | 12 | [bumpversion:file:bumpversion/__init__.py] 13 | 14 | [bumpversion:file:README.rst] 15 | search = **unreleased** 16 | replace = **unreleased** 17 | **v{new_version}** 18 | 19 | [bumpversion:part:release] 20 | optional_value = gamma 21 | values = 22 | dev 23 | gamma 24 | 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | env: 4 | - TOX_ENV=py27 5 | - TOX_ENV=py27-configparser 6 | - TOX_ENV=py32 7 | - TOX_ENV=py33 8 | - TOX_ENV=py34 9 | - TOX_ENV=pypy 10 | install: 11 | - git config --global user.email "bumpversion-test-git@travis.ci" 12 | - git config --global user.name "Testing Git on Travis CI" 13 | - git --version 14 | - git config --list 15 | - echo -e '[ui]\nusername = Testing Mercurial on Travis CI ' > ~/.hgrc 16 | - hg --version 17 | - pip install --upgrade pytest tox 18 | 19 | script: 20 | - tox -e $TOX_ENV 21 | 22 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | Copyright (C) 2013-2014 Filip Noetzel 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from setuptools import setup 3 | 4 | description = 'Version-bump your software with a single command!' 5 | 6 | long_description = re.sub( 7 | "\`(.*)\<#.*\>\`\_", 8 | r"\1", 9 | str(open('README.rst', 'rb').read()).replace(description, '') 10 | ) 11 | 12 | setup( 13 | name='bumpversion', 14 | version='0.5.4-dev', 15 | url='https://github.com/peritus/bumpversion', 16 | author='Filip Noetzel', 17 | author_email='filip+bumpversion@j03.de', 18 | license='MIT', 19 | packages=['bumpversion'], 20 | description=description, 21 | long_description=long_description, 22 | entry_points={ 23 | 'console_scripts': [ 24 | 'bumpversion = bumpversion:main', 25 | ] 26 | }, 27 | classifiers=( 28 | 'Development Status :: 4 - Beta', 29 | 'Intended Audience :: Developers', 30 | 'License :: OSI Approved :: MIT License', 31 | 'Operating System :: OS Independent', 32 | 'Programming Language :: Python', 33 | 'Programming Language :: Python :: 2', 34 | 'Programming Language :: Python :: 2.7', 35 | 'Programming Language :: Python :: 3', 36 | 'Programming Language :: Python :: 3.2', 37 | 'Programming Language :: Python :: 3.3', 38 | 'Programming Language :: Python :: 3.4', 39 | 'Programming Language :: Python :: Implementation :: PyPy', 40 | ), 41 | ) 42 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | 2 | version: '{build}' 3 | 4 | build: off 5 | 6 | environment: 7 | matrix: 8 | - PYTHON: "C:/Python27" 9 | TOXENV: "py27" 10 | 11 | - PYTHON: "C:/Python27" 12 | TOXENV: "py27-configparser" 13 | 14 | - PYTHON: "C:/Python33" 15 | TOXENV: "py33" 16 | 17 | - PYTHON: "C:/Python34" 18 | TOXENV: "py34" 19 | 20 | init: 21 | - "ECHO %TOXENV%" 22 | - "ECHO %PYTHON%" 23 | - ps: "ls C:/Python*" 24 | 25 | install: 26 | - ps: (new-object net.webclient).DownloadFile('https://raw.github.com/pypa/pip/master/contrib/get-pip.py', 'C:/get-pip.py') 27 | - "%PYTHON%/python.exe C:/get-pip.py" 28 | - "%PYTHON%/Scripts/pip.exe install virtualenv" 29 | - "%PYTHON%/Scripts/pip.exe install tox" 30 | - "%PYTHON%/Scripts/pip.exe install mercurial" 31 | - "%PYTHON%/Scripts/pip.exe install -e ." 32 | 33 | - 'git config --global user.email "bumpversion-test-git@appveyor.ci"' 34 | - 'git config --global user.name "Testing Git on AppVeyor CI"' 35 | - 'git --version' 36 | - 'git config --list' 37 | 38 | - ps: Add-Content $ENV:UserProfile\Mercurial.ini "[ui]`r`nusername = Testing Mercurial on AppVeyor CI " 39 | - ps: Write-Host $ENV:UserProfile\Mercurial.ini 40 | - ps: Get-Content $ENV:UserProfile\Mercurial.ini 41 | - 'hg --version' 42 | 43 | test_script: 44 | - "%PYTHON%/Scripts/pip.exe --version" 45 | - "%PYTHON%/Scripts/virtualenv.exe --version" 46 | - "%PYTHON%/Scripts/tox.exe --version" 47 | - "%PYTHON%/Scripts/tox.exe -- -v" 48 | -------------------------------------------------------------------------------- /tests/test_version_part.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bumpversion.version_part import * 4 | 5 | @pytest.fixture(params=[None, (('0', '1', '2'),), (('0', '3'),)]) 6 | def confvpc(request): 7 | if request.param is None: 8 | return NumericVersionPartConfiguration() 9 | else: 10 | return ConfiguredVersionPartConfiguration(*request.param) 11 | 12 | 13 | # VersionPart 14 | 15 | def test_version_part_init(confvpc): 16 | assert VersionPart( 17 | confvpc.first_value, confvpc).value == confvpc.first_value 18 | 19 | 20 | def test_version_part_copy(confvpc): 21 | vp = VersionPart(confvpc.first_value, confvpc) 22 | vc = vp.copy() 23 | assert vp.value == vc.value 24 | assert id(vp) != id(vc) 25 | 26 | 27 | def test_version_part_bump(confvpc): 28 | vp = VersionPart(confvpc.first_value, confvpc) 29 | vc = vp.bump() 30 | assert vc.value == confvpc.bump(confvpc.first_value) 31 | 32 | 33 | def test_version_part_check_optional_false(confvpc): 34 | assert not VersionPart(confvpc.first_value, confvpc).bump().is_optional() 35 | 36 | 37 | def test_version_part_check_optional_true(confvpc): 38 | assert VersionPart(confvpc.first_value, confvpc).is_optional() 39 | 40 | 41 | def test_version_part_format(confvpc): 42 | assert "{}".format( 43 | VersionPart(confvpc.first_value, confvpc)) == confvpc.first_value 44 | 45 | 46 | def test_version_part_equality(confvpc): 47 | assert VersionPart(confvpc.first_value, confvpc) == VersionPart( 48 | confvpc.first_value, confvpc) 49 | 50 | 51 | def test_version_part_null(confvpc): 52 | assert VersionPart(confvpc.first_value, confvpc).null() == VersionPart( 53 | confvpc.first_value, confvpc) 54 | -------------------------------------------------------------------------------- /bumpversion/version_part.py: -------------------------------------------------------------------------------- 1 | from bumpversion.functions import NumericFunction, ValuesFunction 2 | 3 | class PartConfiguration(object): 4 | function_cls = NumericFunction 5 | 6 | def __init__(self, *args, **kwds): 7 | self.function = self.function_cls(*args, **kwds) 8 | 9 | @property 10 | def first_value(self): 11 | return str(self.function.first_value) 12 | 13 | @property 14 | def optional_value(self): 15 | return str(self.function.optional_value) 16 | 17 | def bump(self, value=None): 18 | return self.function.bump(value) 19 | 20 | 21 | class ConfiguredVersionPartConfiguration(PartConfiguration): 22 | function_cls = ValuesFunction 23 | 24 | 25 | class NumericVersionPartConfiguration(PartConfiguration): 26 | function_cls = NumericFunction 27 | 28 | 29 | class VersionPart(object): 30 | 31 | """ 32 | This class represents part of a version number. It contains a self.config 33 | object that rules how the part behaves when increased or reset. 34 | """ 35 | 36 | def __init__(self, value, config=None): 37 | self._value = value 38 | 39 | if config is None: 40 | config = NumericVersionPartConfiguration() 41 | 42 | self.config = config 43 | 44 | @property 45 | def value(self): 46 | return self._value or self.config.optional_value 47 | 48 | def copy(self): 49 | return VersionPart(self._value) 50 | 51 | def bump(self): 52 | return VersionPart(self.config.bump(self.value), self.config) 53 | 54 | def is_optional(self): 55 | return self.value == self.config.optional_value 56 | 57 | def __format__(self, format_spec): 58 | return self.value 59 | 60 | def __repr__(self): 61 | return ''.format( 62 | self.config.__class__.__name__, 63 | self.value 64 | ) 65 | 66 | def __eq__(self, other): 67 | return self.value == other.value 68 | 69 | def null(self): 70 | return VersionPart(self.config.first_value, self.config) 71 | -------------------------------------------------------------------------------- /tests/test_functions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bumpversion.functions import * 4 | 5 | 6 | # NumericFunction 7 | 8 | def test_numeric_init_wo_first_value(): 9 | func = NumericFunction() 10 | assert func.first_value == '0' 11 | 12 | 13 | def test_numeric_init_w_first_value(): 14 | func = NumericFunction(first_value='5') 15 | assert func.first_value == '5' 16 | 17 | 18 | def test_numeric_init_non_numeric_first_value(): 19 | with pytest.raises(ValueError): 20 | func = NumericFunction(first_value='a') 21 | 22 | 23 | def test_numeric_bump_simple_number(): 24 | func = NumericFunction() 25 | assert func.bump('0') == '1' 26 | 27 | 28 | def test_numeric_bump_prefix_and_suffix(): 29 | func = NumericFunction() 30 | assert func.bump('v0b') == 'v1b' 31 | 32 | 33 | # ValuesFunction 34 | 35 | def test_values_init(): 36 | func = ValuesFunction([0, 1, 2]) 37 | assert func.optional_value == 0 38 | assert func.first_value == 0 39 | 40 | 41 | def test_values_init_w_correct_optional_value(): 42 | func = ValuesFunction([0, 1, 2], optional_value=1) 43 | assert func.optional_value == 1 44 | assert func.first_value == 0 45 | 46 | 47 | def test_values_init_w_correct_first_value(): 48 | func = ValuesFunction([0, 1, 2], first_value=1) 49 | assert func.optional_value == 0 50 | assert func.first_value == 1 51 | 52 | 53 | def test_values_init_w_correct_optional_and_first_value(): 54 | func = ValuesFunction([0, 1, 2], optional_value=0, first_value=1) 55 | assert func.optional_value == 0 56 | assert func.first_value == 1 57 | 58 | 59 | def test_values_init_w_empty_values(): 60 | with pytest.raises(ValueError): 61 | func = ValuesFunction([]) 62 | 63 | 64 | def test_values_init_w_incorrect_optional_value(): 65 | with pytest.raises(ValueError): 66 | func = ValuesFunction([0, 1, 2], optional_value=3) 67 | 68 | 69 | def test_values_init_w_incorrect_first_value(): 70 | with pytest.raises(ValueError): 71 | func = ValuesFunction([0, 1, 2], first_value=3) 72 | 73 | 74 | def test_values_bump(): 75 | func = ValuesFunction([0, 5, 10]) 76 | assert func.bump(0) == 5 77 | 78 | 79 | def test_values_bump(): 80 | func = ValuesFunction([0, 5, 10]) 81 | with pytest.raises(ValueError): 82 | func.bump(10) 83 | 84 | -------------------------------------------------------------------------------- /bumpversion/functions.py: -------------------------------------------------------------------------------- 1 | import re 2 | import datetime 3 | 4 | 5 | class NumericFunction(object): 6 | 7 | """ 8 | This is a class that provides a numeric function for version parts. 9 | It simply starts with the provided first_value (0 by default) and 10 | increases it following the sequence of integer numbers. 11 | 12 | The optional value of this function is equal to the first value. 13 | 14 | This function also supports alphanumeric parts, altering just the numeric 15 | part (e.g. 'r3' --> 'r4'). Only the first numeric group found in the part is 16 | considered (e.g. 'r3-001' --> 'r4-001'). 17 | """ 18 | 19 | FIRST_NUMERIC = re.compile('([^\d]*)(\d+)(.*)') 20 | 21 | def __init__(self, first_value=None): 22 | 23 | if first_value is not None: 24 | try: 25 | part_prefix, part_numeric, part_suffix = self.FIRST_NUMERIC.search( 26 | first_value).groups() 27 | except AttributeError: 28 | raise ValueError( 29 | "The given first value {} does not contain any digit".format(first_value)) 30 | else: 31 | first_value = 0 32 | 33 | self.first_value = str(first_value) 34 | self.optional_value = self.first_value 35 | 36 | def bump(self, value): 37 | part_prefix, part_numeric, part_suffix = self.FIRST_NUMERIC.search( 38 | value).groups() 39 | bumped_numeric = int(part_numeric) + 1 40 | 41 | return "".join([part_prefix, str(bumped_numeric), part_suffix]) 42 | 43 | 44 | class ValuesFunction(object): 45 | 46 | """ 47 | This is a class that provides a values list based function for version parts. 48 | It is initialized with a list of values and iterates through them when 49 | bumping the part. 50 | 51 | The default optional value of this function is equal to the first value, 52 | but may be otherwise specified. 53 | 54 | When trying to bump a part which has already the maximum value in the list 55 | you get a ValueError exception. 56 | """ 57 | 58 | def __init__(self, values, optional_value=None, first_value=None): 59 | 60 | if len(values) == 0: 61 | raise ValueError("Version part values cannot be empty") 62 | 63 | self._values = values 64 | 65 | if optional_value is None: 66 | optional_value = values[0] 67 | 68 | if optional_value not in values: 69 | raise ValueError("Optional value {0} must be included in values {1}".format( 70 | optional_value, values)) 71 | 72 | self.optional_value = optional_value 73 | 74 | if first_value is None: 75 | first_value = values[0] 76 | 77 | if first_value not in values: 78 | raise ValueError("First value {0} must be included in values {1}".format( 79 | first_value, values)) 80 | 81 | self.first_value = first_value 82 | 83 | def bump(self, value): 84 | try: 85 | return self._values[self._values.index(value)+1] 86 | except IndexError: 87 | raise ValueError( 88 | "The part has already the maximum value among {} and cannot be bumped.".format(self._values)) 89 | 90 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | **⚠️ Current status of this project** 2 | 3 | * 🎬If you want to start **using bumpversion**, you're best advised to **install one of the maintained forks**, e.g. ➡ `@c4urself's bump2version `_. 4 | * 🔨If you want to **help maintain** bumpversion, there's an `ongoing discussion about merging the fork back to the original project as well as forming a group of maintainers `_ to ensure a long-term future for this project. Please contribute. 5 | 6 | ----- 7 | 8 | =========== 9 | bumpversion 10 | =========== 11 | 12 | Version-bump your software with a single command! 13 | 14 | A small command line tool to simplify releasing software by updating all 15 | version strings in your source code by the correct increment. Also creates 16 | commits and tags: 17 | 18 | - version formats are highly configurable 19 | - works without any VCS, but happily reads tag information from and writes 20 | commits and tags to Git and Mercurial if available 21 | - just handles text files, so it's not specific to any programming language 22 | 23 | .. image:: https://travis-ci.org/peritus/bumpversion.png?branch=master 24 | :target: https://travis-ci.org/peritus/bumpversion 25 | 26 | .. image:: https://ci.appveyor.com/api/projects/status/bxq8185bpq9u3sjd/branch/master?svg=true 27 | :target: https://ci.appveyor.com/project/peritus/bumpversion 28 | 29 | Screencast 30 | ========== 31 | 32 | .. image:: https://dl.dropboxusercontent.com/u/8735936/Screen%20Shot%202013-04-12%20at%202.43.46%20PM.png 33 | :target: https://asciinema.org/a/3828 34 | 35 | Installation 36 | ============ 37 | 38 | You can download and install the latest version of this software from the Python package index (PyPI) as follows:: 39 | 40 | pip install --upgrade bumpversion 41 | 42 | Usage 43 | ===== 44 | 45 | There are two modes of operation: On the command line for single-file operation 46 | and using a `configuration file <#configuration>`_ for more complex multi-file 47 | operations. 48 | 49 | :: 50 | 51 | bumpversion [options] part [file] 52 | 53 | 54 | ``part`` (required) 55 | The part of the version to increase, e.g. ``minor``. 56 | 57 | Valid values include those given in the ``--serialize`` / ``--parse`` option. 58 | 59 | Example `bumping 0.5.1 to 0.6.0`:: 60 | 61 | bumpversion --current-version 0.5.1 minor src/VERSION 62 | 63 | ``[file]`` 64 | **default: none** (optional) 65 | 66 | The file that will be modified. 67 | 68 | If not given, the list of ``[bumpversion:file:…]`` sections from the 69 | configuration file will be used. If no files are mentioned on the 70 | configuration file either, are no files will be modified. 71 | 72 | Example `bumping 1.1.9 to 2.0.0`:: 73 | 74 | bumpversion --current-version 1.1.9 major setup.py 75 | 76 | Configuration 77 | +++++++++++++ 78 | 79 | All options can optionally be specified in a config file called 80 | ``.bumpversion.cfg`` so that once you know how ``bumpversion`` needs to be 81 | configured for one particular software package, you can run it without 82 | specifying options later. You should add that file to VCS so others can also 83 | bump versions. 84 | 85 | Options on the command line take precedence over those from the config file, 86 | which take precedence over those derived from the environment and then from the 87 | defaults. 88 | 89 | Example ``.bumpversion.cfg``:: 90 | 91 | [bumpversion] 92 | current_version = 0.2.9 93 | commit = True 94 | tag = True 95 | 96 | [bumpversion:file:setup.py] 97 | 98 | If no ``.bumpversion.cfg`` exists, ``bumpversion`` will also look into 99 | ``setup.cfg`` for configuration. 100 | 101 | Global configuration 102 | -------------------- 103 | 104 | General configuration is grouped in a ``[bumpversion]`` section. 105 | 106 | ``current_version =`` 107 | **no default value** (required) 108 | 109 | The current version of the software package before bumping. 110 | 111 | Also available as ``--current-version`` (e.g. ``bumpversion --current-version 0.5.1 patch setup.py``) 112 | 113 | ``new_version =`` 114 | **no default value** (optional) 115 | 116 | The version of the software package after the increment. If not given will be 117 | automatically determined. 118 | 119 | Also available as ``--new-version`` (e.g. `to go from 0.5.1 directly to 120 | 0.6.1`: ``bumpversion --current-version 0.5.1 --new-version 0.6.1 patch 121 | setup.py``). 122 | 123 | ``tag = (True | False)`` 124 | **default:** False (`Don't create a tag`) 125 | 126 | Whether to create a tag, that is the new version, prefixed with the character 127 | "``v``". If you are using git, don't forget to ``git-push`` with the 128 | ``--tags`` flag. 129 | 130 | Also available on the command line as ``(--tag | --no-tag)``. 131 | 132 | ``tag_name =`` 133 | **default:** ``v{new_version}`` 134 | 135 | The name of the tag that will be created. Only valid when using ``--tag`` / ``tag = True``. 136 | 137 | This is templated using the `Python Format String Syntax 138 | `_. 139 | Available in the template context are ``current_version`` and ``new_version`` 140 | as well as all environment variables (prefixed with ``$``). You can also use 141 | the variables ``now`` or ``utcnow`` to get a current timestamp. Both accept 142 | datetime formatting (when used like as in ``{now:%d.%m.%Y}``). 143 | 144 | Also available as ``--tag-name`` (e.g. ``bumpversion --message 'Jenkins Build 145 | {$BUILD_NUMBER}: {new_version}' patch``). 146 | 147 | ``commit = (True | False)`` 148 | **default:** ``False`` (`Don't create a commit`) 149 | 150 | Whether to create a commit using git or Mercurial. 151 | 152 | Also available as ``(--commit | --no-commit)``. 153 | 154 | ``message =`` 155 | **default:** ``Bump version: {current_version} → {new_version}`` 156 | 157 | The commit message to use when creating a commit. Only valid when using ``--commit`` / ``commit = True``. 158 | 159 | This is templated using the `Python Format String Syntax 160 | `_. 161 | Available in the template context are ``current_version`` and ``new_version`` 162 | as well as all environment variables (prefixed with ``$``). You can also use 163 | the variables ``now`` or ``utcnow`` to get a current timestamp. Both accept 164 | datetime formatting (when used like as in ``{now:%d.%m.%Y}``). 165 | 166 | Also available as ``--message`` (e.g.: ``bumpversion --message 167 | '[{now:%Y-%m-%d}] Jenkins Build {$BUILD_NUMBER}: {new_version}' patch``) 168 | 169 | 170 | Part specific configuration 171 | --------------------------- 172 | 173 | A version string consists of one or more parts, e.g. the version ``1.0.2`` 174 | has three parts, separated by a dot (``.``) character. In the default 175 | configuration these parts are named `major`, `minor`, `patch`, however you can 176 | customize that using the ``parse``/``serialize`` option. 177 | 178 | By default all parts considered numeric, that is their initial value is ``0`` 179 | and they are increased as integers. Also, the value ``0`` is considered to be 180 | optional if it's not needed for serialization, i.e. the version ``1.4.0`` is 181 | equal to ``1.4`` if ``{major}.{minor}`` is given as a ``serialize`` value. 182 | 183 | For advanced versioning schemes, non-numeric parts may be desirable (e.g. to 184 | identify `alpha or beta versions 185 | `_, 186 | to indicate the stage of development, the flavor of the software package or 187 | a release name). To do so, you can use a ``[bumpversion:part:…]`` section 188 | containing the part's name (e.g. a part named ``release_name`` is configured in 189 | a section called ``[bumpversion:part:release_name]``. 190 | 191 | The following options are valid inside a part configuration: 192 | 193 | ``values =`` 194 | **default**: numeric (i.e. ``0``, ``1``, ``2``, …) 195 | 196 | Explicit list of all values that will be iterated when bumping that specific 197 | part. 198 | 199 | Example:: 200 | 201 | [bumpversion:part:release_name] 202 | values = 203 | witty-warthog 204 | ridiculous-rat 205 | marvelous-mantis 206 | 207 | ``optional_value =`` 208 | **default**: The first entry in ``values =``. 209 | 210 | If the value of the part matches this value it is considered optional, i.e. 211 | it's representation in a ``--serialize`` possibility is not required. 212 | 213 | Example:: 214 | 215 | [bumpversion] 216 | current_version = 1.alpha 217 | parse = (?P\d+)\.(?P.*) 218 | serialize = 219 | {num}.{release} 220 | {num} 221 | 222 | [bumpversion:part:release] 223 | optional_value = gamma 224 | values = 225 | alpha 226 | beta 227 | gamma 228 | 229 | Here, ``bumpversion release`` would bump ``1.alpha`` to ``1.beta``. Executing 230 | ``bumpversion release`` again would bump ``1.beta`` to ``1``, because 231 | `release` being ``gamma`` is configured optional. 232 | 233 | ``first_value =`` 234 | **default**: The first entry in ``values =``. 235 | 236 | When the part is reset, the value will be set to the value specified here. 237 | 238 | File specific configuration 239 | --------------------------- 240 | 241 | ``[bumpversion:file:…]`` 242 | 243 | ``parse =`` 244 | **default:** ``(?P\d+)\.(?P\d+)\.(?P\d+)`` 245 | 246 | Regular expression (using `Python regular expression syntax 247 | `_) on 248 | how to find and parse the version string. 249 | 250 | Is required to parse all strings produced by ``serialize =``. Named matching 251 | groups ("``(?P...)``") provide values to as the ``part`` argument. 252 | 253 | Also available as ``--parse`` 254 | 255 | ``serialize =`` 256 | **default:** ``{major}.{minor}.{patch}`` 257 | 258 | Template specifying how to serialize the version parts back to a version 259 | string. 260 | 261 | This is templated using the `Python Format String Syntax 262 | `_. 263 | Available in the template context are parsed values of the named groups 264 | specified in ``parse =`` as well as all environment variables (prefixed with 265 | ``$``). 266 | 267 | Can be specified multiple times, bumpversion will try the serialization 268 | formats beginning with the first and choose the last one where all values can 269 | be represented like this:: 270 | 271 | serialize = 272 | {major}.{minor} 273 | {major} 274 | 275 | Given the example above, the new version *1.9* it will be serialized as 276 | ``1.9``, but the version *2.0* will be serialized as ``2``. 277 | 278 | Also available as ``--serialize``. Multiple values on the command line are 279 | given like ``--serialize {major}.{minor} --serialize {major}`` 280 | 281 | ``search =`` 282 | **default:** ``{current_version}`` 283 | 284 | Template string how to search for the string to be replaced in the file. 285 | Useful if the remotest possibility exists that the current version number 286 | might be multiple times in the file and you mean to only bump one of the 287 | occurences. Can be multiple lines, templated using `Python Format String Syntax 288 | `_. 289 | 290 | ``replace =`` 291 | **default:** ``{new_version}`` 292 | 293 | Template to create the string that will replace the current version number in 294 | the file. 295 | 296 | Given this ``requirements.txt``:: 297 | 298 | Django>=1.5.6,<1.6 299 | MyProject==1.5.6 300 | 301 | using this ``.bumpversion.cfg`` will ensure only the line containing 302 | ``MyProject`` will be changed:: 303 | 304 | [bumpversion] 305 | current_version = 1.5.6 306 | 307 | [bumpversion:file:requirements.txt] 308 | search = MyProject=={current_version} 309 | replace = MyProject=={new_version} 310 | 311 | Can be multiple lines, templated using `Python Format String Syntax 312 | `_. 313 | 314 | Options 315 | ======= 316 | 317 | Most of the configuration values above can also be given as an option. 318 | Additionally, the following options are available: 319 | 320 | ``--dry-run, -n`` 321 | Don't touch any files, just pretend. Best used with ``--verbose``. 322 | 323 | ``--allow-dirty`` 324 | Normally, bumpversion will abort if the working directory is dirty to protect 325 | yourself from releasing unversioned files and/or overwriting unsaved changes. 326 | Use this option to override this check. 327 | 328 | ``--verbose`` 329 | Print useful information to stderr 330 | 331 | ``--list`` 332 | List machine readable information to stdout for consumption by other 333 | programs. 334 | 335 | Example output:: 336 | 337 | current_version=0.0.18 338 | new_version=0.0.19 339 | 340 | ``-h, --help`` 341 | Print help and exit 342 | 343 | Using bumpversion in a script 344 | ============================= 345 | 346 | If you need to use the version generated by bumpversion in a script you can make use of 347 | the `--list` option, combined with `grep` and `sed`. 348 | 349 | Say for example that you are using git-flow to manage your project and want to automatically 350 | create a release. When you issue `git flow release start` you already need to know the 351 | new version, before applying the change. 352 | 353 | The standard way to get it in a bash script is 354 | 355 | bumpversion --dry-run --list | grep | sed -r s,"^.*=",, 356 | 357 | where is as usual the part of the version number you are updating. You need to specify 358 | `--dry-run` to avoid bumpversion actually bumping the version number. 359 | 360 | For example, if you are updating the minor number and looking for the new version number this becomes 361 | 362 | bumpversion --dry-run --list minor | grep new_version | sed -r s,"^.*=",, 363 | 364 | Development 365 | =========== 366 | 367 | Development of this happens on GitHub, patches including tests, documentation 368 | are very welcome, as well as bug reports! Also please open an issue if this 369 | tool does not support every aspect of bumping versions in your development 370 | workflow, as it is intended to be very versatile. 371 | 372 | How to release bumpversion itself 373 | +++++++++++++++++++++++++++++++++ 374 | 375 | Execute the following commands:: 376 | 377 | git checkout master 378 | git pull 379 | tox 380 | bumpversion release 381 | python setup.py sdist bdist_wheel upload 382 | bumpversion --no-tag patch 383 | git push origin master --tags 384 | 385 | License 386 | ======= 387 | 388 | bumpversion is licensed under the MIT License - see the LICENSE.rst file for details 389 | 390 | Changes 391 | ======= 392 | 393 | **unreleased** 394 | **v0.5.4-dev** 395 | 396 | **v0.5.3** 397 | 398 | - Fix bug where ``--new-version`` value was not used when config was present 399 | (thanks @cscetbon @ecordell (`#60 `_) 400 | - Preserve case of keys config file 401 | (thanks theskumar `#75 `_) 402 | - Windows CRLF improvements (thanks @thebjorn) 403 | 404 | **v0.5.1** 405 | 406 | - Document file specific options ``search =`` and ``replace =`` (introduced in 0.5.0) 407 | - Fix parsing individual labels from ``serialize =`` config even if there are 408 | characters after the last label (thanks @mskrajnowski `#56 409 | `_). 410 | - Fix: Don't crash in git repositories that have tags that contain hyphens 411 | (`#51 `_) (`#52 412 | `_). 413 | - Fix: Log actual content of the config file, not what ConfigParser prints 414 | after reading it. 415 | - Fix: Support multiline values in ``search =`` 416 | - also load configuration from ``setup.cfg`` (thanks @t-8ch `#57 417 | `_). 418 | 419 | **v0.5.0** 420 | 421 | This is a major one, containing two larger features, that require some changes 422 | in the configuration format. This release is fully backwards compatible to 423 | *v0.4.1*, however deprecates two uses that will be removed in a future version. 424 | 425 | - New feature: `Part specific configuration <#part-specific-configuration>`_ 426 | - New feature: `File specific configuration <#file-specific-configuration>`_ 427 | - New feature: parse option can now span multiple line (allows to comment complex 428 | regular expressions. See `re.VERBOSE in the Python documentation 429 | `_ for details, `this 430 | testcase 431 | `_ 432 | as an example.) 433 | - New feature: ``--allow-dirty`` (`#42 `_). 434 | - Fix: Save the files in binary mode to avoid mutating newlines (thanks @jaraco `#45 `_). 435 | - License: bumpversion is now licensed under the MIT License (`#47 `_) 436 | 437 | - Deprecate multiple files on the command line (use a `configuration file <#configuration>`_ instead, or invoke ``bumpversion`` multiple times) 438 | - Deprecate 'files =' configuration (use `file specific configuration <#file-specific-configuration>`_ instead) 439 | 440 | **v0.4.1** 441 | 442 | - Add --list option (`#39 `_) 443 | - Use temporary files for handing over commit/tag messages to git/hg (`#36 `_) 444 | - Fix: don't encode stdout as utf-8 on py3 (`#40 `_) 445 | - Fix: logging of content of config file was wrong 446 | 447 | **v0.4.0** 448 | 449 | - Add --verbose option (`#21 `_ `#30 `_) 450 | - Allow option --serialize multiple times 451 | 452 | **v0.3.8** 453 | 454 | - Fix: --parse/--serialize didn't work from cfg (`#34 `_) 455 | 456 | **v0.3.7** 457 | 458 | - Don't fail if git or hg is not installed (thanks @keimlink) 459 | - "files" option is now optional (`#16 `_) 460 | - Fix bug related to dirty work dir (`#28 `_) 461 | 462 | 463 | **v0.3.6** 464 | 465 | - Fix --tag default (thanks @keimlink) 466 | 467 | **v0.3.5** 468 | 469 | - add {now} and {utcnow} to context 470 | - use correct file encoding writing to config file. NOTE: If you are using 471 | Python2 and want to use UTF-8 encoded characters in your config file, you 472 | need to update ConfigParser like using 'pip install -U configparser' 473 | - leave current_version in config even if available from vcs tags (was 474 | confusing) 475 | - print own version number in usage 476 | - allow bumping parts that contain non-numerics 477 | - various fixes regarding file encoding 478 | 479 | **v0.3.4** 480 | 481 | - bugfix: tag_name and message in .bumpversion.cfg didn't have an effect (`#9 `_) 482 | 483 | **v0.3.3** 484 | 485 | - add --tag-name option 486 | - now works on Python 3.2, 3.3 and PyPy 487 | 488 | **v0.3.2** 489 | 490 | - bugfix: Read only tags from `git describe` that look like versions 491 | 492 | **v0.3.1** 493 | 494 | - bugfix: ``--help`` in git workdir raising AssertionError 495 | - bugfix: fail earlier if one of files does not exist 496 | - bugfix: ``commit = True`` / ``tag = True`` in .bumpversion.cfg had no effect 497 | 498 | **v0.3.0** 499 | 500 | - **BREAKING CHANGE** The ``--bump`` argument was removed, this is now the first 501 | positional argument. 502 | If you used ``bumpversion --bump major`` before, you can use 503 | ``bumpversion major`` now. 504 | If you used ``bumpversion`` without arguments before, you now 505 | need to specify the part (previous default was ``patch``) as in 506 | ``bumpversion patch``). 507 | 508 | **v0.2.2** 509 | 510 | - add --no-commit, --no-tag 511 | 512 | **v0.2.1** 513 | 514 | - If available, use git to learn about current version 515 | 516 | **v0.2.0** 517 | 518 | - Mercurial support 519 | 520 | **v0.1.1** 521 | 522 | - Only create a tag when it's requested (thanks @gvangool) 523 | 524 | **v0.1.0** 525 | 526 | - Initial public version 527 | 528 | -------------------------------------------------------------------------------- /bumpversion/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | try: 6 | from configparser import RawConfigParser, NoOptionError 7 | except ImportError: 8 | from ConfigParser import RawConfigParser, NoOptionError 9 | 10 | try: 11 | from StringIO import StringIO 12 | except: 13 | from io import StringIO 14 | 15 | 16 | import argparse 17 | import os 18 | import re 19 | import sre_constants 20 | import subprocess 21 | import warnings 22 | import io 23 | from string import Formatter 24 | from datetime import datetime 25 | from difflib import unified_diff 26 | from tempfile import NamedTemporaryFile 27 | 28 | import sys 29 | import codecs 30 | 31 | from bumpversion.version_part import VersionPart, NumericVersionPartConfiguration, ConfiguredVersionPartConfiguration 32 | 33 | if sys.version_info[0] == 2: 34 | sys.stdout = codecs.getwriter('utf-8')(sys.stdout) 35 | 36 | __VERSION__ = '0.5.4-dev' 37 | 38 | DESCRIPTION = 'bumpversion: v{} (using Python v{})'.format( 39 | __VERSION__, 40 | sys.version.split("\n")[0].split(" ")[0], 41 | ) 42 | 43 | import logging 44 | logger = logging.getLogger("bumpversion.logger") 45 | logger_list = logging.getLogger("bumpversion.list") 46 | 47 | from argparse import _AppendAction 48 | class DiscardDefaultIfSpecifiedAppendAction(_AppendAction): 49 | 50 | ''' 51 | Fixes bug http://bugs.python.org/issue16399 for 'append' action 52 | ''' 53 | 54 | def __call__(self, parser, namespace, values, option_string=None): 55 | if getattr(self, "_discarded_default", None) is None: 56 | setattr(namespace, self.dest, []) 57 | self._discarded_default = True 58 | 59 | super(DiscardDefaultIfSpecifiedAppendAction, self).__call__( 60 | parser, namespace, values, option_string=None) 61 | 62 | time_context = { 63 | 'now': datetime.now(), 64 | 'utcnow': datetime.utcnow(), 65 | } 66 | 67 | class BaseVCS(object): 68 | 69 | @classmethod 70 | def commit(cls, message): 71 | f = NamedTemporaryFile('wb', delete=False) 72 | f.write(message.encode('utf-8')) 73 | f.close() 74 | subprocess.check_output(cls._COMMIT_COMMAND + [f.name], env=dict( 75 | list(os.environ.items()) + [(b'HGENCODING', b'utf-8')] 76 | )) 77 | os.unlink(f.name) 78 | 79 | @classmethod 80 | def is_usable(cls): 81 | try: 82 | return subprocess.call( 83 | cls._TEST_USABLE_COMMAND, 84 | stderr=subprocess.PIPE, 85 | stdout=subprocess.PIPE 86 | ) == 0 87 | except OSError as e: 88 | if e.errno == 2: 89 | # mercurial is not installed then, ok. 90 | return False 91 | raise 92 | 93 | 94 | class Git(BaseVCS): 95 | 96 | _TEST_USABLE_COMMAND = ["git", "rev-parse", "--git-dir"] 97 | _COMMIT_COMMAND = ["git", "commit", "-F"] 98 | 99 | @classmethod 100 | def assert_nondirty(cls): 101 | lines = [ 102 | line.strip() for line in 103 | subprocess.check_output( 104 | ["git", "status", "--porcelain"]).splitlines() 105 | if not line.strip().startswith(b"??") 106 | ] 107 | 108 | if lines: 109 | raise WorkingDirectoryIsDirtyException( 110 | "Git working directory is not clean:\n{}".format( 111 | b"\n".join(lines))) 112 | 113 | @classmethod 114 | def latest_tag_info(cls): 115 | try: 116 | # git-describe doesn't update the git-index, so we do that 117 | subprocess.check_output(["git", "update-index", "--refresh"]) 118 | 119 | # get info about the latest tag in git 120 | describe_out = subprocess.check_output([ 121 | "git", 122 | "describe", 123 | "--dirty", 124 | "--tags", 125 | "--long", 126 | "--abbrev=40", 127 | "--match=v*", 128 | ], stderr=subprocess.STDOUT 129 | ).decode().split("-") 130 | except subprocess.CalledProcessError: 131 | # logger.warn("Error when running git describe") 132 | return {} 133 | 134 | info = {} 135 | 136 | if describe_out[-1].strip() == "dirty": 137 | info["dirty"] = True 138 | describe_out.pop() 139 | 140 | info["commit_sha"] = describe_out.pop().lstrip("g") 141 | info["distance_to_latest_tag"] = int(describe_out.pop()) 142 | info["current_version"] = "-".join(describe_out).lstrip("v") 143 | 144 | return info 145 | 146 | @classmethod 147 | def add_path(cls, path): 148 | subprocess.check_output(["git", "add", "--update", path]) 149 | 150 | @classmethod 151 | def tag(cls, name): 152 | subprocess.check_output(["git", "tag", name]) 153 | 154 | 155 | class Mercurial(BaseVCS): 156 | 157 | _TEST_USABLE_COMMAND = ["hg", "root"] 158 | _COMMIT_COMMAND = ["hg", "commit", "--logfile"] 159 | 160 | @classmethod 161 | def latest_tag_info(cls): 162 | return {} 163 | 164 | @classmethod 165 | def assert_nondirty(cls): 166 | lines = [ 167 | line.strip() for line in 168 | subprocess.check_output( 169 | ["hg", "status", "-mard"]).splitlines() 170 | if not line.strip().startswith(b"??") 171 | ] 172 | 173 | if lines: 174 | raise WorkingDirectoryIsDirtyException( 175 | "Mercurial working directory is not clean:\n{}".format( 176 | b"\n".join(lines))) 177 | 178 | @classmethod 179 | def add_path(cls, path): 180 | pass 181 | 182 | @classmethod 183 | def tag(cls, name): 184 | subprocess.check_output(["hg", "tag", name]) 185 | 186 | VCS = [Git, Mercurial] 187 | 188 | 189 | def prefixed_environ(): 190 | return dict((("${}".format(key), value) for key, value in os.environ.items())) 191 | 192 | class ConfiguredFile(object): 193 | 194 | def __init__(self, path, versionconfig): 195 | self.path = path 196 | self._versionconfig = versionconfig 197 | 198 | def should_contain_version(self, version, context): 199 | 200 | context['current_version'] = self._versionconfig.serialize(version, context) 201 | 202 | serialized_version = self._versionconfig.search.format(**context) 203 | 204 | if self.contains(serialized_version): 205 | return 206 | 207 | msg = "Did not find '{}' or '{}' in file {}".format(version.original, serialized_version, self.path) 208 | 209 | if version.original: 210 | assert self.contains(version.original), msg 211 | return 212 | 213 | assert False, msg 214 | 215 | def contains(self, search): 216 | with io.open(self.path, 'rb') as f: 217 | search_lines = search.splitlines() 218 | lookbehind = [] 219 | 220 | for lineno, line in enumerate(f.readlines()): 221 | lookbehind.append(line.decode('utf-8').rstrip("\n")) 222 | 223 | if len(lookbehind) > len(search_lines): 224 | lookbehind = lookbehind[1:] 225 | 226 | if (search_lines[0] in lookbehind[0] and 227 | search_lines[-1] in lookbehind[-1] and 228 | search_lines[1:-1] == lookbehind[1:-1]): 229 | logger.info("Found '{}' in {} at line {}: {}".format( 230 | search, self.path, lineno - (len(lookbehind) - 1), line.decode('utf-8').rstrip())) 231 | return True 232 | return False 233 | 234 | def replace(self, current_version, new_version, context, dry_run): 235 | 236 | with io.open(self.path, 'rb') as f: 237 | file_content_before = f.read().decode('utf-8') 238 | 239 | context['current_version'] = self._versionconfig.serialize(current_version, context) 240 | context['new_version'] = self._versionconfig.serialize(new_version, context) 241 | 242 | search_for = self._versionconfig.search.format(**context) 243 | replace_with = self._versionconfig.replace.format(**context) 244 | 245 | file_content_after = file_content_before.replace( 246 | search_for, replace_with 247 | ) 248 | 249 | if file_content_before == file_content_after: 250 | # TODO expose this to be configurable 251 | file_content_after = file_content_before.replace( 252 | current_version.original, 253 | replace_with, 254 | ) 255 | 256 | if file_content_before != file_content_after: 257 | logger.info("{} file {}:".format( 258 | "Would change" if dry_run else "Changing", 259 | self.path, 260 | )) 261 | logger.info("\n".join(list(unified_diff( 262 | file_content_before.splitlines(), 263 | file_content_after.splitlines(), 264 | lineterm="", 265 | fromfile="a/"+self.path, 266 | tofile="b/"+self.path 267 | )))) 268 | else: 269 | logger.info("{} file {}".format( 270 | "Would not change" if dry_run else "Not changing", 271 | self.path, 272 | )) 273 | 274 | if not dry_run: 275 | with io.open(self.path, 'wb') as f: 276 | f.write(file_content_after.encode('utf-8')) 277 | 278 | def __str__(self): 279 | return self.path 280 | 281 | def __repr__(self): 282 | return ''.format(self.path) 283 | 284 | class IncompleteVersionRepresenationException(Exception): 285 | def __init__(self, message): 286 | self.message = message 287 | 288 | class MissingValueForSerializationException(Exception): 289 | def __init__(self, message): 290 | self.message = message 291 | 292 | class WorkingDirectoryIsDirtyException(Exception): 293 | def __init__(self, message): 294 | self.message = message 295 | 296 | def keyvaluestring(d): 297 | return ", ".join("{}={}".format(k, v) for k, v in sorted(d.items())) 298 | 299 | class Version(object): 300 | 301 | def __init__(self, values, original=None): 302 | self._values = dict(values) 303 | self.original = original 304 | 305 | def __getitem__(self, key): 306 | return self._values[key] 307 | 308 | def __len__(self): 309 | return len(self._values) 310 | 311 | def __iter__(self): 312 | return iter(self._values) 313 | 314 | def __repr__(self): 315 | return ''.format(keyvaluestring(self._values)) 316 | 317 | def bump(self, part_name, order): 318 | bumped = False 319 | 320 | new_values = {} 321 | 322 | for label in order: 323 | if not label in self._values: 324 | continue 325 | elif label == part_name: 326 | new_values[label] = self._values[label].bump() 327 | bumped = True 328 | elif bumped: 329 | new_values[label] = self._values[label].null() 330 | else: 331 | new_values[label] = self._values[label].copy() 332 | 333 | new_version = Version(new_values) 334 | 335 | return new_version 336 | 337 | class VersionConfig(object): 338 | 339 | """ 340 | Holds a complete representation of a version string 341 | """ 342 | 343 | def __init__(self, parse, serialize, search, replace, part_configs=None): 344 | 345 | try: 346 | self.parse_regex = re.compile(parse, re.VERBOSE) 347 | except sre_constants.error as e: 348 | logger.error("--parse '{}' is not a valid regex".format(parse)) 349 | raise e 350 | 351 | self.serialize_formats = serialize 352 | 353 | if not part_configs: 354 | part_configs = {} 355 | 356 | self.part_configs = part_configs 357 | self.search = search 358 | self.replace = replace 359 | 360 | def _labels_for_format(self, serialize_format): 361 | return ( 362 | label 363 | for _, label, _, _ in Formatter().parse(serialize_format) 364 | if label 365 | ) 366 | 367 | def order(self): 368 | # currently, order depends on the first given serialization format 369 | # this seems like a good idea because this should be the most complete format 370 | return self._labels_for_format(self.serialize_formats[0]) 371 | 372 | def parse(self, version_string): 373 | 374 | regexp_one_line = "".join([l.split("#")[0].strip() for l in self.parse_regex.pattern.splitlines()]) 375 | 376 | logger.info("Parsing version '{}' using regexp '{}'".format(version_string, regexp_one_line)) 377 | 378 | match = self.parse_regex.search(version_string) 379 | 380 | _parsed = {} 381 | if not match: 382 | logger.warn("Evaluating 'parse' option: '{}' does not parse current version '{}'".format( 383 | self.parse_regex.pattern, version_string)) 384 | return 385 | 386 | for key, value in match.groupdict().items(): 387 | _parsed[key] = VersionPart(value, self.part_configs.get(key)) 388 | 389 | 390 | v = Version(_parsed, version_string) 391 | 392 | logger.info("Parsed the following values: %s" % keyvaluestring(v._values)) 393 | 394 | return v 395 | 396 | def _serialize(self, version, serialize_format, context, raise_if_incomplete=False): 397 | """ 398 | Attempts to serialize a version with the given serialization format. 399 | 400 | Raises MissingValueForSerializationException if not serializable 401 | """ 402 | values = context.copy() 403 | for k in version: 404 | values[k] = version[k] 405 | 406 | # TODO dump complete context on debug level 407 | 408 | try: 409 | # test whether all parts required in the format have values 410 | serialized = serialize_format.format(**values) 411 | 412 | except KeyError as e: 413 | missing_key = getattr(e, 414 | 'message', # Python 2 415 | e.args[0] # Python 3 416 | ) 417 | raise MissingValueForSerializationException( 418 | "Did not find key {} in {} when serializing version number".format( 419 | repr(missing_key), repr(version))) 420 | 421 | keys_needing_representation = set() 422 | found_required = False 423 | 424 | for k in self.order(): 425 | v = values[k] 426 | 427 | if not isinstance(v, VersionPart): 428 | # values coming from environment variables don't need 429 | # representation 430 | continue 431 | 432 | if not v.is_optional(): 433 | found_required = True 434 | keys_needing_representation.add(k) 435 | elif not found_required: 436 | keys_needing_representation.add(k) 437 | 438 | required_by_format = set(self._labels_for_format(serialize_format)) 439 | 440 | # try whether all parsed keys are represented 441 | if raise_if_incomplete: 442 | if not (keys_needing_representation <= required_by_format): 443 | raise IncompleteVersionRepresenationException( 444 | "Could not represent '{}' in format '{}'".format( 445 | "', '".join(keys_needing_representation ^ required_by_format), 446 | serialize_format, 447 | )) 448 | 449 | return serialized 450 | 451 | 452 | def _choose_serialize_format(self, version, context): 453 | 454 | chosen = None 455 | 456 | # logger.info("Available serialization formats: '{}'".format("', '".join(self.serialize_formats))) 457 | 458 | for serialize_format in self.serialize_formats: 459 | try: 460 | self._serialize(version, serialize_format, context, raise_if_incomplete=True) 461 | chosen = serialize_format 462 | # logger.info("Found '{}' to be a usable serialization format".format(chosen)) 463 | except IncompleteVersionRepresenationException as e: 464 | # logger.info(e.message) 465 | if not chosen: 466 | chosen = serialize_format 467 | except MissingValueForSerializationException as e: 468 | logger.info(e.message) 469 | raise e 470 | 471 | if not chosen: 472 | raise KeyError("Did not find suitable serialization format") 473 | 474 | # logger.info("Selected serialization format '{}'".format(chosen)) 475 | 476 | return chosen 477 | 478 | def serialize(self, version, context): 479 | serialized = self._serialize(version, self._choose_serialize_format(version, context), context) 480 | # logger.info("Serialized to '{}'".format(serialized)) 481 | return serialized 482 | 483 | OPTIONAL_ARGUMENTS_THAT_TAKE_VALUES = [ 484 | '--config-file', 485 | '--current-version', 486 | '--message', 487 | '--new-version', 488 | '--parse', 489 | '--serialize', 490 | '--search', 491 | '--replace', 492 | '--tag-name', 493 | '-m' 494 | ] 495 | 496 | 497 | def split_args_in_optional_and_positional(args): 498 | # manually parsing positional arguments because stupid argparse can't mix 499 | # positional and optional arguments 500 | 501 | positions = [] 502 | for i, arg in enumerate(args): 503 | 504 | previous = None 505 | 506 | if i > 0: 507 | previous = args[i-1] 508 | 509 | if ((not arg.startswith('-')) and 510 | (previous not in OPTIONAL_ARGUMENTS_THAT_TAKE_VALUES)): 511 | positions.append(i) 512 | 513 | positionals = [arg for i, arg in enumerate(args) if i in positions] 514 | args = [arg for i, arg in enumerate(args) if i not in positions] 515 | 516 | return (positionals, args) 517 | 518 | def main(original_args=None): 519 | 520 | positionals, args = split_args_in_optional_and_positional( 521 | sys.argv[1:] if original_args is None else original_args 522 | ) 523 | 524 | if len(positionals[1:]) > 2: 525 | warnings.warn("Giving multiple files on the command line will be deprecated, please use [bumpversion:file:...] in a config file.", PendingDeprecationWarning) 526 | 527 | parser1 = argparse.ArgumentParser(add_help=False) 528 | 529 | parser1.add_argument( 530 | '--config-file', metavar='FILE', 531 | default=argparse.SUPPRESS, required=False, 532 | help='Config file to read most of the variables from (default: .bumpversion.cfg)') 533 | 534 | parser1.add_argument( 535 | '--verbose', action='count', default=0, 536 | help='Print verbose logging to stderr', required=False) 537 | 538 | parser1.add_argument( 539 | '--list', action='store_true', default=False, 540 | help='List machine readable information', required=False) 541 | 542 | parser1.add_argument( 543 | '--allow-dirty', action='store_true', default=False, 544 | help="Don't abort if working directory is dirty", required=False) 545 | 546 | known_args, remaining_argv = parser1.parse_known_args(args) 547 | 548 | logformatter = logging.Formatter('%(message)s') 549 | 550 | if len(logger.handlers) == 0: 551 | ch = logging.StreamHandler(sys.stderr) 552 | ch.setFormatter(logformatter) 553 | logger.addHandler(ch) 554 | 555 | if len(logger_list.handlers) == 0: 556 | ch2 = logging.StreamHandler(sys.stdout) 557 | ch2.setFormatter(logformatter) 558 | logger_list.addHandler(ch2) 559 | 560 | if known_args.list: 561 | logger_list.setLevel(1) 562 | 563 | log_level = { 564 | 0: logging.WARNING, 565 | 1: logging.INFO, 566 | 2: logging.DEBUG, 567 | }.get(known_args.verbose, logging.DEBUG) 568 | 569 | logger.setLevel(log_level) 570 | 571 | logger.debug("Starting {}".format(DESCRIPTION)) 572 | 573 | defaults = {} 574 | vcs_info = {} 575 | 576 | for vcs in VCS: 577 | if vcs.is_usable(): 578 | vcs_info.update(vcs.latest_tag_info()) 579 | 580 | if 'current_version' in vcs_info: 581 | defaults['current_version'] = vcs_info['current_version'] 582 | 583 | config = RawConfigParser('') 584 | 585 | # don't transform keys to lowercase (which would be the default) 586 | config.optionxform = lambda option: option 587 | 588 | config.add_section('bumpversion') 589 | 590 | explicit_config = hasattr(known_args, 'config_file') 591 | 592 | if explicit_config: 593 | config_file = known_args.config_file 594 | elif not os.path.exists('.bumpversion.cfg') and \ 595 | os.path.exists('setup.cfg'): 596 | config_file = 'setup.cfg' 597 | else: 598 | config_file = '.bumpversion.cfg' 599 | 600 | config_file_exists = os.path.exists(config_file) 601 | 602 | part_configs = {} 603 | 604 | files = [] 605 | 606 | if config_file_exists: 607 | 608 | logger.info("Reading config file {}:".format(config_file)) 609 | logger.info(io.open(config_file, 'rt', encoding='utf-8').read()) 610 | 611 | config.readfp(io.open(config_file, 'rt', encoding='utf-8')) 612 | 613 | log_config = StringIO() 614 | config.write(log_config) 615 | 616 | if 'files' in dict(config.items("bumpversion")): 617 | warnings.warn( 618 | "'files =' configuration is will be deprecated, please use [bumpversion:file:...]", 619 | PendingDeprecationWarning 620 | ) 621 | 622 | defaults.update(dict(config.items("bumpversion"))) 623 | 624 | for listvaluename in ("serialize",): 625 | try: 626 | value = config.get("bumpversion", listvaluename) 627 | defaults[listvaluename] = list(filter(None, (x.strip() for x in value.splitlines()))) 628 | except NoOptionError: 629 | pass # no default value then ;) 630 | 631 | for boolvaluename in ("commit", "tag", "dry_run"): 632 | try: 633 | defaults[boolvaluename] = config.getboolean( 634 | "bumpversion", boolvaluename) 635 | except NoOptionError: 636 | pass # no default value then ;) 637 | 638 | for section_name in config.sections(): 639 | 640 | section_name_match = re.compile("^bumpversion:(file|part):(.+)").match(section_name) 641 | 642 | if not section_name_match: 643 | continue 644 | 645 | section_prefix, section_value = section_name_match.groups() 646 | 647 | section_config = dict(config.items(section_name)) 648 | 649 | if section_prefix == "part": 650 | 651 | ThisVersionPartConfiguration = NumericVersionPartConfiguration 652 | 653 | if 'values' in section_config: 654 | section_config['values'] = list(filter(None, (x.strip() for x in section_config['values'].splitlines()))) 655 | ThisVersionPartConfiguration = ConfiguredVersionPartConfiguration 656 | 657 | part_configs[section_value] = ThisVersionPartConfiguration(**section_config) 658 | 659 | elif section_prefix == "file": 660 | 661 | filename = section_value 662 | 663 | if 'serialize' in section_config: 664 | section_config['serialize'] = list(filter(None, (x.strip() for x in section_config['serialize'].splitlines()))) 665 | 666 | section_config['part_configs'] = part_configs 667 | 668 | if not 'parse' in section_config: 669 | section_config['parse'] = defaults.get("parse", '(?P\d+)\.(?P\d+)\.(?P\d+)') 670 | 671 | if not 'serialize' in section_config: 672 | section_config['serialize'] = defaults.get('serialize', [str('{major}.{minor}.{patch}')]) 673 | 674 | if not 'search' in section_config: 675 | section_config['search'] = defaults.get("search", '{current_version}') 676 | 677 | if not 'replace' in section_config: 678 | section_config['replace'] = defaults.get("replace", '{new_version}') 679 | 680 | files.append(ConfiguredFile(filename, VersionConfig(**section_config))) 681 | 682 | else: 683 | message = "Could not read config file at {}".format(config_file) 684 | if explicit_config: 685 | raise argparse.ArgumentTypeError(message) 686 | else: 687 | logger.info(message) 688 | 689 | parser2 = argparse.ArgumentParser(prog='bumpversion', add_help=False, parents=[parser1]) 690 | parser2.set_defaults(**defaults) 691 | 692 | parser2.add_argument('--current-version', metavar='VERSION', 693 | help='Version that needs to be updated', required=False) 694 | parser2.add_argument('--parse', metavar='REGEX', 695 | help='Regex parsing the version string', 696 | default=defaults.get("parse", '(?P\d+)\.(?P\d+)\.(?P\d+)')) 697 | parser2.add_argument('--serialize', metavar='FORMAT', 698 | action=DiscardDefaultIfSpecifiedAppendAction, 699 | help='How to format what is parsed back to a version', 700 | default=defaults.get("serialize", [str('{major}.{minor}.{patch}')])) 701 | parser2.add_argument('--search', metavar='SEARCH', 702 | help='Template for complete string to search', 703 | default=defaults.get("search", '{current_version}')) 704 | parser2.add_argument('--replace', metavar='REPLACE', 705 | help='Template for complete string to replace', 706 | default=defaults.get("replace", '{new_version}')) 707 | 708 | known_args, remaining_argv = parser2.parse_known_args(args) 709 | 710 | defaults.update(vars(known_args)) 711 | 712 | assert type(known_args.serialize) == list 713 | 714 | context = dict(list(time_context.items()) + list(prefixed_environ().items()) + list(vcs_info.items())) 715 | 716 | try: 717 | vc = VersionConfig( 718 | parse=known_args.parse, 719 | serialize=known_args.serialize, 720 | search=known_args.search, 721 | replace=known_args.replace, 722 | part_configs=part_configs, 723 | ) 724 | except sre_constants.error as e: 725 | sys.exit(1) 726 | 727 | current_version = vc.parse(known_args.current_version) if known_args.current_version else None 728 | 729 | new_version = None 730 | 731 | if not 'new_version' in defaults and known_args.current_version: 732 | try: 733 | if current_version and len(positionals) > 0: 734 | logger.info("Attempting to increment part '{}'".format(positionals[0])) 735 | new_version = current_version.bump(positionals[0], vc.order()) 736 | logger.info("Values are now: " + keyvaluestring(new_version._values)) 737 | defaults['new_version'] = vc.serialize(new_version, context) 738 | except MissingValueForSerializationException as e: 739 | logger.info("Opportunistic finding of new_version failed: " + e.message) 740 | except IncompleteVersionRepresenationException as e: 741 | logger.info("Opportunistic finding of new_version failed: " + e.message) 742 | except KeyError as e: 743 | logger.info("Opportunistic finding of new_version failed") 744 | 745 | parser3 = argparse.ArgumentParser( 746 | prog='bumpversion', 747 | description=DESCRIPTION, 748 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 749 | conflict_handler='resolve', 750 | parents=[parser2], 751 | ) 752 | 753 | parser3.set_defaults(**defaults) 754 | 755 | parser3.add_argument('--current-version', metavar='VERSION', 756 | help='Version that needs to be updated', 757 | required=not 'current_version' in defaults) 758 | parser3.add_argument('--dry-run', '-n', action='store_true', 759 | default=False, help="Don't write any files, just pretend.") 760 | parser3.add_argument('--new-version', metavar='VERSION', 761 | help='New version that should be in the files', 762 | required=not 'new_version' in defaults) 763 | 764 | commitgroup = parser3.add_mutually_exclusive_group() 765 | 766 | commitgroup.add_argument('--commit', action='store_true', dest="commit", 767 | help='Commit to version control', default=defaults.get("commit", False)) 768 | commitgroup.add_argument('--no-commit', action='store_false', dest="commit", 769 | help='Do not commit to version control', default=argparse.SUPPRESS) 770 | 771 | taggroup = parser3.add_mutually_exclusive_group() 772 | 773 | taggroup.add_argument('--tag', action='store_true', dest="tag", default=defaults.get("tag", False), 774 | help='Create a tag in version control') 775 | taggroup.add_argument('--no-tag', action='store_false', dest="tag", 776 | help='Do not create a tag in version control', default=argparse.SUPPRESS) 777 | 778 | parser3.add_argument('--tag-name', metavar='TAG_NAME', 779 | help='Tag name (only works with --tag)', 780 | default=defaults.get('tag_name', 'v{new_version}')) 781 | 782 | parser3.add_argument('--message', '-m', metavar='COMMIT_MSG', 783 | help='Commit message', 784 | default=defaults.get('message', 'Bump version: {current_version} → {new_version}')) 785 | 786 | 787 | file_names = [] 788 | if 'files' in defaults: 789 | assert defaults['files'] != None 790 | file_names = defaults['files'].split(' ') 791 | 792 | parser3.add_argument('part', 793 | help='Part of the version to be bumped.') 794 | parser3.add_argument('files', metavar='file', 795 | nargs='*', 796 | help='Files to change', default=file_names) 797 | 798 | args = parser3.parse_args(remaining_argv + positionals) 799 | 800 | if args.dry_run: 801 | logger.info("Dry run active, won't touch any files.") 802 | 803 | if args.new_version: 804 | new_version = vc.parse(args.new_version) 805 | 806 | logger.info("New version will be '{}'".format(args.new_version)) 807 | 808 | file_names = file_names or positionals[1:] 809 | 810 | for file_name in file_names: 811 | files.append(ConfiguredFile(file_name, vc)) 812 | 813 | for vcs in VCS: 814 | if vcs.is_usable(): 815 | try: 816 | vcs.assert_nondirty() 817 | except WorkingDirectoryIsDirtyException as e: 818 | if not defaults['allow_dirty']: 819 | logger.warn( 820 | "{}\n\nUse --allow-dirty to override this if you know what you're doing.".format(e.message)) 821 | raise 822 | break 823 | else: 824 | vcs = None 825 | 826 | # make sure files exist and contain version string 827 | 828 | logger.info("Asserting files {} contain the version string:".format(", ".join([str(f) for f in files]))) 829 | 830 | for f in files: 831 | f.should_contain_version(current_version, context) 832 | 833 | # change version string in files 834 | for f in files: 835 | f.replace(current_version, new_version, context, args.dry_run) 836 | 837 | commit_files = [f.path for f in files] 838 | 839 | config.set('bumpversion', 'new_version', args.new_version) 840 | 841 | for key, value in config.items('bumpversion'): 842 | logger_list.info("{}={}".format(key, value)) 843 | 844 | config.remove_option('bumpversion', 'new_version') 845 | 846 | config.set('bumpversion', 'current_version', args.new_version) 847 | 848 | new_config = StringIO() 849 | 850 | try: 851 | write_to_config_file = (not args.dry_run) and config_file_exists 852 | 853 | logger.info("{} to config file {}:".format( 854 | "Would write" if not write_to_config_file else "Writing", 855 | config_file, 856 | )) 857 | 858 | config.write(new_config) 859 | logger.info(new_config.getvalue()) 860 | 861 | if write_to_config_file: 862 | with io.open(config_file, 'wb') as f: 863 | f.write(new_config.getvalue().encode('utf-8')) 864 | 865 | except UnicodeEncodeError: 866 | warnings.warn( 867 | "Unable to write UTF-8 to config file, because of an old configparser version. " 868 | "Update with `pip install --upgrade configparser`." 869 | ) 870 | 871 | if config_file_exists: 872 | commit_files.append(config_file) 873 | 874 | if not vcs: 875 | return 876 | 877 | assert vcs.is_usable(), "Did find '{}' unusable, unable to commit.".format(vcs.__name__) 878 | 879 | do_commit = (not args.dry_run) and args.commit 880 | do_tag = (not args.dry_run) and args.tag 881 | 882 | logger.info("{} {} commit".format( 883 | "Would prepare" if not do_commit else "Preparing", 884 | vcs.__name__, 885 | )) 886 | 887 | for path in commit_files: 888 | logger.info("{} changes in file '{}' to {}".format( 889 | "Would add" if not do_commit else "Adding", 890 | path, 891 | vcs.__name__, 892 | )) 893 | 894 | if do_commit: 895 | vcs.add_path(path) 896 | 897 | vcs_context = { 898 | "current_version": args.current_version, 899 | "new_version": args.new_version, 900 | } 901 | vcs_context.update(time_context) 902 | vcs_context.update(prefixed_environ()) 903 | 904 | commit_message = args.message.format(**vcs_context) 905 | 906 | logger.info("{} to {} with message '{}'".format( 907 | "Would commit" if not do_commit else "Committing", 908 | vcs.__name__, 909 | commit_message, 910 | )) 911 | 912 | if do_commit: 913 | vcs.commit(message=commit_message) 914 | 915 | tag_name = args.tag_name.format(**vcs_context) 916 | logger.info("{} '{}' in {}".format( 917 | "Would tag" if not do_tag else "Tagging", 918 | tag_name, 919 | vcs.__name__ 920 | )) 921 | 922 | if do_tag: 923 | vcs.tag(tag_name) 924 | 925 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, print_function 4 | 5 | import pytest 6 | import sys 7 | import logging 8 | import mock 9 | 10 | import argparse 11 | import subprocess 12 | from os import curdir, makedirs, chdir, environ 13 | from os.path import join, curdir, dirname 14 | from shlex import split as shlex_split 15 | from textwrap import dedent 16 | from functools import partial 17 | 18 | import bumpversion 19 | 20 | from bumpversion import main, DESCRIPTION, WorkingDirectoryIsDirtyException, \ 21 | split_args_in_optional_and_positional 22 | 23 | SUBPROCESS_ENV = dict( 24 | list(environ.items()) + [(b'HGENCODING', b'utf-8')] 25 | ) 26 | 27 | call = partial(subprocess.call, env=SUBPROCESS_ENV) 28 | check_call = partial(subprocess.check_call, env=SUBPROCESS_ENV) 29 | check_output = partial(subprocess.check_output, env=SUBPROCESS_ENV) 30 | 31 | xfail_if_no_git = pytest.mark.xfail( 32 | call(["git", "help"]) != 0, 33 | reason="git is not installed" 34 | ) 35 | 36 | xfail_if_no_hg = pytest.mark.xfail( 37 | call(["hg", "help"]) != 0, 38 | reason="hg is not installed" 39 | ) 40 | 41 | @pytest.fixture(params=['.bumpversion.cfg', 'setup.cfg']) 42 | def configfile(request): 43 | return request.param 44 | 45 | 46 | try: 47 | bumpversion.RawConfigParser(empty_lines_in_values=False) 48 | using_old_configparser = False 49 | except TypeError: 50 | using_old_configparser = True 51 | 52 | xfail_if_old_configparser = pytest.mark.xfail( 53 | using_old_configparser, 54 | reason="configparser doesn't support empty_lines_in_values" 55 | ) 56 | 57 | def _mock_calls_to_string(called_mock): 58 | return ["{}|{}|{}".format( 59 | name, 60 | args[0] if len(args) > 0 else args, 61 | repr(kwargs) if len(kwargs) > 0 else "" 62 | ) for name, args, kwargs in called_mock.mock_calls] 63 | 64 | 65 | 66 | EXPECTED_OPTIONS = """ 67 | [-h] 68 | [--config-file FILE] 69 | [--verbose] 70 | [--list] 71 | [--allow-dirty] 72 | [--parse REGEX] 73 | [--serialize FORMAT] 74 | [--search SEARCH] 75 | [--replace REPLACE] 76 | [--current-version VERSION] 77 | [--dry-run] 78 | --new-version VERSION 79 | [--commit | --no-commit] 80 | [--tag | --no-tag] 81 | [--tag-name TAG_NAME] 82 | [--message COMMIT_MSG] 83 | part 84 | [file [file ...]] 85 | """.strip().splitlines() 86 | 87 | EXPECTED_USAGE = (""" 88 | 89 | %s 90 | 91 | positional arguments: 92 | part Part of the version to be bumped. 93 | file Files to change (default: []) 94 | 95 | optional arguments: 96 | -h, --help show this help message and exit 97 | --config-file FILE Config file to read most of the variables from 98 | (default: .bumpversion.cfg) 99 | --verbose Print verbose logging to stderr (default: 0) 100 | --list List machine readable information (default: False) 101 | --allow-dirty Don't abort if working directory is dirty (default: 102 | False) 103 | --parse REGEX Regex parsing the version string (default: 104 | (?P\d+)\.(?P\d+)\.(?P\d+)) 105 | --serialize FORMAT How to format what is parsed back to a version 106 | (default: ['{major}.{minor}.{patch}']) 107 | --search SEARCH Template for complete string to search (default: 108 | {current_version}) 109 | --replace REPLACE Template for complete string to replace (default: 110 | {new_version}) 111 | --current-version VERSION 112 | Version that needs to be updated (default: None) 113 | --dry-run, -n Don't write any files, just pretend. (default: False) 114 | --new-version VERSION 115 | New version that should be in the files (default: 116 | None) 117 | --commit Commit to version control (default: False) 118 | --no-commit Do not commit to version control 119 | --tag Create a tag in version control (default: False) 120 | --no-tag Do not create a tag in version control 121 | --tag-name TAG_NAME Tag name (only works with --tag) (default: 122 | v{new_version}) 123 | --message COMMIT_MSG, -m COMMIT_MSG 124 | Commit message (default: Bump version: 125 | {current_version} → {new_version}) 126 | """ % DESCRIPTION).lstrip() 127 | 128 | 129 | def test_usage_string(tmpdir, capsys): 130 | tmpdir.chdir() 131 | 132 | with pytest.raises(SystemExit): 133 | main(['--help']) 134 | 135 | out, err = capsys.readouterr() 136 | assert err == "" 137 | 138 | for option_line in EXPECTED_OPTIONS: 139 | assert option_line in out, "Usage string is missing {}".format(option_line) 140 | 141 | assert EXPECTED_USAGE in out 142 | 143 | def test_usage_string_fork(tmpdir, capsys): 144 | tmpdir.chdir() 145 | 146 | try: 147 | out = check_output('bumpversion --help', shell=True, stderr=subprocess.STDOUT).decode('utf-8') 148 | except subprocess.CalledProcessError as e: 149 | out = e.output 150 | 151 | if not 'usage: bumpversion [-h]' in out: 152 | print(out) 153 | 154 | assert 'usage: bumpversion [-h]' in out 155 | 156 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 157 | def test_regression_help_in_workdir(tmpdir, capsys, vcs): 158 | tmpdir.chdir() 159 | tmpdir.join("somesource.txt").write("1.7.2013") 160 | check_call([vcs, "init"]) 161 | check_call([vcs, "add", "somesource.txt"]) 162 | check_call([vcs, "commit", "-m", "initial commit"]) 163 | check_call([vcs, "tag", "v1.7.2013"]) 164 | 165 | with pytest.raises(SystemExit): 166 | main(['--help']) 167 | 168 | out, err = capsys.readouterr() 169 | 170 | for option_line in EXPECTED_OPTIONS: 171 | assert option_line in out, "Usage string is missing {}".format(option_line) 172 | 173 | if vcs == "git": 174 | assert "Version that needs to be updated (default: 1.7.2013)" in out 175 | else: 176 | assert EXPECTED_USAGE in out 177 | 178 | 179 | def test_defaults_in_usage_with_config(tmpdir, capsys): 180 | tmpdir.chdir() 181 | tmpdir.join("mydefaults.cfg").write("""[bumpversion] 182 | current_version: 18 183 | new_version: 19 184 | files: file1 file2 file3""") 185 | with pytest.raises(SystemExit): 186 | main(['--config-file', 'mydefaults.cfg', '--help']) 187 | 188 | out, err = capsys.readouterr() 189 | 190 | assert "Version that needs to be updated (default: 18)" in out 191 | assert "New version that should be in the files (default: 19)" in out 192 | assert "[--current-version VERSION]" in out 193 | assert "[--new-version VERSION]" in out 194 | assert "[file [file ...]]" in out 195 | 196 | 197 | def test_missing_explicit_config_file(tmpdir): 198 | tmpdir.chdir() 199 | with pytest.raises(argparse.ArgumentTypeError): 200 | main(['--config-file', 'missing.cfg']) 201 | 202 | 203 | def test_simple_replacement(tmpdir): 204 | tmpdir.join("VERSION").write("1.2.0") 205 | tmpdir.chdir() 206 | main(shlex_split("patch --current-version 1.2.0 --new-version 1.2.1 VERSION")) 207 | assert "1.2.1" == tmpdir.join("VERSION").read() 208 | 209 | 210 | def test_simple_replacement_in_utf8_file(tmpdir): 211 | tmpdir.join("VERSION").write("Kröt1.3.0".encode('utf-8'), 'wb') 212 | tmpdir.chdir() 213 | main(shlex_split("patch --current-version 1.3.0 --new-version 1.3.1 VERSION")) 214 | out = tmpdir.join("VERSION").read('rb') 215 | assert "'Kr\\xc3\\xb6t1.3.1'" in repr(out) 216 | 217 | 218 | def test_config_file(tmpdir): 219 | tmpdir.join("file1").write("0.9.34") 220 | tmpdir.join("mybumpconfig.cfg").write("""[bumpversion] 221 | current_version: 0.9.34 222 | new_version: 0.9.35 223 | files: file1""") 224 | 225 | tmpdir.chdir() 226 | main(shlex_split("patch --config-file mybumpconfig.cfg")) 227 | 228 | assert "0.9.35" == tmpdir.join("file1").read() 229 | 230 | 231 | def test_default_config_files(tmpdir, configfile): 232 | tmpdir.join("file2").write("0.10.2") 233 | tmpdir.join(configfile).write("""[bumpversion] 234 | current_version: 0.10.2 235 | new_version: 0.10.3 236 | files: file2""") 237 | 238 | tmpdir.chdir() 239 | main(['patch']) 240 | 241 | assert "0.10.3" == tmpdir.join("file2").read() 242 | 243 | 244 | def test_multiple_config_files(tmpdir): 245 | tmpdir.join("file2").write("0.10.2") 246 | tmpdir.join("setup.cfg").write("""[bumpversion] 247 | current_version: 0.10.2 248 | new_version: 0.10.3 249 | files: file2""") 250 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion] 251 | current_version: 0.10.2 252 | new_version: 0.10.4 253 | files: file2""") 254 | 255 | tmpdir.chdir() 256 | main(['patch']) 257 | 258 | assert "0.10.4" == tmpdir.join("file2").read() 259 | 260 | 261 | def test_config_file_is_updated(tmpdir): 262 | tmpdir.join("file3").write("0.0.13") 263 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion] 264 | current_version: 0.0.13 265 | new_version: 0.0.14 266 | files: file3 267 | """) 268 | 269 | tmpdir.chdir() 270 | main(['patch', '--verbose']) 271 | 272 | assert """[bumpversion] 273 | current_version = 0.0.14 274 | files = file3 275 | 276 | """ == tmpdir.join(".bumpversion.cfg").read() 277 | 278 | 279 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 280 | def test_dry_run(tmpdir, vcs): 281 | tmpdir.chdir() 282 | 283 | config = """[bumpversion] 284 | current_version = 0.12.0 285 | files = file4 286 | tag = True 287 | commit = True 288 | message = DO NOT BUMP VERSIONS WITH THIS FILE 289 | """ 290 | 291 | version = "0.12.0" 292 | 293 | tmpdir.join("file4").write(version) 294 | tmpdir.join(".bumpversion.cfg").write(config) 295 | 296 | check_call([vcs, "init"]) 297 | check_call([vcs, "add", "file4"]) 298 | check_call([vcs, "add", ".bumpversion.cfg"]) 299 | check_call([vcs, "commit", "-m", "initial commit"]) 300 | 301 | main(['patch', '--dry-run']) 302 | 303 | assert config == tmpdir.join(".bumpversion.cfg").read() 304 | assert version == tmpdir.join("file4").read() 305 | 306 | vcs_log = check_output([vcs, "log"]).decode('utf-8') 307 | 308 | assert "initial commit" in vcs_log 309 | assert "DO NOT" not in vcs_log 310 | 311 | def test_bump_version(tmpdir): 312 | 313 | tmpdir.join("file5").write("1.0.0") 314 | tmpdir.chdir() 315 | main(['patch', '--current-version', '1.0.0', 'file5']) 316 | 317 | assert '1.0.1' == tmpdir.join("file5").read() 318 | 319 | 320 | def test_bump_version_custom_parse(tmpdir): 321 | 322 | tmpdir.join("file6").write("XXX1;0;0") 323 | tmpdir.chdir() 324 | main([ 325 | '--current-version', 'XXX1;0;0', 326 | '--parse', 'XXX(?P\d+);(?P\d+);(?P\d+)', 327 | '--serialize', 'XXX{spam};{garlg};{slurp}', 328 | 'garlg', 329 | 'file6' 330 | ]) 331 | 332 | assert 'XXX1;1;0' == tmpdir.join("file6").read() 333 | 334 | def test_bump_version_custom_parse_serialize_configfile(tmpdir): 335 | 336 | tmpdir.join("file12").write("ZZZ8;0;0") 337 | tmpdir.chdir() 338 | 339 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion] 340 | files = file12 341 | current_version = ZZZ8;0;0 342 | serialize = ZZZ{spam};{garlg};{slurp} 343 | parse = ZZZ(?P\d+);(?P\d+);(?P\d+) 344 | """) 345 | 346 | main(['garlg']) 347 | 348 | assert 'ZZZ8;1;0' == tmpdir.join("file12").read() 349 | 350 | def test_bumpversion_custom_parse_semver(tmpdir): 351 | tmpdir.join("file15").write("XXX1.1.7-master+allan1") 352 | tmpdir.chdir() 353 | main([ 354 | '--current-version', '1.1.7-master+allan1', 355 | '--parse', '(?P\d+).(?P\d+).(?P\d+)(-(?P[^\+]+))?(\+(?P.*))?', 356 | '--serialize', '{major}.{minor}.{patch}-{prerel}+{meta}', 357 | 'meta', 358 | 'file15' 359 | ]) 360 | 361 | assert 'XXX1.1.7-master+allan2' == tmpdir.join("file15").read() 362 | 363 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 364 | def test_dirty_workdir(tmpdir, vcs): 365 | tmpdir.chdir() 366 | check_call([vcs, "init"]) 367 | tmpdir.join("dirty").write("i'm dirty") 368 | 369 | check_call([vcs, "add", "dirty"]) 370 | 371 | with pytest.raises(WorkingDirectoryIsDirtyException): 372 | with mock.patch("bumpversion.logger") as logger: 373 | main(['patch', '--current-version', '1', '--new-version', '2', 'file7']) 374 | 375 | actual_log ="\n".join(_mock_calls_to_string(logger)[4:]) 376 | 377 | assert 'working directory is not clean' in actual_log 378 | assert "Use --allow-dirty to override this if you know what you're doing." in actual_log 379 | 380 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 381 | def test_force_dirty_workdir(tmpdir, vcs): 382 | tmpdir.chdir() 383 | check_call([vcs, "init"]) 384 | tmpdir.join("dirty2").write("i'm dirty! 1.1.1") 385 | 386 | check_call([vcs, "add", "dirty2"]) 387 | 388 | main([ 389 | 'patch', 390 | '--allow-dirty', 391 | '--current-version', 392 | '1.1.1', 393 | 'dirty2' 394 | ]) 395 | 396 | assert "i'm dirty! 1.1.2" == tmpdir.join("dirty2").read() 397 | 398 | def test_bump_major(tmpdir): 399 | tmpdir.join("fileMAJORBUMP").write("4.2.8") 400 | tmpdir.chdir() 401 | main(['--current-version', '4.2.8', 'major', 'fileMAJORBUMP']) 402 | 403 | assert '5.0.0' == tmpdir.join("fileMAJORBUMP").read() 404 | 405 | 406 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 407 | def test_commit_and_tag(tmpdir, vcs): 408 | tmpdir.chdir() 409 | check_call([vcs, "init"]) 410 | tmpdir.join("VERSION").write("47.1.1") 411 | check_call([vcs, "add", "VERSION"]) 412 | check_call([vcs, "commit", "-m", "initial commit"]) 413 | 414 | main(['patch', '--current-version', '47.1.1', '--commit', 'VERSION']) 415 | 416 | assert '47.1.2' == tmpdir.join("VERSION").read() 417 | 418 | log = check_output([vcs, "log", "-p"]).decode("utf-8") 419 | 420 | assert '-47.1.1' in log 421 | assert '+47.1.2' in log 422 | assert 'Bump version: 47.1.1 → 47.1.2' in log 423 | 424 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 425 | 426 | assert b'v47.1.2' not in tag_out 427 | 428 | main(['patch', '--current-version', '47.1.2', '--commit', '--tag', 'VERSION']) 429 | 430 | assert '47.1.3' == tmpdir.join("VERSION").read() 431 | 432 | log = check_output([vcs, "log", "-p"]) 433 | 434 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 435 | 436 | assert b'v47.1.3' in tag_out 437 | 438 | 439 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 440 | def test_commit_and_tag_with_configfile(tmpdir, vcs): 441 | tmpdir.chdir() 442 | 443 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion]\ncommit = True\ntag = True""") 444 | 445 | check_call([vcs, "init"]) 446 | tmpdir.join("VERSION").write("48.1.1") 447 | check_call([vcs, "add", "VERSION"]) 448 | check_call([vcs, "commit", "-m", "initial commit"]) 449 | 450 | main(['patch', '--current-version', '48.1.1', '--no-tag', 'VERSION']) 451 | 452 | assert '48.1.2' == tmpdir.join("VERSION").read() 453 | 454 | log = check_output([vcs, "log", "-p"]).decode("utf-8") 455 | 456 | assert '-48.1.1' in log 457 | assert '+48.1.2' in log 458 | assert 'Bump version: 48.1.1 → 48.1.2' in log 459 | 460 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 461 | 462 | assert b'v48.1.2' not in tag_out 463 | 464 | main(['patch', '--current-version', '48.1.2', 'VERSION']) 465 | 466 | assert '48.1.3' == tmpdir.join("VERSION").read() 467 | 468 | log = check_output([vcs, "log", "-p"]) 469 | 470 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 471 | 472 | assert b'v48.1.3' in tag_out 473 | 474 | 475 | @pytest.mark.parametrize(("vcs,config"), [ 476 | xfail_if_no_git(("git", """[bumpversion]\ncommit = True""")), 477 | xfail_if_no_hg(("hg", """[bumpversion]\ncommit = True""")), 478 | xfail_if_no_git(("git", """[bumpversion]\ncommit = True\ntag = False""")), 479 | xfail_if_no_hg(("hg", """[bumpversion]\ncommit = True\ntag = False""")), 480 | ]) 481 | def test_commit_and_not_tag_with_configfile(tmpdir, vcs, config): 482 | tmpdir.chdir() 483 | 484 | tmpdir.join(".bumpversion.cfg").write(config) 485 | 486 | check_call([vcs, "init"]) 487 | tmpdir.join("VERSION").write("48.1.1") 488 | check_call([vcs, "add", "VERSION"]) 489 | check_call([vcs, "commit", "-m", "initial commit"]) 490 | 491 | main(['patch', '--current-version', '48.1.1', 'VERSION']) 492 | 493 | assert '48.1.2' == tmpdir.join("VERSION").read() 494 | 495 | log = check_output([vcs, "log", "-p"]).decode("utf-8") 496 | 497 | assert '-48.1.1' in log 498 | assert '+48.1.2' in log 499 | assert 'Bump version: 48.1.1 → 48.1.2' in log 500 | 501 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 502 | 503 | assert b'v48.1.2' not in tag_out 504 | 505 | 506 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 507 | def test_commit_explicitly_false(tmpdir, vcs): 508 | tmpdir.chdir() 509 | 510 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion] 511 | current_version: 10.0.0 512 | commit = False 513 | tag = False""") 514 | 515 | check_call([vcs, "init"]) 516 | tmpdir.join("trackedfile").write("10.0.0") 517 | check_call([vcs, "add", "trackedfile"]) 518 | check_call([vcs, "commit", "-m", "initial commit"]) 519 | 520 | main(['patch', 'trackedfile']) 521 | 522 | assert '10.0.1' == tmpdir.join("trackedfile").read() 523 | 524 | log = check_output([vcs, "log", "-p"]).decode("utf-8") 525 | assert "10.0.1" not in log 526 | 527 | diff = check_output([vcs, "diff"]).decode("utf-8") 528 | assert "10.0.1" in diff 529 | 530 | 531 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 532 | def test_commit_configfile_true_cli_false_override(tmpdir, vcs): 533 | tmpdir.chdir() 534 | 535 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion] 536 | current_version: 27.0.0 537 | commit = True""") 538 | 539 | check_call([vcs, "init"]) 540 | tmpdir.join("dontcommitfile").write("27.0.0") 541 | check_call([vcs, "add", "dontcommitfile"]) 542 | check_call([vcs, "commit", "-m", "initial commit"]) 543 | 544 | main(['patch', '--no-commit', 'dontcommitfile']) 545 | 546 | assert '27.0.1' == tmpdir.join("dontcommitfile").read() 547 | 548 | log = check_output([vcs, "log", "-p"]).decode("utf-8") 549 | assert "27.0.1" not in log 550 | 551 | diff = check_output([vcs, "diff"]).decode("utf-8") 552 | assert "27.0.1" in diff 553 | 554 | 555 | def test_bump_version_ENV(tmpdir): 556 | 557 | tmpdir.join("on_jenkins").write("2.3.4") 558 | tmpdir.chdir() 559 | environ['BUILD_NUMBER'] = "567" 560 | main([ 561 | '--verbose', 562 | '--current-version', '2.3.4', 563 | '--parse', '(?P\d+)\.(?P\d+)\.(?P\d+).*', 564 | '--serialize', '{major}.{minor}.{patch}.pre{$BUILD_NUMBER}', 565 | 'patch', 566 | 'on_jenkins', 567 | ]) 568 | del environ['BUILD_NUMBER'] 569 | 570 | assert '2.3.5.pre567' == tmpdir.join("on_jenkins").read() 571 | 572 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git")]) 573 | def test_current_version_from_tag(tmpdir, vcs): 574 | # prepare 575 | tmpdir.join("update_from_tag").write("26.6.0") 576 | tmpdir.chdir() 577 | check_call(["git", "init"]) 578 | check_call(["git", "add", "update_from_tag"]) 579 | check_call(["git", "commit", "-m", "initial"]) 580 | check_call(["git", "tag", "v26.6.0"]) 581 | 582 | # don't give current-version, that should come from tag 583 | main(['patch', 'update_from_tag']) 584 | 585 | assert '26.6.1' == tmpdir.join("update_from_tag").read() 586 | 587 | 588 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git")]) 589 | def test_current_version_from_tag_written_to_config_file(tmpdir, vcs): 590 | # prepare 591 | tmpdir.join("updated_also_in_config_file").write("14.6.0") 592 | tmpdir.chdir() 593 | 594 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion]""") 595 | 596 | check_call(["git", "init"]) 597 | check_call(["git", "add", "updated_also_in_config_file"]) 598 | check_call(["git", "commit", "-m", "initial"]) 599 | check_call(["git", "tag", "v14.6.0"]) 600 | 601 | # don't give current-version, that should come from tag 602 | main([ 603 | 'patch', 604 | 'updated_also_in_config_file', 605 | '--commit', 606 | '--tag', 607 | ]) 608 | 609 | assert '14.6.1' == tmpdir.join("updated_also_in_config_file").read() 610 | assert '14.6.1' in tmpdir.join(".bumpversion.cfg").read() 611 | 612 | 613 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git")]) 614 | def test_distance_to_latest_tag_as_part_of_new_version(tmpdir, vcs): 615 | # prepare 616 | tmpdir.join("mysourcefile").write("19.6.0") 617 | tmpdir.chdir() 618 | 619 | check_call(["git", "init"]) 620 | check_call(["git", "add", "mysourcefile"]) 621 | check_call(["git", "commit", "-m", "initial"]) 622 | check_call(["git", "tag", "v19.6.0"]) 623 | check_call(["git", "commit", "--allow-empty", "-m", "Just a commit 1"]) 624 | check_call(["git", "commit", "--allow-empty", "-m", "Just a commit 2"]) 625 | check_call(["git", "commit", "--allow-empty", "-m", "Just a commit 3"]) 626 | 627 | # don't give current-version, that should come from tag 628 | main([ 629 | 'patch', 630 | '--parse', '(?P\d+)\.(?P\d+)\.(?P\d+).*', 631 | '--serialize', '{major}.{minor}.{patch}-pre{distance_to_latest_tag}', 632 | 'mysourcefile', 633 | ]) 634 | 635 | assert '19.6.1-pre3' == tmpdir.join("mysourcefile").read() 636 | 637 | 638 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git")]) 639 | def test_override_vcs_current_version(tmpdir, vcs): 640 | # prepare 641 | tmpdir.join("contains_actual_version").write("6.7.8") 642 | tmpdir.chdir() 643 | check_call(["git", "init"]) 644 | check_call(["git", "add", "contains_actual_version"]) 645 | check_call(["git", "commit", "-m", "initial"]) 646 | check_call(["git", "tag", "v6.7.8"]) 647 | 648 | # update file 649 | tmpdir.join("contains_actual_version").write("7.0.0") 650 | check_call(["git", "add", "contains_actual_version"]) 651 | 652 | # but forgot to tag or forgot to push --tags 653 | check_call(["git", "commit", "-m", "major release"]) 654 | 655 | # if we don't give current-version here we get 656 | # "AssertionError: Did not find string 6.7.8 in file contains_actual_version" 657 | main(['patch', '--current-version', '7.0.0', 'contains_actual_version']) 658 | 659 | assert '7.0.1' == tmpdir.join("contains_actual_version").read() 660 | 661 | 662 | def test_nonexisting_file(tmpdir): 663 | tmpdir.chdir() 664 | with pytest.raises(IOError): 665 | main(shlex_split("patch --current-version 1.2.0 --new-version 1.2.1 doesnotexist.txt")) 666 | 667 | 668 | def test_nonexisting_file(tmpdir): 669 | tmpdir.chdir() 670 | tmpdir.join("mysourcecode.txt").write("1.2.3") 671 | with pytest.raises(IOError): 672 | main(shlex_split("patch --current-version 1.2.3 mysourcecode.txt doesnotexist2.txt")) 673 | 674 | # first file is unchanged because second didn't exist 675 | assert '1.2.3' == tmpdir.join("mysourcecode.txt").read() 676 | 677 | 678 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git")]) 679 | def test_read_version_tags_only(tmpdir, vcs): 680 | # prepare 681 | tmpdir.join("update_from_tag").write("29.6.0") 682 | tmpdir.chdir() 683 | check_call(["git", "init"]) 684 | check_call(["git", "add", "update_from_tag"]) 685 | check_call(["git", "commit", "-m", "initial"]) 686 | check_call(["git", "tag", "v29.6.0"]) 687 | check_call(["git", "commit", "--allow-empty", "-m", "a commit"]) 688 | check_call(["git", "tag", "jenkins-deploy-myproject-2"]) 689 | 690 | # don't give current-version, that should come from tag 691 | main(['patch', 'update_from_tag']) 692 | 693 | assert '29.6.1' == tmpdir.join("update_from_tag").read() 694 | 695 | 696 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 697 | def test_tag_name(tmpdir, vcs): 698 | tmpdir.chdir() 699 | check_call([vcs, "init"]) 700 | tmpdir.join("VERSION").write("31.1.1") 701 | check_call([vcs, "add", "VERSION"]) 702 | check_call([vcs, "commit", "-m", "initial commit"]) 703 | 704 | main(['patch', '--current-version', '31.1.1', '--commit', '--tag', 'VERSION', '--tag-name', 'ReleasedVersion-{new_version}']) 705 | 706 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 707 | 708 | assert b'ReleasedVersion-31.1.2' in tag_out 709 | 710 | 711 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 712 | def test_message_from_config_file(tmpdir, capsys, vcs): 713 | tmpdir.chdir() 714 | check_call([vcs, "init"]) 715 | tmpdir.join("VERSION").write("400.0.0") 716 | check_call([vcs, "add", "VERSION"]) 717 | check_call([vcs, "commit", "-m", "initial commit"]) 718 | 719 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion] 720 | current_version: 400.0.0 721 | new_version: 401.0.0 722 | commit: True 723 | tag: True 724 | message: {current_version} was old, {new_version} is new 725 | tag_name: from-{current_version}-to-{new_version}""") 726 | 727 | main(['major', 'VERSION']) 728 | 729 | log = check_output([vcs, "log", "-p"]) 730 | 731 | assert b'400.0.0 was old, 401.0.0 is new' in log 732 | 733 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 734 | 735 | assert b'from-400.0.0-to-401.0.0' in tag_out 736 | 737 | config_parser_handles_utf8 = True 738 | try: 739 | import configparser 740 | except ImportError: 741 | config_parser_handles_utf8 = False 742 | 743 | 744 | @pytest.mark.xfail(not config_parser_handles_utf8, 745 | reason="old ConfigParser uses non-utf-8-strings internally") 746 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 747 | def test_utf8_message_from_config_file(tmpdir, capsys, vcs): 748 | tmpdir.chdir() 749 | check_call([vcs, "init"]) 750 | tmpdir.join("VERSION").write("500.0.0") 751 | check_call([vcs, "add", "VERSION"]) 752 | check_call([vcs, "commit", "-m", "initial commit"]) 753 | 754 | initial_config = """[bumpversion] 755 | current_version = 500.0.0 756 | commit = True 757 | message = Nová verze: {current_version} ☃, {new_version} ☀ 758 | 759 | """ 760 | 761 | tmpdir.join(".bumpversion.cfg").write(initial_config.encode('utf-8'), mode='wb') 762 | main(['major', 'VERSION']) 763 | log = check_output([vcs, "log", "-p"]) 764 | expected_new_config = initial_config.replace('500', '501') 765 | assert expected_new_config.encode('utf-8') == tmpdir.join(".bumpversion.cfg").read(mode='rb') 766 | 767 | 768 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 769 | def test_utf8_message_from_config_file(tmpdir, capsys, vcs): 770 | tmpdir.chdir() 771 | check_call([vcs, "init"]) 772 | tmpdir.join("VERSION").write("10.10.0") 773 | check_call([vcs, "add", "VERSION"]) 774 | check_call([vcs, "commit", "-m", "initial commit"]) 775 | 776 | initial_config = """[bumpversion] 777 | current_version = 10.10.0 778 | commit = True 779 | message = [{now}] [{utcnow} {utcnow:%YXX%mYY%d}] 780 | 781 | """ 782 | tmpdir.join(".bumpversion.cfg").write(initial_config) 783 | 784 | main(['major', 'VERSION']) 785 | 786 | log = check_output([vcs, "log", "-p"]) 787 | 788 | assert b'[20' in log 789 | assert b'] [' in log 790 | assert b'XX' in log 791 | assert b'YY' in log 792 | 793 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 794 | def test_commit_and_tag_from_below_vcs_root(tmpdir, vcs, monkeypatch): 795 | tmpdir.chdir() 796 | check_call([vcs, "init"]) 797 | tmpdir.join("VERSION").write("30.0.3") 798 | check_call([vcs, "add", "VERSION"]) 799 | check_call([vcs, "commit", "-m", "initial commit"]) 800 | 801 | tmpdir.mkdir("subdir") 802 | monkeypatch.chdir(tmpdir.join("subdir")) 803 | 804 | main(['major', '--current-version', '30.0.3', '--commit', '../VERSION']) 805 | 806 | assert '31.0.0' == tmpdir.join("VERSION").read() 807 | 808 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 809 | def test_non_vcs_operations_if_vcs_is_not_installed(tmpdir, vcs, monkeypatch): 810 | 811 | monkeypatch.setenv("PATH", "") 812 | 813 | tmpdir.chdir() 814 | tmpdir.join("VERSION").write("31.0.3") 815 | 816 | main(['major', '--current-version', '31.0.3', 'VERSION']) 817 | 818 | assert '32.0.0' == tmpdir.join("VERSION").read() 819 | 820 | def test_multiple_serialize_threepart(tmpdir): 821 | tmpdir.join("fileA").write("Version: 0.9") 822 | tmpdir.chdir() 823 | main([ 824 | '--current-version', 'Version: 0.9', 825 | '--parse', 'Version:\ (?P\d+)(\.(?P\d+)(\.(?P\d+))?)?', 826 | '--serialize', 'Version: {major}.{minor}.{patch}', 827 | '--serialize', 'Version: {major}.{minor}', 828 | '--serialize', 'Version: {major}', 829 | '--verbose', 830 | 'major', 831 | 'fileA' 832 | ]) 833 | 834 | assert 'Version: 1' == tmpdir.join("fileA").read() 835 | 836 | def test_multiple_serialize_twopart(tmpdir): 837 | tmpdir.join("fileB").write("0.9") 838 | tmpdir.chdir() 839 | main([ 840 | '--current-version', '0.9', 841 | '--parse', '(?P\d+)\.(?P\d+)(\.(?P\d+))?', 842 | '--serialize', '{major}.{minor}.{patch}', 843 | '--serialize', '{major}.{minor}', 844 | 'minor', 845 | 'fileB' 846 | ]) 847 | 848 | assert '0.10' == tmpdir.join("fileB").read() 849 | 850 | def test_multiple_serialize_twopart_patch(tmpdir): 851 | tmpdir.join("fileC").write("0.7") 852 | tmpdir.chdir() 853 | main([ 854 | '--current-version', '0.7', 855 | '--parse', '(?P\d+)\.(?P\d+)(\.(?P\d+))?', 856 | '--serialize', '{major}.{minor}.{patch}', 857 | '--serialize', '{major}.{minor}', 858 | 'patch', 859 | 'fileC' 860 | ]) 861 | 862 | assert '0.7.1' == tmpdir.join("fileC").read() 863 | 864 | def test_multiple_serialize_twopart_patch_configfile(tmpdir): 865 | tmpdir.join("fileD").write("0.6") 866 | tmpdir.chdir() 867 | 868 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion] 869 | files = fileD 870 | current_version = 0.6 871 | serialize = 872 | {major}.{minor}.{patch} 873 | {major}.{minor} 874 | parse = (?P\d+)\.(?P\d+)(\.(?P\d+))? 875 | """) 876 | 877 | main(['patch']) 878 | 879 | assert '0.6.1' == tmpdir.join("fileD").read() 880 | 881 | 882 | def test_log_no_config_file_info_message(tmpdir, capsys): 883 | tmpdir.chdir() 884 | 885 | tmpdir.join("blargh.txt").write("1.0.0") 886 | 887 | with mock.patch("bumpversion.logger") as logger: 888 | main(['--verbose', '--verbose', '--current-version', '1.0.0', 'patch', 'blargh.txt']) 889 | 890 | actual_log ="\n".join(_mock_calls_to_string(logger)[4:]) 891 | 892 | EXPECTED_LOG = dedent(""" 893 | info|Could not read config file at .bumpversion.cfg| 894 | info|Parsing version '1.0.0' using regexp '(?P\d+)\.(?P\d+)\.(?P\d+)'| 895 | info|Parsed the following values: major=1, minor=0, patch=0| 896 | info|Attempting to increment part 'patch'| 897 | info|Values are now: major=1, minor=0, patch=1| 898 | info|Parsing version '1.0.1' using regexp '(?P\d+)\.(?P\d+)\.(?P\d+)'| 899 | info|Parsed the following values: major=1, minor=0, patch=1| 900 | info|New version will be '1.0.1'| 901 | info|Asserting files blargh.txt contain the version string:| 902 | info|Found '1.0.0' in blargh.txt at line 0: 1.0.0| 903 | info|Changing file blargh.txt:| 904 | info|--- a/blargh.txt 905 | +++ b/blargh.txt 906 | @@ -1 +1 @@ 907 | -1.0.0 908 | +1.0.1| 909 | info|Would write to config file .bumpversion.cfg:| 910 | info|[bumpversion] 911 | current_version = 1.0.1 912 | 913 | | 914 | """).strip() 915 | 916 | assert actual_log == EXPECTED_LOG 917 | 918 | def test_log_parse_doesnt_parse_current_version(tmpdir): 919 | tmpdir.chdir() 920 | 921 | with mock.patch("bumpversion.logger") as logger: 922 | main(['--parse', 'xxx', '--current-version', '12', '--new-version', '13', 'patch']) 923 | 924 | actual_log ="\n".join(_mock_calls_to_string(logger)[4:]) 925 | 926 | EXPECTED_LOG = dedent(""" 927 | info|Could not read config file at .bumpversion.cfg| 928 | info|Parsing version '12' using regexp 'xxx'| 929 | warn|Evaluating 'parse' option: 'xxx' does not parse current version '12'| 930 | info|Parsing version '13' using regexp 'xxx'| 931 | warn|Evaluating 'parse' option: 'xxx' does not parse current version '13'| 932 | info|New version will be '13'| 933 | info|Asserting files contain the version string:| 934 | info|Would write to config file .bumpversion.cfg:| 935 | info|[bumpversion] 936 | current_version = 13 937 | 938 | | 939 | """).strip() 940 | 941 | assert actual_log == EXPECTED_LOG 942 | 943 | def test_log_invalid_regex_exit(tmpdir): 944 | tmpdir.chdir() 945 | 946 | with pytest.raises(SystemExit): 947 | with mock.patch("bumpversion.logger") as logger: 948 | main(['--parse', '*kittens*', '--current-version', '12', '--new-version', '13', 'patch']) 949 | 950 | actual_log ="\n".join(_mock_calls_to_string(logger)[4:]) 951 | 952 | EXPECTED_LOG = dedent(""" 953 | info|Could not read config file at .bumpversion.cfg| 954 | error|--parse '*kittens*' is not a valid regex| 955 | """).strip() 956 | 957 | assert actual_log == EXPECTED_LOG 958 | 959 | def test_complex_info_logging(tmpdir, capsys): 960 | tmpdir.join("fileE").write("0.4") 961 | tmpdir.chdir() 962 | 963 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 964 | [bumpversion] 965 | files = fileE 966 | current_version = 0.4 967 | serialize = 968 | {major}.{minor}.{patch} 969 | {major}.{minor} 970 | parse = (?P\d+)\.(?P\d+)(\.(?P\d+))? 971 | """).strip()) 972 | 973 | with mock.patch("bumpversion.logger") as logger: 974 | main(['patch']) 975 | 976 | # beware of the trailing space (" ") after "serialize =": 977 | EXPECTED_LOG = dedent(""" 978 | info|Reading config file .bumpversion.cfg:| 979 | info|[bumpversion] 980 | files = fileE 981 | current_version = 0.4 982 | serialize = 983 | {major}.{minor}.{patch} 984 | {major}.{minor} 985 | parse = (?P\d+)\.(?P\d+)(\.(?P\d+))?| 986 | info|Parsing version '0.4' using regexp '(?P\d+)\.(?P\d+)(\.(?P\d+))?'| 987 | info|Parsed the following values: major=0, minor=4, patch=0| 988 | info|Attempting to increment part 'patch'| 989 | info|Values are now: major=0, minor=4, patch=1| 990 | info|Parsing version '0.4.1' using regexp '(?P\d+)\.(?P\d+)(\.(?P\d+))?'| 991 | info|Parsed the following values: major=0, minor=4, patch=1| 992 | info|New version will be '0.4.1'| 993 | info|Asserting files fileE contain the version string:| 994 | info|Found '0.4' in fileE at line 0: 0.4| 995 | info|Changing file fileE:| 996 | info|--- a/fileE 997 | +++ b/fileE 998 | @@ -1 +1 @@ 999 | -0.4 1000 | +0.4.1| 1001 | info|Writing to config file .bumpversion.cfg:| 1002 | info|[bumpversion] 1003 | files = fileE 1004 | current_version = 0.4.1 1005 | serialize = 1006 | {major}.{minor}.{patch} 1007 | {major}.{minor} 1008 | parse = (?P\d+)\.(?P\d+)(\.(?P\d+))? 1009 | 1010 | | 1011 | """).strip() 1012 | 1013 | actual_log ="\n".join(_mock_calls_to_string(logger)[4:]) 1014 | 1015 | assert actual_log == EXPECTED_LOG 1016 | 1017 | 1018 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 1019 | def test_subjunctive_dry_run_logging(tmpdir, vcs): 1020 | tmpdir.join("dont_touch_me.txt").write("0.8") 1021 | tmpdir.chdir() 1022 | 1023 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1024 | [bumpversion] 1025 | files = dont_touch_me.txt 1026 | current_version = 0.8 1027 | commit = True 1028 | tag = True 1029 | serialize = 1030 | {major}.{minor}.{patch} 1031 | {major}.{minor} 1032 | parse = (?P\d+)\.(?P\d+)(\.(?P\d+))?""" 1033 | ).strip()) 1034 | 1035 | check_call([vcs, "init"]) 1036 | check_call([vcs, "add", "dont_touch_me.txt"]) 1037 | check_call([vcs, "commit", "-m", "initial commit"]) 1038 | 1039 | with mock.patch("bumpversion.logger") as logger: 1040 | main(['patch', '--dry-run']) 1041 | 1042 | # beware of the trailing space (" ") after "serialize =": 1043 | EXPECTED_LOG = dedent(""" 1044 | info|Reading config file .bumpversion.cfg:| 1045 | info|[bumpversion] 1046 | files = dont_touch_me.txt 1047 | current_version = 0.8 1048 | commit = True 1049 | tag = True 1050 | serialize = 1051 | {major}.{minor}.{patch} 1052 | {major}.{minor} 1053 | parse = (?P\d+)\.(?P\d+)(\.(?P\d+))?| 1054 | info|Parsing version '0.8' using regexp '(?P\d+)\.(?P\d+)(\.(?P\d+))?'| 1055 | info|Parsed the following values: major=0, minor=8, patch=0| 1056 | info|Attempting to increment part 'patch'| 1057 | info|Values are now: major=0, minor=8, patch=1| 1058 | info|Dry run active, won't touch any files.| 1059 | info|Parsing version '0.8.1' using regexp '(?P\d+)\.(?P\d+)(\.(?P\d+))?'| 1060 | info|Parsed the following values: major=0, minor=8, patch=1| 1061 | info|New version will be '0.8.1'| 1062 | info|Asserting files dont_touch_me.txt contain the version string:| 1063 | info|Found '0.8' in dont_touch_me.txt at line 0: 0.8| 1064 | info|Would change file dont_touch_me.txt:| 1065 | info|--- a/dont_touch_me.txt 1066 | +++ b/dont_touch_me.txt 1067 | @@ -1 +1 @@ 1068 | -0.8 1069 | +0.8.1| 1070 | info|Would write to config file .bumpversion.cfg:| 1071 | info|[bumpversion] 1072 | files = dont_touch_me.txt 1073 | current_version = 0.8.1 1074 | commit = True 1075 | tag = True 1076 | serialize = 1077 | {major}.{minor}.{patch} 1078 | {major}.{minor} 1079 | parse = (?P\d+)\.(?P\d+)(\.(?P\d+))? 1080 | 1081 | | 1082 | info|Would prepare Git commit| 1083 | info|Would add changes in file 'dont_touch_me.txt' to Git| 1084 | info|Would add changes in file '.bumpversion.cfg' to Git| 1085 | info|Would commit to Git with message 'Bump version: 0.8 \u2192 0.8.1'| 1086 | info|Would tag 'v0.8.1' in Git| 1087 | """).strip() 1088 | 1089 | if vcs == "hg": 1090 | EXPECTED_LOG = EXPECTED_LOG.replace("Git", "Mercurial") 1091 | 1092 | actual_log ="\n".join(_mock_calls_to_string(logger)[4:]) 1093 | 1094 | assert actual_log == EXPECTED_LOG 1095 | 1096 | 1097 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 1098 | def test_log_commitmessage_if_no_commit_tag_but_usable_vcs(tmpdir, vcs): 1099 | tmpdir.join("please_touch_me.txt").write("0.3.3") 1100 | tmpdir.chdir() 1101 | 1102 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1103 | [bumpversion] 1104 | files = please_touch_me.txt 1105 | current_version = 0.3.3 1106 | commit = False 1107 | tag = False 1108 | """).strip()) 1109 | 1110 | check_call([vcs, "init"]) 1111 | check_call([vcs, "add", "please_touch_me.txt"]) 1112 | check_call([vcs, "commit", "-m", "initial commit"]) 1113 | 1114 | with mock.patch("bumpversion.logger") as logger: 1115 | main(['patch']) 1116 | 1117 | # beware of the trailing space (" ") after "serialize =": 1118 | EXPECTED_LOG = dedent(""" 1119 | info|Reading config file .bumpversion.cfg:| 1120 | info|[bumpversion] 1121 | files = please_touch_me.txt 1122 | current_version = 0.3.3 1123 | commit = False 1124 | tag = False| 1125 | info|Parsing version '0.3.3' using regexp '(?P\d+)\.(?P\d+)\.(?P\d+)'| 1126 | info|Parsed the following values: major=0, minor=3, patch=3| 1127 | info|Attempting to increment part 'patch'| 1128 | info|Values are now: major=0, minor=3, patch=4| 1129 | info|Parsing version '0.3.4' using regexp '(?P\d+)\.(?P\d+)\.(?P\d+)'| 1130 | info|Parsed the following values: major=0, minor=3, patch=4| 1131 | info|New version will be '0.3.4'| 1132 | info|Asserting files please_touch_me.txt contain the version string:| 1133 | info|Found '0.3.3' in please_touch_me.txt at line 0: 0.3.3| 1134 | info|Changing file please_touch_me.txt:| 1135 | info|--- a/please_touch_me.txt 1136 | +++ b/please_touch_me.txt 1137 | @@ -1 +1 @@ 1138 | -0.3.3 1139 | +0.3.4| 1140 | info|Writing to config file .bumpversion.cfg:| 1141 | info|[bumpversion] 1142 | files = please_touch_me.txt 1143 | current_version = 0.3.4 1144 | commit = False 1145 | tag = False 1146 | 1147 | | 1148 | info|Would prepare Git commit| 1149 | info|Would add changes in file 'please_touch_me.txt' to Git| 1150 | info|Would add changes in file '.bumpversion.cfg' to Git| 1151 | info|Would commit to Git with message 'Bump version: 0.3.3 \u2192 0.3.4'| 1152 | info|Would tag 'v0.3.4' in Git| 1153 | """).strip() 1154 | 1155 | if vcs == "hg": 1156 | EXPECTED_LOG = EXPECTED_LOG.replace("Git", "Mercurial") 1157 | 1158 | actual_log ="\n".join(_mock_calls_to_string(logger)[4:]) 1159 | 1160 | assert actual_log == EXPECTED_LOG 1161 | 1162 | 1163 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 1164 | def test_listing(tmpdir, vcs): 1165 | tmpdir.join("please_list_me.txt").write("0.5.5") 1166 | tmpdir.chdir() 1167 | 1168 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1169 | [bumpversion] 1170 | files = please_list_me.txt 1171 | current_version = 0.5.5 1172 | commit = False 1173 | tag = False 1174 | """).strip()) 1175 | 1176 | check_call([vcs, "init"]) 1177 | check_call([vcs, "add", "please_list_me.txt"]) 1178 | check_call([vcs, "commit", "-m", "initial commit"]) 1179 | 1180 | with mock.patch("bumpversion.logger_list") as logger: 1181 | main(['--list', 'patch']) 1182 | 1183 | EXPECTED_LOG = dedent(""" 1184 | info|files=please_list_me.txt| 1185 | info|current_version=0.5.5| 1186 | info|commit=False| 1187 | info|tag=False| 1188 | info|new_version=0.5.6| 1189 | """).strip() 1190 | 1191 | if vcs == "hg": 1192 | EXPECTED_LOG = EXPECTED_LOG.replace("Git", "Mercurial") 1193 | 1194 | actual_log ="\n".join(_mock_calls_to_string(logger)[3:]) 1195 | 1196 | assert actual_log == EXPECTED_LOG 1197 | 1198 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git"), xfail_if_no_hg("hg")]) 1199 | def test_no_list_no_stdout(tmpdir, vcs): 1200 | tmpdir.join("please_dont_list_me.txt").write("0.5.5") 1201 | tmpdir.chdir() 1202 | 1203 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1204 | [bumpversion] 1205 | files = please_dont_list_me.txt 1206 | current_version = 0.5.5 1207 | commit = False 1208 | tag = False 1209 | """).strip()) 1210 | 1211 | check_call([vcs, "init"]) 1212 | check_call([vcs, "add", "please_dont_list_me.txt"]) 1213 | check_call([vcs, "commit", "-m", "initial commit"]) 1214 | 1215 | out = check_output( 1216 | 'bumpversion patch; exit 0', 1217 | shell=True, 1218 | stderr=subprocess.STDOUT 1219 | ).decode('utf-8') 1220 | 1221 | assert out == "" 1222 | 1223 | def test_bump_non_numeric_parts(tmpdir, capsys): 1224 | tmpdir.join("with_prereleases.txt").write("1.5.dev") 1225 | tmpdir.chdir() 1226 | 1227 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1228 | [bumpversion] 1229 | files = with_prereleases.txt 1230 | current_version = 1.5.dev 1231 | parse = (?P\d+)\.(?P\d+)(\.(?P[a-z]+))? 1232 | serialize = 1233 | {major}.{minor}.{release} 1234 | {major}.{minor} 1235 | 1236 | [bumpversion:part:release] 1237 | optional_value = gamma 1238 | values = 1239 | dev 1240 | gamma 1241 | """).strip()) 1242 | 1243 | main(['release', '--verbose']) 1244 | 1245 | assert '1.5' == tmpdir.join("with_prereleases.txt").read() 1246 | 1247 | main(['minor', '--verbose']) 1248 | 1249 | assert '1.6.dev' == tmpdir.join("with_prereleases.txt").read() 1250 | 1251 | def test_optional_value_from_documentation(tmpdir): 1252 | 1253 | tmpdir.join("optional_value_fromdoc.txt").write("1.alpha") 1254 | tmpdir.chdir() 1255 | 1256 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1257 | [bumpversion] 1258 | current_version = 1.alpha 1259 | parse = (?P\d+)(\.(?P.*))?(\.)? 1260 | serialize = 1261 | {num}.{release} 1262 | {num} 1263 | 1264 | [bumpversion:part:release] 1265 | optional_value = gamma 1266 | values = 1267 | alpha 1268 | beta 1269 | gamma 1270 | 1271 | [bumpversion:file:optional_value_fromdoc.txt] 1272 | """).strip()) 1273 | 1274 | main(['release', '--verbose']) 1275 | 1276 | assert '1.beta' == tmpdir.join("optional_value_fromdoc.txt").read() 1277 | 1278 | main(['release', '--verbose']) 1279 | 1280 | assert '1' == tmpdir.join("optional_value_fromdoc.txt").read() 1281 | 1282 | def test_python_prerelease_release_postrelease(tmpdir, capsys): 1283 | tmpdir.join("python386.txt").write("1.0a") 1284 | tmpdir.chdir() 1285 | 1286 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1287 | [bumpversion] 1288 | files = python386.txt 1289 | current_version = 1.0a 1290 | 1291 | # adapted from http://legacy.python.org/dev/peps/pep-0386/#the-new-versioning-algorithm 1292 | parse = ^ 1293 | (?P\d+)\.(?P\d+) # minimum 'N.N' 1294 | (?: 1295 | (?P[abc]|rc|dev) # 'a' = alpha, 'b' = beta 1296 | # 'c' or 'rc' = release candidate 1297 | (?: 1298 | (?P\d+(?:\.\d+)*) 1299 | )? 1300 | )? 1301 | (?P(\.post(?P\d+))?(\.dev(?P\d+))?)? 1302 | 1303 | serialize = 1304 | {major}.{minor}{prerel}{prerelversion} 1305 | {major}.{minor}{prerel} 1306 | {major}.{minor} 1307 | 1308 | [bumpversion:part:prerel] 1309 | optional_value = d 1310 | values = 1311 | dev 1312 | a 1313 | b 1314 | c 1315 | rc 1316 | d 1317 | """)) 1318 | 1319 | def file_content(): 1320 | return tmpdir.join("python386.txt").read() 1321 | 1322 | main(['prerel']) 1323 | assert '1.0b' == file_content() 1324 | 1325 | main(['prerelversion']) 1326 | assert '1.0b1' == file_content() 1327 | 1328 | main(['prerelversion']) 1329 | assert '1.0b2' == file_content() 1330 | 1331 | main(['prerel']) # now it's 1.0c 1332 | main(['prerel']) 1333 | assert '1.0rc' == file_content() 1334 | 1335 | main(['prerel']) 1336 | assert '1.0' == file_content() 1337 | 1338 | main(['minor']) 1339 | assert '1.1dev' == file_content() 1340 | 1341 | main(['prerel', '--verbose']) 1342 | assert '1.1a' == file_content() 1343 | 1344 | def test_part_first_value(tmpdir): 1345 | 1346 | tmpdir.join("the_version.txt").write("0.9.4") 1347 | tmpdir.chdir() 1348 | 1349 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1350 | [bumpversion] 1351 | files = the_version.txt 1352 | current_version = 0.9.4 1353 | 1354 | [bumpversion:part:minor] 1355 | first_value = 1 1356 | """)) 1357 | 1358 | main(['major', '--verbose']) 1359 | 1360 | assert '1.1.0' == tmpdir.join("the_version.txt").read() 1361 | 1362 | def test_multi_file_configuration(tmpdir, capsys): 1363 | tmpdir.join("FULL_VERSION.txt").write("1.0.3") 1364 | tmpdir.join("MAJOR_VERSION.txt").write("1") 1365 | 1366 | tmpdir.chdir() 1367 | 1368 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1369 | [bumpversion] 1370 | current_version = 1.0.3 1371 | 1372 | [bumpversion:file:FULL_VERSION.txt] 1373 | 1374 | [bumpversion:file:MAJOR_VERSION.txt] 1375 | serialize = {major} 1376 | parse = \d+ 1377 | 1378 | """)) 1379 | 1380 | main(['major', '--verbose']) 1381 | assert '2.0.0' in tmpdir.join("FULL_VERSION.txt").read() 1382 | assert '2' in tmpdir.join("MAJOR_VERSION.txt").read() 1383 | 1384 | main(['patch']) 1385 | assert '2.0.1' in tmpdir.join("FULL_VERSION.txt").read() 1386 | assert '2' in tmpdir.join("MAJOR_VERSION.txt").read() 1387 | 1388 | 1389 | def test_multi_file_configuration2(tmpdir, capsys): 1390 | tmpdir.join("setup.cfg").write("1.6.6") 1391 | tmpdir.join("README.txt").write("MyAwesomeSoftware(TM) v1.6") 1392 | tmpdir.join("BUILDNUMBER").write("1.6.6+joe+38943") 1393 | 1394 | tmpdir.chdir() 1395 | 1396 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1397 | [bumpversion] 1398 | current_version = 1.6.6 1399 | 1400 | [something:else] 1401 | 1402 | [foo] 1403 | 1404 | [bumpversion:file:setup.cfg] 1405 | 1406 | [bumpversion:file:README.txt] 1407 | parse = '(?P\d+)\.(?P\d+)' 1408 | serialize = 1409 | {major}.{minor} 1410 | 1411 | [bumpversion:file:BUILDNUMBER] 1412 | serialize = 1413 | {major}.{minor}.{patch}+{$USER}+{$BUILDNUMBER} 1414 | 1415 | """)) 1416 | 1417 | environ['BUILDNUMBER'] = "38944" 1418 | environ['USER'] = "bob" 1419 | main(['minor', '--verbose']) 1420 | del environ['BUILDNUMBER'] 1421 | del environ['USER'] 1422 | 1423 | assert '1.7.0' in tmpdir.join("setup.cfg").read() 1424 | assert 'MyAwesomeSoftware(TM) v1.7' in tmpdir.join("README.txt").read() 1425 | assert '1.7.0+bob+38944' in tmpdir.join("BUILDNUMBER").read() 1426 | 1427 | environ['BUILDNUMBER'] = "38945" 1428 | environ['USER'] = "bob" 1429 | main(['patch', '--verbose']) 1430 | del environ['BUILDNUMBER'] 1431 | del environ['USER'] 1432 | 1433 | assert '1.7.1' in tmpdir.join("setup.cfg").read() 1434 | assert 'MyAwesomeSoftware(TM) v1.7' in tmpdir.join("README.txt").read() 1435 | assert '1.7.1+bob+38945' in tmpdir.join("BUILDNUMBER").read() 1436 | 1437 | 1438 | def test_search_replace_to_avoid_updating_unconcerned_lines(tmpdir, capsys): 1439 | tmpdir.chdir() 1440 | 1441 | tmpdir.join("requirements.txt").write("Django>=1.5.6,<1.6\nMyProject==1.5.6") 1442 | 1443 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1444 | [bumpversion] 1445 | current_version = 1.5.6 1446 | 1447 | [bumpversion:file:requirements.txt] 1448 | search = MyProject=={current_version} 1449 | replace = MyProject=={new_version} 1450 | """).strip()) 1451 | 1452 | with mock.patch("bumpversion.logger") as logger: 1453 | main(['minor', '--verbose']) 1454 | 1455 | # beware of the trailing space (" ") after "serialize =": 1456 | EXPECTED_LOG = dedent(""" 1457 | info|Reading config file .bumpversion.cfg:| 1458 | info|[bumpversion] 1459 | current_version = 1.5.6 1460 | 1461 | [bumpversion:file:requirements.txt] 1462 | search = MyProject=={current_version} 1463 | replace = MyProject=={new_version}| 1464 | info|Parsing version '1.5.6' using regexp '(?P\d+)\.(?P\d+)\.(?P\d+)'| 1465 | info|Parsed the following values: major=1, minor=5, patch=6| 1466 | info|Attempting to increment part 'minor'| 1467 | info|Values are now: major=1, minor=6, patch=0| 1468 | info|Parsing version '1.6.0' using regexp '(?P\d+)\.(?P\d+)\.(?P\d+)'| 1469 | info|Parsed the following values: major=1, minor=6, patch=0| 1470 | info|New version will be '1.6.0'| 1471 | info|Asserting files requirements.txt contain the version string:| 1472 | info|Found 'MyProject==1.5.6' in requirements.txt at line 1: MyProject==1.5.6| 1473 | info|Changing file requirements.txt:| 1474 | info|--- a/requirements.txt 1475 | +++ b/requirements.txt 1476 | @@ -1,2 +1,2 @@ 1477 | Django>=1.5.6,<1.6 1478 | -MyProject==1.5.6 1479 | +MyProject==1.6.0| 1480 | info|Writing to config file .bumpversion.cfg:| 1481 | info|[bumpversion] 1482 | current_version = 1.6.0 1483 | 1484 | [bumpversion:file:requirements.txt] 1485 | search = MyProject=={current_version} 1486 | replace = MyProject=={new_version} 1487 | 1488 | | 1489 | """).strip() 1490 | 1491 | actual_log ="\n".join(_mock_calls_to_string(logger)[4:]) 1492 | 1493 | assert actual_log == EXPECTED_LOG 1494 | 1495 | assert 'MyProject==1.6.0' in tmpdir.join("requirements.txt").read() 1496 | assert 'Django>=1.5.6' in tmpdir.join("requirements.txt").read() 1497 | 1498 | 1499 | def test_search_replace_expanding_changelog(tmpdir, capsys): 1500 | 1501 | tmpdir.chdir() 1502 | 1503 | tmpdir.join("CHANGELOG.md").write(dedent(""" 1504 | My awesome software project Changelog 1505 | ===================================== 1506 | 1507 | Unreleased 1508 | ---------- 1509 | 1510 | * Some nice feature 1511 | * Some other nice feature 1512 | 1513 | Version v8.1.1 (2014-05-28) 1514 | --------------------------- 1515 | 1516 | * Another old nice feature 1517 | 1518 | """)) 1519 | 1520 | config_content = dedent(""" 1521 | [bumpversion] 1522 | current_version = 8.1.1 1523 | 1524 | [bumpversion:file:CHANGELOG.md] 1525 | search = 1526 | Unreleased 1527 | ---------- 1528 | replace = 1529 | Unreleased 1530 | ---------- 1531 | Version v{new_version} ({now:%Y-%m-%d}) 1532 | --------------------------- 1533 | """) 1534 | 1535 | tmpdir.join(".bumpversion.cfg").write(config_content) 1536 | 1537 | with mock.patch("bumpversion.logger") as logger: 1538 | main(['minor', '--verbose']) 1539 | 1540 | predate = dedent(''' 1541 | Unreleased 1542 | ---------- 1543 | Version v8.2.0 (20 1544 | ''').strip() 1545 | 1546 | postdate = dedent(''' 1547 | ) 1548 | --------------------------- 1549 | 1550 | * Some nice feature 1551 | * Some other nice feature 1552 | ''').strip() 1553 | 1554 | assert predate in tmpdir.join("CHANGELOG.md").read() 1555 | assert postdate in tmpdir.join("CHANGELOG.md").read() 1556 | 1557 | def test_search_replace_cli(tmpdir, capsys): 1558 | tmpdir.join("file89").write("My birthday: 3.5.98\nCurrent version: 3.5.98") 1559 | tmpdir.chdir() 1560 | main([ 1561 | '--current-version', '3.5.98', 1562 | '--search', 'Current version: {current_version}', 1563 | '--replace', 'Current version: {new_version}', 1564 | 'minor', 1565 | 'file89', 1566 | ]) 1567 | 1568 | assert 'My birthday: 3.5.98\nCurrent version: 3.6.0' == tmpdir.join("file89").read() 1569 | 1570 | import warnings 1571 | 1572 | def test_deprecation_warning_files_in_global_configuration(tmpdir): 1573 | tmpdir.chdir() 1574 | 1575 | tmpdir.join("fileX").write("3.2.1") 1576 | tmpdir.join("fileY").write("3.2.1") 1577 | tmpdir.join("fileZ").write("3.2.1") 1578 | 1579 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion] 1580 | current_version = 3.2.1 1581 | files = fileX fileY fileZ 1582 | """) 1583 | 1584 | bumpversion.__warningregistry__.clear() 1585 | warnings.resetwarnings() 1586 | warnings.simplefilter('always') 1587 | with warnings.catch_warnings(record=True) as recwarn: 1588 | main(['patch']) 1589 | 1590 | w = recwarn.pop() 1591 | assert issubclass(w.category, PendingDeprecationWarning) 1592 | assert "'files =' configuration is will be deprecated, please use" in str(w.message) 1593 | 1594 | 1595 | def test_deprecation_warning_multiple_files_cli(tmpdir): 1596 | tmpdir.chdir() 1597 | 1598 | tmpdir.join("fileA").write("1.2.3") 1599 | tmpdir.join("fileB").write("1.2.3") 1600 | tmpdir.join("fileC").write("1.2.3") 1601 | 1602 | bumpversion.__warningregistry__.clear() 1603 | warnings.resetwarnings() 1604 | warnings.simplefilter('always') 1605 | with warnings.catch_warnings(record=True) as recwarn: 1606 | main(['--current-version', '1.2.3', 'patch', 'fileA', 'fileB', 'fileC']) 1607 | 1608 | w = recwarn.pop() 1609 | assert issubclass(w.category, PendingDeprecationWarning) 1610 | assert 'Giving multiple files on the command line will be deprecated' in str(w.message) 1611 | 1612 | 1613 | def test_file_specific_config_inherits_parse_serialize(tmpdir): 1614 | 1615 | tmpdir.chdir() 1616 | 1617 | tmpdir.join("todays_icecream").write("14-chocolate") 1618 | tmpdir.join("todays_cake").write("14-chocolate") 1619 | 1620 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1621 | [bumpversion] 1622 | current_version = 14-chocolate 1623 | parse = (?P\d+)(\-(?P[a-z]+))? 1624 | serialize = 1625 | {major}-{flavor} 1626 | {major} 1627 | 1628 | [bumpversion:file:todays_icecream] 1629 | serialize = 1630 | {major}-{flavor} 1631 | 1632 | [bumpversion:file:todays_cake] 1633 | 1634 | [bumpversion:part:flavor] 1635 | values = 1636 | vanilla 1637 | chocolate 1638 | strawberry 1639 | """)) 1640 | 1641 | main(['flavor']) 1642 | 1643 | assert '14-strawberry' == tmpdir.join("todays_cake").read() 1644 | assert '14-strawberry' == tmpdir.join("todays_icecream").read() 1645 | 1646 | main(['major']) 1647 | 1648 | assert '15-vanilla' == tmpdir.join("todays_icecream").read() 1649 | assert '15' == tmpdir.join("todays_cake").read() 1650 | 1651 | 1652 | def test_multiline_search_is_found(tmpdir): 1653 | 1654 | tmpdir.chdir() 1655 | 1656 | tmpdir.join("the_alphabet.txt").write(dedent(""" 1657 | A 1658 | B 1659 | C 1660 | """)) 1661 | 1662 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1663 | [bumpversion] 1664 | current_version = 9.8.7 1665 | 1666 | [bumpversion:file:the_alphabet.txt] 1667 | search = 1668 | A 1669 | B 1670 | C 1671 | replace = 1672 | A 1673 | B 1674 | C 1675 | {new_version} 1676 | """).strip()) 1677 | 1678 | main(['major']) 1679 | 1680 | assert dedent(""" 1681 | A 1682 | B 1683 | C 1684 | 10.0.0 1685 | """) == tmpdir.join("the_alphabet.txt").read() 1686 | 1687 | @xfail_if_old_configparser 1688 | def test_configparser_empty_lines_in_values(tmpdir): 1689 | 1690 | tmpdir.chdir() 1691 | 1692 | tmpdir.join("CHANGES.rst").write(dedent(""" 1693 | My changelog 1694 | ============ 1695 | 1696 | current 1697 | ------- 1698 | 1699 | """)) 1700 | 1701 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1702 | [bumpversion] 1703 | current_version = 0.4.1 1704 | 1705 | [bumpversion:file:CHANGES.rst] 1706 | search = 1707 | current 1708 | ------- 1709 | replace = current 1710 | ------- 1711 | 1712 | 1713 | {new_version} 1714 | ------- 1715 | """).strip()) 1716 | 1717 | main(['patch']) 1718 | assert dedent(""" 1719 | My changelog 1720 | ============ 1721 | current 1722 | ------- 1723 | 1724 | 1725 | 0.4.2 1726 | ------- 1727 | 1728 | """) == tmpdir.join("CHANGES.rst").read() 1729 | 1730 | 1731 | 1732 | @pytest.mark.parametrize(("vcs"), [xfail_if_no_git("git")]) 1733 | def test_regression_tag_name_with_hyphens(tmpdir, capsys, vcs): 1734 | tmpdir.chdir() 1735 | tmpdir.join("somesource.txt").write("2014.10.22") 1736 | check_call([vcs, "init"]) 1737 | check_call([vcs, "add", "somesource.txt"]) 1738 | check_call([vcs, "commit", "-m", "initial commit"]) 1739 | check_call([vcs, "tag", "very-unrelated-but-containing-lots-of-hyphens"]) 1740 | 1741 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1742 | [bumpversion] 1743 | current_version = 2014.10.22 1744 | """)) 1745 | 1746 | main(['patch', 'somesource.txt']) 1747 | 1748 | def test_regression_characters_after_last_label_serialize_string(tmpdir, capsys): 1749 | tmpdir.chdir() 1750 | tmpdir.join("bower.json").write(''' 1751 | { 1752 | "version": "1.0.0", 1753 | "dependency1": "1.0.0", 1754 | } 1755 | ''') 1756 | 1757 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1758 | [bumpversion] 1759 | current_version = 1.0.0 1760 | 1761 | [bumpversion:file:bower.json] 1762 | parse = "version": "(?P\d+)\.(?P\d+)\.(?P\d+)" 1763 | serialize = "version": "{major}.{minor}.{patch}" 1764 | """)) 1765 | 1766 | main(['patch', 'bower.json']) 1767 | 1768 | def test_regression_dont_touch_capitalization_of_keys_in_config(tmpdir, capsys): 1769 | 1770 | tmpdir.chdir() 1771 | tmpdir.join("setup.cfg").write(dedent(""" 1772 | [bumpversion] 1773 | current_version = 0.1.0 1774 | 1775 | [other] 1776 | DJANGO_SETTINGS = Value 1777 | """)) 1778 | 1779 | main(['patch']) 1780 | 1781 | assert dedent(""" 1782 | [bumpversion] 1783 | current_version = 0.1.1 1784 | 1785 | [other] 1786 | DJANGO_SETTINGS = Value 1787 | """).strip() == tmpdir.join("setup.cfg").read().strip() 1788 | 1789 | def test_regression_new_version_cli_in_files(tmpdir, capsys): 1790 | ''' 1791 | Reported here: https://github.com/peritus/bumpversion/issues/60 1792 | ''' 1793 | tmpdir.chdir() 1794 | tmpdir.join("myp___init__.py").write("__version__ = '0.7.2'") 1795 | tmpdir.chdir() 1796 | 1797 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1798 | [bumpversion] 1799 | current_version = 0.7.2 1800 | files = myp___init__.py 1801 | message = v{new_version} 1802 | tag_name = {new_version} 1803 | tag = true 1804 | commit = true 1805 | """).strip()) 1806 | 1807 | main("patch --allow-dirty --verbose --new-version 0.9.3".split(" ")) 1808 | 1809 | assert "__version__ = '0.9.3'" == tmpdir.join("myp___init__.py").read() 1810 | assert "current_version = 0.9.3" in tmpdir.join(".bumpversion.cfg").read() 1811 | 1812 | 1813 | class TestSplitArgsInOptionalAndPositional: 1814 | def test_all_optional(self): 1815 | params = ['--allow-dirty', '--verbose', '-n', '--tag-name', '"Tag"'] 1816 | positional, optional = \ 1817 | split_args_in_optional_and_positional(params) 1818 | 1819 | assert positional == [] 1820 | assert optional == params 1821 | 1822 | def test_all_positional(self): 1823 | params = ['minor', 'setup.py'] 1824 | positional, optional = \ 1825 | split_args_in_optional_and_positional(params) 1826 | 1827 | assert positional == params 1828 | assert optional == [] 1829 | 1830 | def test_no_args(self): 1831 | assert split_args_in_optional_and_positional([]) == \ 1832 | ([], []) 1833 | 1834 | def test_short_optionals(self): 1835 | params = ['-m', '"Commit"', '-n'] 1836 | positional, optional = \ 1837 | split_args_in_optional_and_positional(params) 1838 | 1839 | assert positional == [] 1840 | assert optional == params 1841 | 1842 | def test_1optional_2positional(self): 1843 | params = ['-n', 'major', 'setup.py'] 1844 | positional, optional = \ 1845 | split_args_in_optional_and_positional(params) 1846 | 1847 | assert positional == ['major', 'setup.py'] 1848 | assert optional == ['-n'] 1849 | 1850 | def test_2optional_1positional(self): 1851 | params = ['-n', '-m', '"Commit"', 'major'] 1852 | positional, optional = \ 1853 | split_args_in_optional_and_positional(params) 1854 | 1855 | assert positional == ['major'] 1856 | assert optional == ['-n', '-m', '"Commit"'] 1857 | 1858 | def test_2optional_mixed_2positionl(self): 1859 | params = ['--allow-dirty', '-m', '"Commit"', 'minor', 'setup.py'] 1860 | positional, optional = \ 1861 | split_args_in_optional_and_positional(params) 1862 | 1863 | assert positional == ['minor', 'setup.py'] 1864 | assert optional == ['--allow-dirty', '-m', '"Commit"'] 1865 | --------------------------------------------------------------------------------