├── .bumpversion.cfg ├── .dockerignore ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .pylintrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE.rst ├── MANIFEST.in ├── Makefile ├── README.md ├── RELATED.md ├── bumpversion ├── __init__.py ├── __main__.py ├── cli.py ├── exceptions.py ├── functions.py ├── utils.py ├── vcs.py └── version_part.py ├── docker-compose.yml ├── docs └── examples │ └── semantic-versioning │ ├── 1.0.0 │ ├── .bumpversion.cfg │ ├── VERSION.txt │ └── bump-patch │ │ ├── ARGUMENTS.txt │ │ └── VERSION.txt │ ├── 1.0.1-dev1 │ ├── .bumpversion.cfg │ ├── VERSION.txt │ ├── bump-build │ │ ├── ARGUMENTS.txt │ │ └── VERSION.txt │ └── bump-release │ │ ├── ARGUMENTS.txt │ │ └── VERSION.txt │ ├── 1.0.1-rc1 │ ├── .bumpversion.cfg │ ├── VERSION.txt │ ├── bump-build │ │ ├── ARGUMENTS.txt │ │ └── VERSION.txt │ └── bump-release │ │ ├── ARGUMENTS.txt │ │ └── VERSION.txt │ └── README.md ├── setup.cfg ├── setup.py ├── tests ├── test-cases │ └── use-shortest-pattern │ │ ├── arguments.txt │ │ ├── input │ │ ├── .bumpversion.cfg │ │ └── package.json │ │ ├── output │ │ ├── .bumpversion.cfg │ │ └── package.json │ │ └── run │ │ ├── .bumpversion.cfg │ │ └── package.json ├── test_cli.py ├── test_functions.py └── test_version_part.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | commit = True 3 | tag = True 4 | current_version = 1.0.2-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:CHANGELOG.md] 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 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore generated files 2 | **/*.pyc 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | defaults: 8 | run: 9 | shell: bash 10 | 11 | jobs: 12 | 13 | tox: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: 18 | - ubuntu-latest 19 | - windows-latest 20 | python-version: 21 | - "3.6" 22 | - "3.7" 23 | - "3.8" 24 | - "pypy-3.7" 25 | runs-on: ${{ matrix.os }} 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: actions/setup-python@v2 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | - name: Check git is working 32 | run: | 33 | git config --global user.email "bumpversion-test-git@github.actions" 34 | git config --global user.name "Testing Git on Travis CI" 35 | git --version 36 | git config --list 37 | - name: Check mercurial is working 38 | run: | 39 | echo -e '[ui]\nusername = Testing Mercurial on Travis CI ' > ~/.hgrc 40 | hg --version 41 | - name: Install test dependencies 42 | run: pip install tox tox-gh-actions 43 | - name: Run setup and tests as defined in tox.ini 44 | run: tox 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tox 2 | _test_run/ 3 | bin/ 4 | include/ 5 | lib/ 6 | .Python 7 | *.egg-info/ 8 | __pycache__ 9 | .cache 10 | dist 11 | 12 | # Byte-compiled / optimized / DLL files 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | 17 | # C extensions 18 | *.so 19 | 20 | # Distribution / packaging 21 | .Python 22 | build/ 23 | develop-eggs/ 24 | dist/ 25 | downloads/ 26 | eggs/ 27 | .eggs/ 28 | lib/ 29 | lib64/ 30 | parts/ 31 | sdist/ 32 | var/ 33 | wheels/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | MANIFEST 38 | 39 | # PyInstaller 40 | # Usually these files are written by a python script from a template 41 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 42 | *.manifest 43 | *.spec 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Unit test / coverage reports 50 | htmlcov/ 51 | .tox/ 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | .hypothesis/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | .static_storage/ 67 | .media/ 68 | local_settings.py 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # celery beat schedule file 90 | celerybeat-schedule 91 | 92 | # SageMath parsed files 93 | *.sage.py 94 | 95 | # Environments 96 | .env 97 | .venv 98 | env/ 99 | venv/ 100 | ENV/ 101 | env.bak/ 102 | venv.bak/ 103 | 104 | # Spyder project settings 105 | .spyderproject 106 | .spyproject 107 | 108 | .idea 109 | 110 | # Rope project settings 111 | .ropeproject 112 | 113 | # mkdocs documentation 114 | /site 115 | 116 | # mypy 117 | .mypy_cache/ 118 | 119 | # Swap 120 | [._]*.s[a-v][a-z] 121 | [._]*.sw[a-p] 122 | [._]s[a-v][a-z] 123 | [._]sw[a-p] 124 | 125 | # Session 126 | Session.vim 127 | 128 | # Temporary 129 | .netrwhist 130 | *~ 131 | 132 | # Auto-generated tag files 133 | tags 134 | .pytest_cache 135 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [REPORTS] 2 | output-format=text 3 | 4 | 5 | [BASIC] 6 | #missing-docstring=no 7 | 8 | 9 | [FORMAT] 10 | max-line-length=100 11 | ignore-long-lines=^\s*(# )??$ 12 | 13 | 14 | [MESSAGES CONTROL] 15 | disable= 16 | fixme, 17 | too-many-locals, 18 | missing-docstring, 19 | trailing-newlines, 20 | invalid-name, 21 | bad-continuation, 22 | too-few-public-methods, 23 | too-many-branches, 24 | too-many-statements, 25 | deprecated-method, 26 | wrong-import-order, 27 | ungrouped-imports, 28 | unused-import, 29 | useless-object-inheritance, # doesn't like class Foo(object) but required for py2 30 | super-init-not-called, 31 | protected-access, 32 | 33 | 34 | [DESIGN] 35 | #too-few-public-methods=no 36 | #too-many-branches=no 37 | #too-many-statements=no 38 | max-args=10 39 | 40 | 41 | [CLASSES] 42 | #useless-object-inheritance=no 43 | #super-init-not-called=no 44 | valid-classmethod-first-arg=cls 45 | 46 | 47 | [IMPORTS] 48 | #wrong-import-order=no 49 | #ungrouped-imports=no 50 | #unused-import=no 51 | 52 | 53 | [STDLIB] 54 | #deprecated-method=no 55 | 56 | 57 | [MISCELLANEOUS] 58 | notes=FIXME,FIX,XXX,TODO 59 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | **unreleased** 2 | **v1.0.2-dev** 3 | - Declare `bump2version` as unmaintained 4 | - Housekeeping: migrated from travis+appveyor to GitHub Actions for CI, thanks @clbarnes 5 | 6 | **v1.0.1** 7 | - Added: enable special characters in search/replace, thanks @mckelvin 8 | - Added: allow globbing a pattern to match multiple files, thanks @balrok 9 | - Added: way to only bump a specified file via --no-configured-files, thanks @balrok 10 | - Fixed: dry-run now correctly outputs, thanks @fmigneault 11 | - Housekeeping: documentation for lightweight tags improved, thanks @GreatBahram 12 | - Housekeeping: added related tools document, thanks @florisla 13 | - Fixed: no more falling back to default search, thanks @florisla 14 | 15 | **v1.0.0** 16 | - Fix the spurious newline that bump2version adds when writing to bumpversion.cfg, thanks @kyluca #58 17 | - Add Python3.8 support, thanks @florisla 18 | - Drop Python2 support, thanks @hugovk 19 | - Allow additional arguments to the commit call, thanks @lubomir 20 | - Various documentation improvements, thanks @lubomir @florisla @padamstx @glotis 21 | - Housekeeping, move changelog into own file 22 | 23 | **v0.5.11** 24 | 25 | - Housekeeping, also publish an sdist 26 | - Housekeeping, fix appveyor builds 27 | - Housekeeping, `make lint` now lints with pylint 28 | - Drop support for Python3.4, thanks @hugovk #79 29 | - Enhance missing VCS command detection (errno 13), thanks @lowell80 #75 30 | - Add environment variables for other scripts to use, thanks @mauvilsa #70 31 | - Refactor, cli.main is now much more readable, thanks @florisla #68 32 | - Fix, retain file newlines for Windows, thanks @hesstobi #59 33 | - Add support (tests) for Pythno3.7, thanks @florisla #49 34 | - Allow any part to be configured in configurable strings such as tag_name etc., thanks @florisla #41 35 | 36 | **v0.5.10** 37 | 38 | - Housekeeping, use twine 39 | 40 | **v0.5.9** 41 | 42 | - Fixed windows appveyor-based testing, thanks: @jeremycarroll #33 and #34 43 | - Fixed interpolating correctly when using setup.cfg for config, thanks: @SethMMorton #32 44 | - Improve tox/travis testing, thanks: @ekohl #27 45 | - Fixed markdown formatting in setup.py for pypi.org documentation, thanks: @florisla, @Mattwmaster58 #26 46 | 47 | **v0.5.8** 48 | 49 | - Updated the readme to markdown for easier maintainability 50 | - Fixed travis testing, thanks: @sharksforarms #15 51 | - Added support for newlines, thanks: @sharksforarms #14 52 | - Fixed an issue with a TypeError on Windows, thanks: @lorengordon #12 53 | - Standardised the python versions, thanks: @ekohl #8 54 | - Fixed testing for pypy, #7 55 | 56 | **v0.5.7** 57 | 58 | - Added support for signing tags (git tag -s) 59 | thanks: @Californian [#6](https://github.com/c4urself/bump2version/pull/6) 60 | 61 | **v0.5.6** 62 | 63 | - Added compatibility with `bumpversion` by making script install as `bumpversion` as well 64 | thanks: @the-allanc [#2](https://github.com/c4urself/bump2version/pull/2) 65 | 66 | **v0.5.5** 67 | 68 | - Added support for annotated tags 69 | thanks: @ekohl @gvangool [#58](https://github.com/peritus/bumpversion/pull/58) 70 | 71 | **v0.5.4** 72 | 73 | - Renamed to bump2version to ensure no conflicts with original package 74 | 75 | **v0.5.3** 76 | 77 | - Fix bug where `--new-version` value was not used when config was present 78 | (thanks @cscetbon @ecordell [#60](https://github.com/peritus/bumpversion/pull/60) 79 | - Preserve case of keys config file 80 | (thanks theskumar [#75](https://github.com/peritus/bumpversion/pull/75) 81 | - Windows CRLF improvements (thanks @thebjorn) 82 | 83 | **v0.5.1** 84 | 85 | - Document file specific options `search =` and `replace =` (introduced in 0.5.0) 86 | - Fix parsing individual labels from `serialize =` config even if there are 87 | characters after the last label (thanks @mskrajnowski [#56](https://github.com/peritus/bumpversion/pull/56) 88 | - Fix: Don't crash in git repositories that have tags that contain hyphens [#51](https://github.com/peritus/bumpversion/pull/51) and [#52](https://github.com/peritus/bumpversion/pull/52) 89 | - Fix: Log actual content of the config file, not what ConfigParser prints 90 | after reading it. 91 | - Fix: Support multiline values in `search =` 92 | - also load configuration from `setup.cfg`, thanks @t-8ch [#57](https://github.com/peritus/bumpversion/pull/57) 93 | 94 | **v0.5.0** 95 | 96 | This is a major one, containing two larger features, that require some changes 97 | in the configuration format. This release is fully backwards compatible to 98 | *v0.4.1*, however deprecates two uses that will be removed in a future version. 99 | 100 | - New feature: `Part specific configuration` 101 | - New feature: `File specific configuration` 102 | - New feature: parse option can now span multiple line (allows to comment complex 103 | regular expressions. See re.VERBOSE in the [Python documentation](https://docs.python.org/library/re.html#re.VERBOSE) for details, also see [this testcase](https://github.com/peritus/bumpversion/blob/165e5d8bd308e9b7a1a6d17dba8aec9603f2d063/tests.py#L1202-L1211) as an example. 104 | - New feature: `--allow-dirty` [#42](https://github.com/peritus/bumpversion/pull/42) 105 | - Fix: Save the files in binary mode to avoid mutating newlines (thanks @jaraco [#45](https://github.com/peritus/bumpversion/pull/45) 106 | - License: bumpversion is now licensed under the MIT License [#47](https://github.com/peritus/bumpversion/issues/47) 107 | - Deprecate multiple files on the command line (use a `configuration file` instead, or invoke `bumpversion` multiple times) 108 | - Deprecate 'files =' configuration (use `file specific configuration` instead) 109 | 110 | **v0.4.1** 111 | 112 | - Add --list option [#39](https://github.com/peritus/bumpversion/issues/39) 113 | - Use temporary files for handing over commit/tag messages to git/hg [#36](https://github.com/peritus/bumpversion/issues/36) 114 | - Fix: don't encode stdout as utf-8 on py3 [#40](https://github.com/peritus/bumpversion/issues/40) 115 | - Fix: logging of content of config file was wrong 116 | 117 | **v0.4.0** 118 | 119 | - Add --verbose option [#21](https://github.com/peritus/bumpversion/issues/21) [#30](https://github.com/peritus/bumpversion/issues/30) 120 | - Allow option --serialize multiple times 121 | 122 | **v0.3.8** 123 | 124 | - Fix: --parse/--serialize didn't work from cfg [#34](https://github.com/peritus/bumpversion/issues/34) 125 | 126 | **v0.3.7** 127 | 128 | - Don't fail if git or hg is not installed (thanks @keimlink) 129 | - "files" option is now optional [#16](https://github.com/peritus/bumpversion/issues/16) 130 | - Fix bug related to dirty work dir [#28](https://github.com/peritus/bumpversion/issues/28) 131 | 132 | 133 | **v0.3.6** 134 | 135 | - Fix --tag default (thanks @keimlink) 136 | 137 | **v0.3.5** 138 | 139 | - add {now} and {utcnow} to context 140 | - use correct file encoding writing to config file. NOTE: If you are using 141 | Python2 and want to use UTF-8 encoded characters in your config file, you 142 | need to update ConfigParser like using 'pip install -U configparser' 143 | - leave `current_version` in config even if available from vcs tags (was 144 | confusing) 145 | - print own version number in usage 146 | - allow bumping parts that contain non-numerics 147 | - various fixes regarding file encoding 148 | 149 | **v0.3.4** 150 | 151 | - bugfix: tag_name and message in .bumpversion.cfg didn't have an effect [#9](https://github.com/peritus/bumpversion/issues/9) 152 | 153 | **v0.3.3** 154 | 155 | - add --tag-name option 156 | - now works on Python 3.2, 3.3 and PyPy 157 | 158 | **v0.3.2** 159 | 160 | - bugfix: Read only tags from `git describe` that look like versions 161 | 162 | **v0.3.1** 163 | 164 | - bugfix: `--help` in git workdir raising AssertionError 165 | - bugfix: fail earlier if one of files does not exist 166 | - bugfix: `commit = True` / `tag = True` in .bumpversion.cfg had no effect 167 | 168 | **v0.3.0** 169 | 170 | - **BREAKING CHANGE** The `--bump` argument was removed, this is now the first 171 | positional argument. 172 | If you used `bumpversion --bump major` before, you can use 173 | `bumpversion major` now. 174 | If you used `bumpversion` without arguments before, you now 175 | need to specify the part (previous default was `patch`) as in 176 | `bumpversion patch`). 177 | 178 | **v0.2.2** 179 | 180 | - add --no-commit, --no-tag 181 | 182 | **v0.2.1** 183 | 184 | - If available, use git to learn about current version 185 | 186 | **v0.2.0** 187 | 188 | - Mercurial support 189 | 190 | **v0.1.1** 191 | 192 | - Only create a tag when it's requested (thanks @gvangool) 193 | 194 | **v0.1.0** 195 | 196 | - Initial public version 197 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are just a few small guidelines you need to follow. 4 | 5 | ## Guidelines 6 | 1. Write your patch 7 | 1. Add a test case to your patch 8 | 1. Make sure that `make test` runs properly 9 | 1. Send your patch as a PR 10 | 11 | ## Setup 12 | 13 | 1. Fork & clone the repo 14 | 1. Install [Docker](https://docs.docker.com/install/) 15 | 1. Install [docker-compose](https://docs.docker.com/compose/install/) 16 | 1. Run `make test` from the root directory 17 | 18 | 19 | ## How to release bumpversion itself 20 | 21 | Execute the following commands: 22 | 23 | git checkout master 24 | git pull 25 | make test 26 | make lint 27 | bump2version release 28 | make dist 29 | make upload 30 | bump2version --no-tag patch 31 | git push origin master --tags -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM themattrix/tox-base 2 | 3 | RUN apt-get update && apt-get install -y git-core mercurial 4 | 5 | # Update pyenv for access to newer Python releases. 6 | RUN cd /.pyenv \ 7 | && git fetch \ 8 | && git checkout v1.2.15 9 | 10 | # only install certain versions for tox to use 11 | RUN pyenv versions 12 | RUN pyenv global system 3.5.7 3.6.9 3.7.5 3.8.0 pypy3.6-7.2.0 13 | 14 | RUN git config --global user.email "bumpversion_test@example.org" 15 | RUN git config --global user.name "Bumpversion Test" 16 | 17 | ENV PYTHONDONTWRITEBYTECODE = 1 # prevent *.pyc files 18 | 19 | WORKDIR /code 20 | COPY . . 21 | CMD tox 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE.rst 2 | recursive-include tests *.py 3 | 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | docker-compose build test 3 | docker-compose run test 4 | 5 | local_test: 6 | PYTHONPATH=. pytest tests/ 7 | 8 | lint: 9 | pip install pylint 10 | pylint bumpversion 11 | 12 | debug_test: 13 | docker-compose build test 14 | docker-compose run test /bin/bash 15 | 16 | clean: 17 | rm -rf dist build *.egg-info 18 | 19 | dist: clean 20 | python3 setup.py sdist bdist_wheel 21 | 22 | upload: 23 | twine upload dist/* 24 | 25 | .PHONY: dist upload test debug_test 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bump2version 2 | 3 | [![image](https://img.shields.io/pypi/v/bump2version.svg)](https://pypi.org/project/bump2version/) 4 | [![image](https://img.shields.io/pypi/l/bump2version.svg)](https://pypi.org/project/bump2version/) 5 | [![image](https://img.shields.io/pypi/pyversions/bump2version.svg)](https://pypi.org/project/bump2version/) 6 | [![GitHub Actions](https://github.com/c4urself/bump2version/workflows/CI/badge.svg)](https://github.com/c4urself/bump2version/actions) 7 | 8 | > ⚠️ **Warning** 9 | > 10 | > `bump2version` is **no longer maintained**. 11 | > 12 | > You should **switch to [`bump-my-version`](https://github.com/callowayproject/bump-my-version)**. 13 | 14 | ## Overview 15 | 16 | Version-bump your software with a single command! 17 | 18 | A small command line tool to simplify releasing software by updating all 19 | version strings in your source code by the correct increment. Also creates 20 | commits and tags: 21 | 22 | * version formats are highly configurable 23 | * works without any VCS, but happily reads tag information from and writes 24 | commits and tags to Git and Mercurial if available 25 | * just handles text files, so it's not specific to any programming language 26 | * supports Python 3 and PyPy3 27 | 28 | If you want to use Python 2, use `pip>=9` and you'll get the last supported version, 29 | or pin `bump2version<1`. 30 | 31 | ## Alternatives 32 | 33 | If bump2version does not fully suit your needs, you could take a look 34 | at other tools doing similar or related tasks: 35 | [ALTERNATIVES.md](https://github.com/c4urself/bump2version/blob/master/RELATED.md). 36 | 37 | ## Installation 38 | 39 | You can download and install the latest version of this software from the Python package index (PyPI) as follows: 40 | 41 | pip install --upgrade bump2version 42 | 43 | **NOTE: `pip install bumpversion` now installs the latest bump2version!** 44 | 45 | ## Changelog 46 | 47 | Please find the changelog here: [CHANGELOG.md](CHANGELOG.md) 48 | 49 | ## Usage 50 | 51 | NOTE: Throughout this document you can use `bumpversion` or `bump2version` interchangeably. 52 | 53 | There are two modes of operation: On the command line for single-file operation 54 | and using a configuration file (`.bumpversion.cfg`) for more complex multi-file operations. 55 | 56 | bump2version [options] part [file] 57 | 58 | #### `part` 59 | _**required**_
60 | 61 | The part of the version to increase, e.g. `minor`. 62 | 63 | Valid values include those given in the `--serialize` / `--parse` option. 64 | 65 | Example bumping 0.5.1 to 0.6.0: 66 | 67 | bump2version --current-version 0.5.1 minor src/VERSION 68 | 69 | #### `file` 70 | _**[optional]**_
71 | **default**: none 72 | 73 | The file that will be modified. 74 | 75 | This file is added to the list of files specified in `[bumpversion:file:…]` 76 | sections from the configuration file. If you want to rewrite only files 77 | specified on the command line, use `--no-configured-files`. 78 | 79 | Example bumping 1.1.9 to 2.0.0: 80 | 81 | bump2version --current-version 1.1.9 major setup.py 82 | 83 | ## Configuration file 84 | 85 | All options can optionally be specified in a config file called 86 | `.bumpversion.cfg` so that once you know how `bump2version` needs to be 87 | configured for one particular software package, you can run it without 88 | specifying options later. You should add that file to VCS so others can also 89 | bump versions. 90 | 91 | Options on the command line take precedence over those from the config file, 92 | which take precedence over those derived from the environment and then from the 93 | defaults. 94 | 95 | Example `.bumpversion.cfg`: 96 | 97 | ```ini 98 | [bumpversion] 99 | current_version = 0.2.9 100 | commit = True 101 | tag = True 102 | 103 | [bumpversion:file:setup.py] 104 | ``` 105 | 106 | If no `.bumpversion.cfg` exists, `bump2version` will also look into 107 | `setup.cfg` for configuration. 108 | 109 | ### Configuration file -- Global configuration 110 | 111 | General configuration is grouped in a `[bumpversion]` section. 112 | 113 | #### `current_version` 114 | _**required**_
115 | **default**: none 116 | 117 | The current version of the software package before bumping. 118 | 119 | Also available as `--current-version` (e.g. `bump2version --current-version 0.5.1 patch setup.py`) 120 | 121 | #### `new_version` 122 | _**[optional]**_
123 | **default**: none 124 | 125 | The version of the software package after the increment. If not given will be 126 | automatically determined. 127 | 128 | Also available as `--new-version` (e.g. `to go from 0.5.1 directly to 129 | 0.6.1`: `bump2version --current-version 0.5.1 --new-version 0.6.1 patch 130 | setup.py`). 131 | 132 | #### `tag = (True | False)` 133 | _**[optional]**_
134 | **default**: False (Don't create a tag) 135 | 136 | Whether to create a tag, that is the new version, prefixed with the character 137 | "`v`". If you are using git, don't forget to `git-push` with the 138 | `--tags` flag. 139 | 140 | Also available on the command line as `(--tag | --no-tag)`. 141 | 142 | #### `sign_tags = (True | False)` 143 | _**[optional]**_
144 | **default**: False (Don't sign tags) 145 | 146 | Whether to sign tags. 147 | 148 | Also available on the command line as `(--sign-tags | --no-sign-tags)`. 149 | 150 | #### `tag_name =` 151 | _**[optional]**_
152 | **default:** `v{new_version}` 153 | 154 | The name of the tag that will be created. Only valid when using `--tag` / `tag = True`. 155 | 156 | This is templated using the [Python Format String Syntax](https://docs.python.org/3/library/string.html#format-string-syntax). 157 | Available in the template context are `current_version` and `new_version` 158 | as well as `current_[part]` and `new_[part]` (e.g. '`current_major`' 159 | or '`new_patch`'). 160 | In addition, all environment variables are exposed, prefixed with `$`. 161 | You can also use the variables `now` or `utcnow` to get a current timestamp. Both accept 162 | datetime formatting (when used like as in `{now:%d.%m.%Y}`). 163 | 164 | Also available as command-line flag `tag-name`. Example usage: 165 | `bump2version --tag-name 'release-{new_version}' patch` 166 | 167 | #### `tag_message =` 168 | _**[optional]**_
169 | **default:** `Bump version: {current_version} → {new_version}` 170 | 171 | The tag message to use when creating a tag. Only valid when using `--tag` / `tag = True`. 172 | 173 | This is templated using the [Python Format String Syntax](https://docs.python.org/3/library/string.html#format-string-syntax). 174 | Available in the template context are `current_version` and `new_version` 175 | as well as `current_[part]` and `new_[part]` (e.g. '`current_major`' 176 | or '`new_patch`'). 177 | In addition, all environment variables are exposed, prefixed with `$`. 178 | You can also use the variables `now` or `utcnow` to get a current timestamp. Both accept 179 | datetime formatting (when used like as in `{now:%d.%m.%Y}`). 180 | 181 | Also available as command-line flag `--tag-message`. Example usage: 182 | `bump2version --tag-message 'Release {new_version}' patch` 183 | 184 | `bump2version` creates an `annotated` tag in Git by default. To disable this and create a `lightweight` tag, you must explicitly set an empty `tag_message`: 185 | 186 | * either in the configuration file: `tag_message =` 187 | * or in the command-line: `bump2version --tag-message ''` 188 | 189 | You can read more about Git tagging [here](https://git-scm.com/book/en/v2/Git-Basics-Tagging). 190 | 191 | #### `commit = (True | False)` 192 | _**[optional]**_
193 | **default:** False (Don't create a commit) 194 | 195 | Whether to create a commit using git or Mercurial. 196 | 197 | Also available as `(--commit | --no-commit)`. 198 | 199 | In many projects it is common to have a pre-commit hook that runs prior to a 200 | commit and in case of failure aborts the commit. For some use cases it might 201 | be desired that when bumping a version and having `commit = True`, the 202 | pre-commit hook should perform slightly different actions than in regular 203 | commits. For example run an extended set of checks only for actual releases of 204 | the software. To allow the pre-commit hooks to distinguish a bumpversion 205 | commit, the `BUMPVERSION_CURRENT_VERSION` and `BUMPVERSION_NEW_VERSION` 206 | environment variables are set when executing the commit command. 207 | 208 | #### `message =` 209 | _**[optional]**_
210 | **default:** `Bump version: {current_version} → {new_version}` 211 | 212 | The commit message to use when creating a commit. Only valid when using `--commit` / `commit = True`. 213 | 214 | This is templated using the [Python Format String Syntax](https://docs.python.org/3/library/string.html#format-string-syntax). 215 | Available in the template context are `current_version` and `new_version` 216 | as well as `current_[part]` and `new_[part]` (e.g. '`current_major`' 217 | or '`new_patch`'). 218 | In addition, all environment variables are exposed, prefixed with `$`. 219 | You can also use the variables `now` or `utcnow` to get a current timestamp. Both accept 220 | datetime formatting (when used like as in `{now:%d.%m.%Y}`). 221 | 222 | Also available as command-line flag `--message`. Example usage: 223 | `bump2version --message '[{now:%Y-%m-%d}] Jenkins Build {$BUILD_NUMBER}: {new_version}' patch`) 224 | 225 | #### `commit_args =` 226 | _**[optional]**_
227 | **default:** empty 228 | 229 | Extra arguments to pass to commit command. Only valid when using `--commit` / 230 | `commit = True`. 231 | 232 | This is for example useful to add `-s` to generate `Signed-off-by:` line in 233 | the commit message. 234 | 235 | Multiple arguments can be specified on separate lines. 236 | 237 | Also available as command-line flag `--commit-args`, in which case only one 238 | argument can be specified. 239 | 240 | 241 | ### Configuration file -- Part specific configuration 242 | 243 | A version string consists of one or more parts, e.g. the version `1.0.2` 244 | has three parts, separated by a dot (`.`) character. In the default 245 | configuration these parts are named `major`, `minor`, `patch`, however you can 246 | customize that using the `parse`/`serialize` option. 247 | 248 | By default all parts are considered numeric, that is their initial value is `0` 249 | and they are increased as integers. Also, the value `0` is considered to be 250 | optional if it's not needed for serialization, i.e. the version `1.4.0` is 251 | equal to `1.4` if `{major}.{minor}` is given as a `serialize` value. 252 | 253 | For advanced versioning schemes, non-numeric parts may be desirable (e.g. to 254 | identify [alpha or beta versions](http://en.wikipedia.org/wiki/Software_release_life_cycle#Stages_of_development) 255 | to indicate the stage of development, the flavor of the software package or 256 | a release name). To do so, you can use a `[bumpversion:part:…]` section 257 | containing the part's name (e.g. a part named `release_name` is configured in 258 | a section called `[bumpversion:part:release_name]`. 259 | 260 | The following options are valid inside a part configuration: 261 | 262 | #### `values =` 263 | **default**: numeric (i.e. `0`, `1`, `2`, …) 264 | 265 | Explicit list of all values that will be iterated when bumping that specific 266 | part. 267 | 268 | Example: 269 | 270 | ```ini 271 | [bumpversion:part:release_name] 272 | values = 273 | witty-warthog 274 | ridiculous-rat 275 | marvelous-mantis 276 | ``` 277 | 278 | #### `optional_value =` 279 | **default**: The first entry in `values =`. 280 | 281 | If the value of the part matches this value it is considered optional, i.e. 282 | its representation in a `--serialize` possibility is not required. 283 | 284 | Example: 285 | 286 | ```ini 287 | [bumpversion] 288 | current_version = 1.alpha 289 | parse = (?P\d+)(\.(?P.*))? 290 | serialize = 291 | {num}.{release} 292 | {num} 293 | 294 | [bumpversion:part:release] 295 | optional_value = gamma 296 | values = 297 | alpha 298 | beta 299 | gamma 300 | ``` 301 | 302 | Here, `bump2version release` would bump `1.alpha` to `1.beta`. Executing 303 | `bump2version release` again would bump `1.beta` to `1`, because 304 | `release` being `gamma` is configured optional. 305 | 306 | You should consider the version of `1` to technically be `1.gamma` 307 | with the `.gamma` part not being serialized since it is optional. 308 | The `{num}` entry in the `serialize` list allows the release part to be 309 | hidden. If you only had `{num}.{release}`, an optional release will always 310 | be serialized. 311 | 312 | Attempting to bump the release when it is the value of 313 | `gamma` will cause a `ValueError` as it will think you are trying to 314 | exceed the `values` list of the release part. 315 | 316 | #### `first_value =` 317 | **default**: The first entry in `values =`. 318 | 319 | When the part is reset, the value will be set to the value specified here. 320 | 321 | Example: 322 | 323 | ```ini 324 | [bumpversion] 325 | current_version = 1.alpha1 326 | parse = (?P\d+)(\.(?P.*)(?P\d+))? 327 | serialize = 328 | {num}.{release}{build} 329 | 330 | [bumpversion:part:release] 331 | values = 332 | alpha 333 | beta 334 | gamma 335 | 336 | [bumpversion:part:build] 337 | first_value = 1 338 | ``` 339 | 340 | Here, `bump2version release` would bump `1.alpha1` to `1.beta1`. 341 | 342 | Without the `first_value = 1` of the build part configured, 343 | `bump2version release` would bump `1.alpha1` to `1.beta0`, starting 344 | the build at `0`. 345 | 346 | 347 | #### `independent =` 348 | **default**: `False` 349 | 350 | When this value is set to `True`, the part is not reset when other parts are incremented. Its incrementation is 351 | independent of the other parts. It is in particular useful when you have a build number in your version that is 352 | incremented independently of the actual version. 353 | 354 | Example: 355 | 356 | ```ini 357 | [bumpversion] 358 | current_version: 2.1.6-5123 359 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)\-(?P\d+) 360 | serialize = {major}.{minor}.{patch}-{build} 361 | 362 | [bumpversion:file:VERSION.txt] 363 | 364 | [bumpversion:part:build] 365 | independent = True 366 | ``` 367 | 368 | Here, `bump2version build` would bump `2.1.6-5123` to `2.1.6-5124`. Executing`bump2version major` 369 | would bump `2.1.6-5124` to `3.0.0-5124` without resetting the build number. 370 | 371 | 372 | ### Configuration file -- File specific configuration 373 | 374 | This configuration is in the section: `[bumpversion:file:…]` or `[bumpversion:glob:…]` 375 | 376 | Both, `file:` and `glob:` are configured the same. Their difference is that 377 | file will match file names directly like `requirements.txt`. While glob also 378 | matches multiple files via wildcards like `**/pom.xml`. 379 | 380 | Note: The configuration file format requires each section header to be 381 | unique. If you want to process a certain file multiple times, 382 | you may append a description between parens to the `file` keyword: 383 | `[bumpversion:file (special one):…]`. 384 | 385 | #### `parse =` 386 | **default:** `(?P\d+)\.(?P\d+)\.(?P\d+)` 387 | 388 | Regular expression (using [Python regular expression syntax](https://docs.python.org/3/library/re.html#regular-expression-syntax)) on 389 | how to find and parse the version string. 390 | 391 | Is required to parse all strings produced by `serialize =`. Named matching 392 | groups ("`(?P...)`") provide values to as the `part` argument. 393 | 394 | Also available as `--parse` 395 | 396 | #### `serialize =` 397 | **default:** `{major}.{minor}.{patch}` 398 | 399 | Template specifying how to serialize the version parts back to a version 400 | string. 401 | 402 | This is templated using the [Python Format String Syntax](https://docs.python.org/3/library/string.html#format-string-syntax). 403 | Available in the template context are parsed values of the named groups 404 | specified in `parse =` as well as all environment variables (prefixed with 405 | `$`). 406 | 407 | Can be specified multiple times, bumpversion will try the serialization 408 | formats beginning with the first and choose the last one where all values can 409 | be represented like this: 410 | 411 | ```ini 412 | serialize = 413 | {major}.{minor} 414 | {major} 415 | ``` 416 | 417 | Given the example above, the new version `1.9` will be serialized as 418 | `1.9`, but the version `2.0` will be serialized as `2`. 419 | 420 | Also available as `--serialize`. Multiple values on the command line are 421 | given like `--serialize {major}.{minor} --serialize {major}` 422 | 423 | #### `search =` 424 | **default:** `{current_version}` 425 | 426 | Template string how to search for the string to be replaced in the file. 427 | Useful if the remotest possibility exists that the current version number 428 | might be present multiple times in the file and you mean to only bump one of the 429 | occurrences. Can be multiple lines, templated using [Python Format String Syntax](https://docs.python.org/3/library/string.html#format-string-syntax) 430 | 431 | #### `replace =` 432 | **default:** `{new_version}` 433 | 434 | Template to create the string that will replace the current version number in 435 | the file. 436 | 437 | Given this `requirements.txt`: 438 | 439 | Django>=1.5.6,<1.6 440 | MyProject==1.5.6 441 | 442 | using this `.bumpversion.cfg` will ensure only the line containing 443 | `MyProject` will be changed: 444 | 445 | ```ini 446 | [bumpversion] 447 | current_version = 1.5.6 448 | 449 | [bumpversion:file:requirements.txt] 450 | search = MyProject=={current_version} 451 | replace = MyProject=={new_version} 452 | ``` 453 | 454 | Can be multiple lines, templated using [Python Format String Syntax](https://docs.python.org/3/library/string.html#format-string-syntax). 455 | 456 | **NOTE**: (*Updated in v1.0.1*) It is important to point out that if a 457 | custom search pattern is configured, then `bump2version` will only perform 458 | a change if it finds an exact match and will not fallback to the default 459 | pattern. This is to prevent accidentally changing strings that match the 460 | default pattern when there is a typo in the custom search pattern. 461 | 462 | For example, if the string to be replaced includes literal quotes, 463 | the search and replace patterns must include them too to match. Given the 464 | file `version.sh`: 465 | 466 | MY_VERSION="1.2.3" 467 | 468 | Then the following search and replace patterns (including quotes) would be 469 | required: 470 | 471 | ```ini 472 | [bumpversion:file:version.sh] 473 | search = MY_VERSION="{current_version}" 474 | replace = MY_VERSION="{new_version}" 475 | ``` 476 | 477 | ## Command-line Options 478 | 479 | Most of the configuration values above can also be given as an option on the command-line. 480 | Additionally, the following options are available: 481 | 482 | `--dry-run, -n` 483 | Don't touch any files, just pretend. Best used with `--verbose`. 484 | 485 | `--allow-dirty` 486 | Normally, bumpversion will abort if the working directory is dirty to protect 487 | yourself from releasing unversioned files and/or overwriting unsaved changes. 488 | Use this option to override this check. 489 | 490 | `--no-configured-files` 491 | Will not update/check files specified in the .bumpversion.cfg. 492 | Similar to dry-run, but will also avoid checking the files. 493 | Also useful when you want to update just one file with e.g., 494 | `bump2version --no-configured-files major my-file.txt` 495 | 496 | `--verbose` 497 | Print useful information to stderr 498 | 499 | `--list` 500 | List machine readable information to stdout for consumption by other 501 | programs. 502 | 503 | Example output: 504 | 505 | current_version=0.0.18 506 | new_version=0.0.19 507 | 508 | `-h, --help` 509 | Print help and exit 510 | 511 | ## Using bumpversion in a script 512 | 513 | If you need to use the version generated by bumpversion in a script you can make use of 514 | the `--list` option, combined with `grep` and `sed`. 515 | 516 | Say for example that you are using git-flow to manage your project and want to automatically 517 | create a release. When you issue `git flow release start` you already need to know the 518 | new version, before applying the change. 519 | 520 | The standard way to get it in a bash script is 521 | 522 | bump2version --dry-run --list | grep | sed -r s,"^.*=",, 523 | 524 | where `part` is as usual the part of the version number you are updating. You need to specify 525 | `--dry-run` to avoid bumpversion actually bumping the version number. 526 | 527 | For example, if you are updating the minor number and looking for the new version number this becomes 528 | 529 | bump2version --dry-run --list minor | grep new_version | sed -r s,"^.*=",, 530 | 531 | ## Using bumpversion to maintain a go.mod file within a Go project 532 | 533 | In a module-aware Go project, when you create a major version of your module beyond v1, your module name will need 534 | to include the major version # (e.g. `github.com/myorg/myproject/v2`). 535 | 536 | You can use bump2version to maintain the major version # within the go.mod file by using the `parse` and `serialize` 537 | options, as in this example: 538 | 539 | - Example `.bumpversion.cfg` file: 540 | 541 | ``` 542 | [bumpversion] 543 | current_version = 2.0.0 544 | commit = True 545 | 546 | [bumpversion:file:go.mod] 547 | parse = (?P\d+) 548 | serialize = {major} 549 | search = module github.com/myorg/myproject/v{current_version} 550 | replace = module github.com/myorg/myproject/v{new_version} 551 | ``` 552 | 553 | - Example `go.mod` file: 554 | 555 | ``` 556 | module github.com/myorg/myproject/v2 557 | 558 | go 1.12 559 | 560 | require ( 561 | ... 562 | ) 563 | ``` 564 | 565 | Then run this command to create version 3.0.0 of your project: 566 | 567 | ``` 568 | bump2version --new-version 3.0.0 major 569 | ``` 570 | Your `go.mod` file now contains this module directive: 571 | 572 | ``` 573 | module github.com/myorg/myproject/v3 574 | ``` 575 | 576 | ## Development & Contributing 577 | 578 | Thank you contributors! You can find a full list here: https://github.com/c4urself/bump2version/graphs/contributors 579 | 580 | See also our [CONTRIBUTING.md](CONTRIBUTING.md) 581 | 582 | Development of this happens on GitHub, patches including tests, documentation 583 | are very welcome, as well as bug reports! Also please open an issue if this 584 | tool does not support every aspect of bumping versions in your development 585 | workflow, as it is intended to be very versatile. 586 | 587 | ## License 588 | 589 | bump2version is licensed under the MIT License - see the [LICENSE.rst](LICENSE.rst) file for details 590 | -------------------------------------------------------------------------------- /RELATED.md: -------------------------------------------------------------------------------- 1 | # Successor 2 | 3 | [Bump-my-version](https://pypi.org/project/bump-my-version/) is the successor to bump2version and its predecessor bumpversion. 4 | 5 | 6 | # Similar or related tools 7 | 8 | * [bumpversion](https://pypi.org/project/bumpversion/) is the original project 9 | off of which `bump2version` was forked. We'll be merging 10 | back with them at some point (issue [#86](https://github.com/c4urself/bump2version/issues/86)). 11 | 12 | * [tbump](https://github.com/tankerhq/tbump) is a complete rewrite, with a nicer UX and additional features, like running commands (aka hooks) before or after the bump. It only works for Git repos right now. 13 | 14 | * [ADVbumpversion](https://github.com/andrivet/advbumpversion) is another fork. 15 | It offered some features that are now incorporated by its author into `bump2version`. 16 | This fork is thus now deprecated, and it recommends to use `bump2version` 17 | (issue [#121](https://github.com/c4urself/bump2version/issues/121)). 18 | 19 | * [zest.releaser](https://pypi.org/project/zest.releaser/) manages 20 | your Python package releases and keeps the version number in one location. 21 | 22 | * [setuptools-scm](https://pypi.org/project/setuptools-scm/) relies on 23 | version control tags and the state of your working copy to determine 24 | the version number. 25 | 26 | * [incremental](https://pypi.org/project/incremental/) integrates into 27 | setuptools and maintains the version number in `_version.py`. 28 | 29 | * Invocations [packaging.release](https://invocations.readthedocs.io/en/latest/) 30 | are a set of tasks for [invoke](https://www.pyinvoke.org/). 31 | These assume your version is in `_version.py` and you're using 32 | semantic versioning. 33 | 34 | * [python-semantic.release](https://github.com/relekang/python-semantic-release) 35 | automatically bumps your (semantic) version number based on the 36 | types of commits (breaking/new/bugfix) in your source control. 37 | 38 | * [PyCalVer](https://gitlab.com/mbarkhau/pycalver) is very similar to bump2version, but with support for [calendar based versioning](https://calver.org/). 39 | 40 | 41 | ## Change log building 42 | 43 | * [towncrier](https://pypi.org/project/towncrier/) assembles a changelog 44 | file from multiple snippets found in individual (merge) commits. 45 | 46 | * [releases](https://pypi.org/project/releases/) helps build a Sphinx 47 | ReStructuredText changelog. 48 | 49 | * [gitchangelog](https://pypi.org/project/gitchangelog/) searches 50 | the git commit history to make a configurable changelog file. 51 | 52 | ## Other similar or related tools 53 | 54 | Without having looked at these, you may find these interesting: 55 | 56 | * https://github.com/silent-snowman/git_bump_version 57 | * https://pypi.org/project/travis-bump-version/ 58 | * https://pypi.org/project/bump/ 59 | * https://pypi.org/project/bump-anything/ 60 | * https://pypi.org/project/bump-release/ 61 | * https://github.com/Yuav/python-package-version 62 | * https://github.com/keleshev/version 63 | * https://pypi.org/project/blurb/ 64 | * https://regro.github.io/rever-docs/ 65 | * https://pypi.org/project/pbr/ 66 | * https://pypi.org/project/bumpver/ 67 | * https://pypi.org/project/pyxcute/ 68 | * https://pypi.org/project/bumpytrack/ 69 | * https://pypi.org/project/bumpr/ 70 | * https://pypi.org/project/vbump/ 71 | * https://pypi.org/project/pybump/ 72 | * https://github.com/michaeljoseph/changes 73 | * https://github.com/kmfarley11/version-checker 74 | -------------------------------------------------------------------------------- /bumpversion/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0.2-dev" 2 | __license__ = "MIT" 3 | __title__ = "bumpversion" 4 | -------------------------------------------------------------------------------- /bumpversion/__main__.py: -------------------------------------------------------------------------------- 1 | from bumpversion.cli import main 2 | 3 | 4 | main() 5 | -------------------------------------------------------------------------------- /bumpversion/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from datetime import datetime 3 | import glob 4 | import io 5 | import itertools 6 | import logging 7 | import os 8 | import re 9 | import sre_constants 10 | import sys 11 | import warnings 12 | from configparser import ( 13 | ConfigParser, 14 | RawConfigParser, 15 | NoOptionError, 16 | ) 17 | 18 | from bumpversion import __version__, __title__ 19 | from bumpversion.version_part import ( 20 | VersionConfig, 21 | NumericVersionPartConfiguration, 22 | ConfiguredVersionPartConfiguration, 23 | ) 24 | from bumpversion.exceptions import ( 25 | IncompleteVersionRepresentationException, 26 | MissingValueForSerializationException, 27 | WorkingDirectoryIsDirtyException, 28 | ) 29 | 30 | from bumpversion.utils import ( 31 | ConfiguredFile, 32 | DiscardDefaultIfSpecifiedAppendAction, 33 | keyvaluestring, 34 | prefixed_environ, 35 | ) 36 | from bumpversion.vcs import Git, Mercurial 37 | 38 | 39 | DESCRIPTION = "{}: v{} (using Python v{})".format( 40 | __title__, 41 | __version__, 42 | sys.version.split("\n")[0].split(" ")[0] 43 | ) 44 | VCS = [Git, Mercurial] 45 | 46 | # detect either 47 | # bumpversion:part:value 48 | # bumpversion:file:value 49 | # bumpversion:file(suffix):value 50 | # bumpversion:file ( suffix with spaces):value 51 | RE_DETECT_SECTION_TYPE = re.compile( 52 | r"^bumpversion:" 53 | r"((?Pfile|glob)(\s*\(\s*(?P[^\):]+)\)?)?|(?Ppart)):" 54 | r"(?P.+)", 55 | ) 56 | 57 | logger_list = logging.getLogger("bumpversion.list") 58 | logger = logging.getLogger(__name__) 59 | time_context = {"now": datetime.now(), "utcnow": datetime.utcnow()} 60 | special_char_context = {c: c for c in ("#", ";")} 61 | 62 | 63 | OPTIONAL_ARGUMENTS_THAT_TAKE_VALUES = [ 64 | "--config-file", 65 | "--current-version", 66 | "--message", 67 | "--new-version", 68 | "--parse", 69 | "--serialize", 70 | "--search", 71 | "--replace", 72 | "--tag-name", 73 | "--tag-message", 74 | "-m", 75 | ] 76 | 77 | 78 | def main(original_args=None): 79 | # determine configuration based on command-line arguments 80 | # and on-disk configuration files 81 | args, known_args, root_parser, positionals = _parse_arguments_phase_1(original_args) 82 | _setup_logging(known_args.list, known_args.verbose) 83 | vcs_info = _determine_vcs_usability() 84 | defaults = _determine_current_version(vcs_info) 85 | explicit_config = None 86 | if hasattr(known_args, "config_file"): 87 | explicit_config = known_args.config_file 88 | config_file = _determine_config_file(explicit_config) 89 | config, config_file_exists, config_newlines, part_configs, files = _load_configuration( 90 | config_file, explicit_config, defaults, 91 | ) 92 | known_args, parser2, remaining_argv = _parse_arguments_phase_2( 93 | args, known_args, defaults, root_parser 94 | ) 95 | version_config = _setup_versionconfig(known_args, part_configs) 96 | current_version = version_config.parse(known_args.current_version) 97 | context = dict( 98 | itertools.chain( 99 | time_context.items(), 100 | prefixed_environ().items(), 101 | vcs_info.items(), 102 | special_char_context.items(), 103 | ) 104 | ) 105 | 106 | # calculate the desired new version 107 | new_version = _assemble_new_version( 108 | context, current_version, defaults, known_args.current_version, positionals, version_config 109 | ) 110 | args, file_names = _parse_arguments_phase_3(remaining_argv, positionals, defaults, parser2) 111 | new_version = _parse_new_version(args, new_version, version_config) 112 | 113 | # do not use the files from the config 114 | if args.no_configured_files: 115 | files = [] 116 | 117 | # replace version in target files 118 | vcs = _determine_vcs_dirty(VCS, defaults) 119 | files.extend( 120 | ConfiguredFile(file_name, version_config) 121 | for file_name 122 | in (file_names or positionals[1:]) 123 | ) 124 | _check_files_contain_version(files, current_version, context) 125 | _replace_version_in_files(files, current_version, new_version, args.dry_run, context) 126 | _log_list(config, args.new_version) 127 | 128 | # store the new version 129 | _update_config_file( 130 | config, config_file, config_newlines, config_file_exists, args.new_version, args.dry_run, 131 | ) 132 | 133 | # commit and tag 134 | if vcs: 135 | context = _commit_to_vcs(files, context, config_file, config_file_exists, vcs, 136 | args, current_version, new_version) 137 | _tag_in_vcs(vcs, context, args) 138 | 139 | 140 | def split_args_in_optional_and_positional(args): 141 | # manually parsing positional arguments because stupid argparse can't mix 142 | # positional and optional arguments 143 | 144 | positions = [] 145 | for i, arg in enumerate(args): 146 | 147 | previous = None 148 | 149 | if i > 0: 150 | previous = args[i - 1] 151 | 152 | if (not arg.startswith("-")) and ( 153 | previous not in OPTIONAL_ARGUMENTS_THAT_TAKE_VALUES 154 | ): 155 | positions.append(i) 156 | 157 | positionals = [arg for i, arg in enumerate(args) if i in positions] 158 | args = [arg for i, arg in enumerate(args) if i not in positions] 159 | 160 | return (positionals, args) 161 | 162 | 163 | def _parse_arguments_phase_1(original_args): 164 | positionals, args = split_args_in_optional_and_positional( 165 | sys.argv[1:] if original_args is None else original_args 166 | ) 167 | if len(positionals[1:]) > 2: 168 | warnings.warn( 169 | "Giving multiple files on the command line will be deprecated, " 170 | "please use [bumpversion:file:...] in a config file.", 171 | PendingDeprecationWarning, 172 | ) 173 | root_parser = argparse.ArgumentParser(add_help=False) 174 | root_parser.add_argument( 175 | "--config-file", 176 | metavar="FILE", 177 | default=argparse.SUPPRESS, 178 | required=False, 179 | help="Config file to read most of the variables from (default: .bumpversion.cfg)", 180 | ) 181 | root_parser.add_argument( 182 | "--verbose", 183 | action="count", 184 | default=0, 185 | help="Print verbose logging to stderr", 186 | required=False, 187 | ) 188 | root_parser.add_argument( 189 | "--list", 190 | action="store_true", 191 | default=False, 192 | help="List machine readable information", 193 | required=False, 194 | ) 195 | root_parser.add_argument( 196 | "--allow-dirty", 197 | action="store_true", 198 | default=False, 199 | help="Don't abort if working directory is dirty", 200 | required=False, 201 | ) 202 | known_args, _ = root_parser.parse_known_args(args) 203 | return args, known_args, root_parser, positionals 204 | 205 | 206 | def _setup_logging(show_list, verbose): 207 | logformatter = logging.Formatter("%(message)s") 208 | if not logger.handlers: 209 | ch1 = logging.StreamHandler(sys.stderr) 210 | ch1.setFormatter(logformatter) 211 | logger.addHandler(ch1) 212 | if not logger_list.handlers: 213 | ch2 = logging.StreamHandler(sys.stdout) 214 | ch2.setFormatter(logformatter) 215 | logger_list.addHandler(ch2) 216 | if show_list: 217 | logger_list.setLevel(logging.DEBUG) 218 | try: 219 | log_level = [logging.WARNING, logging.INFO, logging.DEBUG][verbose] 220 | except IndexError: 221 | log_level = logging.DEBUG 222 | root_logger = logging.getLogger('') 223 | root_logger.setLevel(log_level) 224 | logger.debug("Starting %s", DESCRIPTION) 225 | 226 | 227 | def _determine_vcs_usability(): 228 | vcs_info = {} 229 | for vcs in VCS: 230 | if vcs.is_usable(): 231 | vcs_info.update(vcs.latest_tag_info()) 232 | return vcs_info 233 | 234 | 235 | def _determine_current_version(vcs_info): 236 | defaults = {} 237 | if "current_version" in vcs_info: 238 | defaults["current_version"] = vcs_info["current_version"] 239 | return defaults 240 | 241 | 242 | def _determine_config_file(explicit_config): 243 | if explicit_config: 244 | return explicit_config 245 | if not os.path.exists(".bumpversion.cfg") and os.path.exists("setup.cfg"): 246 | return "setup.cfg" 247 | return ".bumpversion.cfg" 248 | 249 | 250 | def _load_configuration(config_file, explicit_config, defaults): 251 | # setup.cfg supports interpolation - for compatibility we must do the same. 252 | if os.path.basename(config_file) == "setup.cfg": 253 | config = ConfigParser("") 254 | else: 255 | config = RawConfigParser("") 256 | # don't transform keys to lowercase (which would be the default) 257 | config.optionxform = lambda option: option 258 | config.add_section("bumpversion") 259 | config_file_exists = os.path.exists(config_file) 260 | 261 | if not config_file_exists: 262 | message = "Could not read config file at {}".format(config_file) 263 | if explicit_config: 264 | raise argparse.ArgumentTypeError(message) 265 | logger.info(message) 266 | return config, config_file_exists, None, {}, [] 267 | 268 | logger.info("Reading config file %s:", config_file) 269 | 270 | with open(config_file, "rt", encoding="utf-8") as config_fp: 271 | config_content = config_fp.read() 272 | config_newlines = config_fp.newlines 273 | 274 | # TODO: this is a DEBUG level log 275 | logger.info(config_content) 276 | config.read_string(config_content) 277 | log_config = io.StringIO() 278 | config.write(log_config) 279 | 280 | if config.has_option("bumpversion", "files"): 281 | warnings.warn( 282 | "'files =' configuration will be deprecated, please use [bumpversion:file:...]", 283 | PendingDeprecationWarning, 284 | ) 285 | 286 | defaults.update(dict(config.items("bumpversion"))) 287 | 288 | for listvaluename in ("serialize",): 289 | try: 290 | value = config.get("bumpversion", listvaluename) 291 | defaults[listvaluename] = list( 292 | filter(None, (x.strip() for x in value.splitlines())) 293 | ) 294 | except NoOptionError: 295 | pass # no default value then ;) 296 | 297 | for boolvaluename in ("commit", "tag", "dry_run"): 298 | try: 299 | defaults[boolvaluename] = config.getboolean( 300 | "bumpversion", boolvaluename 301 | ) 302 | except NoOptionError: 303 | pass # no default value then ;) 304 | 305 | part_configs = {} 306 | files = [] 307 | 308 | for section_name in config.sections(): 309 | section_type_match = RE_DETECT_SECTION_TYPE.match(section_name) 310 | 311 | if not section_type_match: 312 | continue 313 | 314 | section_type = section_type_match.groupdict() 315 | section_value = section_type.get("value") 316 | section_config = dict(config.items(section_name)) 317 | 318 | if section_type.get("part"): 319 | ThisVersionPartConfiguration = NumericVersionPartConfiguration 320 | 321 | if "values" in section_config: 322 | section_config["values"] = list( 323 | filter( 324 | None, 325 | (x.strip() for x in section_config["values"].splitlines()), 326 | ) 327 | ) 328 | ThisVersionPartConfiguration = ConfiguredVersionPartConfiguration 329 | 330 | if config.has_option(section_name, 'independent'): 331 | section_config['independent'] = config.getboolean(section_name, 'independent') 332 | 333 | part_configs[section_value] = ThisVersionPartConfiguration( 334 | **section_config 335 | ) 336 | elif section_type.get("file"): 337 | filename = section_value 338 | 339 | if "serialize" in section_config: 340 | section_config["serialize"] = list( 341 | filter( 342 | None, 343 | ( 344 | x.strip().replace("\\n", "\n") 345 | for x in section_config["serialize"].splitlines() 346 | ), 347 | ) 348 | ) 349 | 350 | section_config["part_configs"] = part_configs 351 | 352 | if "parse" not in section_config: 353 | section_config["parse"] = defaults.get( 354 | "parse", r"(?P\d+)\.(?P\d+)\.(?P\d+)" 355 | ) 356 | 357 | if "serialize" not in section_config: 358 | section_config["serialize"] = defaults.get( 359 | "serialize", ["{major}.{minor}.{patch}"] 360 | ) 361 | 362 | if "search" not in section_config: 363 | section_config["search"] = defaults.get( 364 | "search", "{current_version}" 365 | ) 366 | 367 | if "replace" not in section_config: 368 | section_config["replace"] = defaults.get("replace", "{new_version}") 369 | 370 | version_config = VersionConfig(**section_config) 371 | if section_type.get("file") == "glob": 372 | for filename_glob in glob.glob(filename, recursive=True): 373 | files.append(ConfiguredFile(filename_glob, version_config)) 374 | else: 375 | files.append(ConfiguredFile(filename, version_config)) 376 | return config, config_file_exists, config_newlines, part_configs, files 377 | 378 | 379 | def _parse_arguments_phase_2(args, known_args, defaults, root_parser): 380 | parser2 = argparse.ArgumentParser( 381 | prog="bumpversion", add_help=False, parents=[root_parser] 382 | ) 383 | parser2.set_defaults(**defaults) 384 | parser2.add_argument( 385 | "--current-version", 386 | metavar="VERSION", 387 | help="Version that needs to be updated", 388 | required=False, 389 | ) 390 | parser2.add_argument( 391 | "--parse", 392 | metavar="REGEX", 393 | help="Regex parsing the version string", 394 | default=defaults.get( 395 | "parse", r"(?P\d+)\.(?P\d+)\.(?P\d+)" 396 | ), 397 | ) 398 | parser2.add_argument( 399 | "--serialize", 400 | metavar="FORMAT", 401 | action=DiscardDefaultIfSpecifiedAppendAction, 402 | help="How to format what is parsed back to a version", 403 | default=defaults.get("serialize", ["{major}.{minor}.{patch}"]), 404 | ) 405 | parser2.add_argument( 406 | "--search", 407 | metavar="SEARCH", 408 | help="Template for complete string to search", 409 | default=defaults.get("search", "{current_version}"), 410 | ) 411 | parser2.add_argument( 412 | "--replace", 413 | metavar="REPLACE", 414 | help="Template for complete string to replace", 415 | default=defaults.get("replace", "{new_version}"), 416 | ) 417 | known_args, remaining_argv = parser2.parse_known_args(args) 418 | 419 | defaults.update(vars(known_args)) 420 | 421 | assert isinstance(known_args.serialize, list), "Argument `serialize` must be a list" 422 | 423 | return known_args, parser2, remaining_argv 424 | 425 | 426 | def _setup_versionconfig(known_args, part_configs): 427 | try: 428 | version_config = VersionConfig( 429 | parse=known_args.parse, 430 | serialize=known_args.serialize, 431 | search=known_args.search, 432 | replace=known_args.replace, 433 | part_configs=part_configs, 434 | ) 435 | except sre_constants.error: 436 | # TODO: use re.error here mayhaps, also: should we log? 437 | sys.exit(1) 438 | return version_config 439 | 440 | 441 | def _assemble_new_version( 442 | context, current_version, defaults, arg_current_version, positionals, version_config 443 | ): 444 | new_version = None 445 | if "new_version" not in defaults and arg_current_version: 446 | try: 447 | if current_version and positionals: 448 | logger.info("Attempting to increment part '%s'", positionals[0]) 449 | new_version = current_version.bump(positionals[0], version_config.order()) 450 | logger.info("Values are now: %s", keyvaluestring(new_version._values)) 451 | defaults["new_version"] = version_config.serialize(new_version, context) 452 | except MissingValueForSerializationException as e: 453 | logger.info("Opportunistic finding of new_version failed: %s", e.message) 454 | except IncompleteVersionRepresentationException as e: 455 | logger.info("Opportunistic finding of new_version failed: %s", e.message) 456 | except KeyError as e: 457 | logger.info("Opportunistic finding of new_version failed") 458 | return new_version 459 | 460 | 461 | def _parse_arguments_phase_3(remaining_argv, positionals, defaults, parser2): 462 | parser3 = argparse.ArgumentParser( 463 | prog="bumpversion", 464 | description=DESCRIPTION, 465 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 466 | conflict_handler="resolve", 467 | parents=[parser2], 468 | ) 469 | parser3.set_defaults(**defaults) 470 | parser3.add_argument( 471 | "--current-version", 472 | metavar="VERSION", 473 | help="Version that needs to be updated", 474 | required="current_version" not in defaults, 475 | ) 476 | parser3.add_argument( 477 | "--no-configured-files", 478 | action="store_true", 479 | default=False, 480 | dest="no_configured_files", 481 | help="Only replace the version in files specified on the command line, ignoring the files from the configuration file.", 482 | ) 483 | parser3.add_argument( 484 | "--dry-run", 485 | "-n", 486 | action="store_true", 487 | default=False, 488 | help="Don't write any files, just pretend.", 489 | ) 490 | parser3.add_argument( 491 | "--new-version", 492 | metavar="VERSION", 493 | help="New version that should be in the files", 494 | required="new_version" not in defaults, 495 | ) 496 | commitgroup = parser3.add_mutually_exclusive_group() 497 | commitgroup.add_argument( 498 | "--commit", 499 | action="store_true", 500 | dest="commit", 501 | help="Commit to version control", 502 | default=defaults.get("commit", False), 503 | ) 504 | commitgroup.add_argument( 505 | "--no-commit", 506 | action="store_false", 507 | dest="commit", 508 | help="Do not commit to version control", 509 | default=argparse.SUPPRESS, 510 | ) 511 | taggroup = parser3.add_mutually_exclusive_group() 512 | taggroup.add_argument( 513 | "--tag", 514 | action="store_true", 515 | dest="tag", 516 | default=defaults.get("tag", False), 517 | help="Create a tag in version control", 518 | ) 519 | taggroup.add_argument( 520 | "--no-tag", 521 | action="store_false", 522 | dest="tag", 523 | help="Do not create a tag in version control", 524 | default=argparse.SUPPRESS, 525 | ) 526 | signtagsgroup = parser3.add_mutually_exclusive_group() 527 | signtagsgroup.add_argument( 528 | "--sign-tags", 529 | action="store_true", 530 | dest="sign_tags", 531 | help="Sign tags if created", 532 | default=defaults.get("sign_tags", False), 533 | ) 534 | signtagsgroup.add_argument( 535 | "--no-sign-tags", 536 | action="store_false", 537 | dest="sign_tags", 538 | help="Do not sign tags if created", 539 | default=argparse.SUPPRESS, 540 | ) 541 | parser3.add_argument( 542 | "--tag-name", 543 | metavar="TAG_NAME", 544 | help="Tag name (only works with --tag)", 545 | default=defaults.get("tag_name", "v{new_version}"), 546 | ) 547 | parser3.add_argument( 548 | "--tag-message", 549 | metavar="TAG_MESSAGE", 550 | dest="tag_message", 551 | help="Tag message", 552 | default=defaults.get( 553 | "tag_message", "Bump version: {current_version} → {new_version}" 554 | ), 555 | ) 556 | parser3.add_argument( 557 | "--message", 558 | "-m", 559 | metavar="COMMIT_MSG", 560 | help="Commit message", 561 | default=defaults.get( 562 | "message", "Bump version: {current_version} → {new_version}" 563 | ), 564 | ) 565 | parser3.add_argument( 566 | "--commit-args", 567 | metavar="COMMIT_ARGS", 568 | help="Extra arguments to commit command", 569 | default=defaults.get("commit_args", ""), 570 | ) 571 | file_names = [] 572 | if "files" in defaults: 573 | assert defaults["files"] is not None 574 | file_names = defaults["files"].split(" ") 575 | parser3.add_argument("part", help="Part of the version to be bumped.") 576 | parser3.add_argument( 577 | "files", metavar="file", nargs="*", help="Files to change", default=file_names 578 | ) 579 | args = parser3.parse_args(remaining_argv + positionals) 580 | 581 | if args.dry_run: 582 | logger.info("Dry run active, won't touch any files.") 583 | 584 | return args, file_names 585 | 586 | 587 | def _parse_new_version(args, new_version, vc): 588 | if args.new_version: 589 | new_version = vc.parse(args.new_version) 590 | logger.info("New version will be '%s'", args.new_version) 591 | return new_version 592 | 593 | 594 | def _determine_vcs_dirty(possible_vcses, defaults): 595 | for vcs in possible_vcses: 596 | if not vcs.is_usable(): 597 | continue 598 | 599 | try: 600 | vcs.assert_nondirty() 601 | except WorkingDirectoryIsDirtyException as e: 602 | if not defaults["allow_dirty"]: 603 | logger.warning( 604 | "%s\n\nUse --allow-dirty to override this if you know what you're doing.", 605 | e.message, 606 | ) 607 | raise 608 | 609 | return vcs 610 | 611 | return None 612 | 613 | 614 | def _check_files_contain_version(files, current_version, context): 615 | # make sure files exist and contain version string 616 | logger.info( 617 | "Asserting files %s contain the version string...", 618 | ", ".join([str(f) for f in files]), 619 | ) 620 | for f in files: 621 | f.should_contain_version(current_version, context) 622 | 623 | 624 | def _replace_version_in_files(files, current_version, new_version, dry_run, context): 625 | # change version string in files 626 | for f in files: 627 | f.replace(current_version, new_version, context, dry_run) 628 | 629 | 630 | def _log_list(config, new_version): 631 | config.set("bumpversion", "new_version", new_version) 632 | for key, value in config.items("bumpversion"): 633 | logger_list.info("%s=%s", key, value) 634 | config.remove_option("bumpversion", "new_version") 635 | 636 | 637 | def _update_config_file( 638 | config, config_file, config_newlines, config_file_exists, new_version, dry_run, 639 | ): 640 | config.set("bumpversion", "current_version", new_version) 641 | new_config = io.StringIO() 642 | try: 643 | write_to_config_file = (not dry_run) and config_file_exists 644 | 645 | logger.info( 646 | "%s to config file %s:", 647 | "Would write" if not write_to_config_file else "Writing", 648 | config_file, 649 | ) 650 | 651 | config.write(new_config) 652 | logger.info(new_config.getvalue()) 653 | 654 | if write_to_config_file: 655 | with open(config_file, "wt", encoding="utf-8", newline=config_newlines) as f: 656 | f.write(new_config.getvalue().strip() + "\n") 657 | 658 | except UnicodeEncodeError: 659 | warnings.warn( 660 | "Unable to write UTF-8 to config file, because of an old configparser version. " 661 | "Update with `pip install --upgrade configparser`." 662 | ) 663 | 664 | 665 | def _commit_to_vcs(files, context, config_file, config_file_exists, vcs, args, 666 | current_version, new_version): 667 | commit_files = [f.path for f in files] 668 | if config_file_exists: 669 | commit_files.append(config_file) 670 | assert vcs.is_usable(), "Did find '{}' unusable, unable to commit.".format( 671 | vcs.__name__ 672 | ) 673 | do_commit = args.commit and not args.dry_run 674 | logger.info( 675 | "%s %s commit", 676 | "Would prepare" if not do_commit else "Preparing", 677 | vcs.__name__, 678 | ) 679 | for path in commit_files: 680 | logger.info( 681 | "%s changes in file '%s' to %s", 682 | "Would add" if not do_commit else "Adding", 683 | path, 684 | vcs.__name__, 685 | ) 686 | 687 | if do_commit: 688 | vcs.add_path(path) 689 | 690 | context = { 691 | "current_version": args.current_version, 692 | "new_version": args.new_version, 693 | } 694 | context.update(time_context) 695 | context.update(prefixed_environ()) 696 | context.update({'current_' + part: current_version[part].value for part in current_version}) 697 | context.update({'new_' + part: new_version[part].value for part in new_version}) 698 | context.update(special_char_context) 699 | 700 | commit_message = args.message.format(**context) 701 | 702 | logger.info( 703 | "%s to %s with message '%s'", 704 | "Would commit" if not do_commit else "Committing", 705 | vcs.__name__, 706 | commit_message, 707 | ) 708 | if do_commit: 709 | vcs.commit( 710 | message=commit_message, 711 | context=context, 712 | extra_args=[arg.strip() for arg in args.commit_args.splitlines()], 713 | ) 714 | return context 715 | 716 | 717 | def _tag_in_vcs(vcs, context, args): 718 | sign_tags = args.sign_tags 719 | tag_name = args.tag_name.format(**context) 720 | tag_message = args.tag_message.format(**context) 721 | do_tag = args.tag and not args.dry_run 722 | logger.info( 723 | "%s '%s' %s in %s and %s", 724 | "Would tag" if not do_tag else "Tagging", 725 | tag_name, 726 | "with message '{}'".format(tag_message) if tag_message else "without message", 727 | vcs.__name__, 728 | "signing" if sign_tags else "not signing", 729 | ) 730 | if do_tag: 731 | vcs.tag(sign_tags, tag_name, tag_message) 732 | -------------------------------------------------------------------------------- /bumpversion/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class BumpVersionException(Exception): 4 | """Custom base class for all BumpVersion exception types.""" 5 | 6 | 7 | class IncompleteVersionRepresentationException(BumpVersionException): 8 | def __init__(self, message): 9 | self.message = message 10 | 11 | 12 | class MissingValueForSerializationException(BumpVersionException): 13 | def __init__(self, message): 14 | self.message = message 15 | 16 | 17 | class WorkingDirectoryIsDirtyException(BumpVersionException): 18 | def __init__(self, message): 19 | self.message = message 20 | 21 | 22 | class MercurialDoesNotSupportSignedTagsException(BumpVersionException): 23 | def __init__(self, message): 24 | self.message = message 25 | 26 | 27 | class VersionNotFoundException(BumpVersionException): 28 | """A version number was not found in a source file.""" 29 | 30 | 31 | class InvalidVersionPartException(BumpVersionException): 32 | """The specified part (e.g. 'bugfix') was not found""" 33 | -------------------------------------------------------------------------------- /bumpversion/functions.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | class NumericFunction: 5 | 6 | """ 7 | This is a class that provides a numeric function for version parts. 8 | It simply starts with the provided first_value (0 by default) and 9 | increases it following the sequence of integer numbers. 10 | 11 | The optional value of this function is equal to the first value. 12 | 13 | This function also supports alphanumeric parts, altering just the numeric 14 | part (e.g. 'r3' --> 'r4'). Only the first numeric group found in the part is 15 | considered (e.g. 'r3-001' --> 'r4-001'). 16 | """ 17 | 18 | FIRST_NUMERIC = re.compile(r"([^\d]*)(\d+)(.*)") 19 | 20 | def __init__(self, first_value=None, independent=False): 21 | 22 | if first_value is not None: 23 | try: 24 | _, _, _ = self.FIRST_NUMERIC.search( 25 | first_value 26 | ).groups() 27 | except AttributeError: 28 | raise ValueError( 29 | "The given first value {} does not contain any digit".format(first_value) 30 | ) 31 | else: 32 | first_value = 0 33 | 34 | self.first_value = str(first_value) 35 | self.optional_value = self.first_value 36 | self.independent = independent 37 | 38 | def bump(self, value): 39 | part_prefix, part_numeric, part_suffix = self.FIRST_NUMERIC.search( 40 | value 41 | ).groups() 42 | bumped_numeric = int(part_numeric) + 1 43 | 44 | return "".join([part_prefix, str(bumped_numeric), part_suffix]) 45 | 46 | 47 | class ValuesFunction: 48 | 49 | """ 50 | This is a class that provides a values list based function for version parts. 51 | It is initialized with a list of values and iterates through them when 52 | bumping the part. 53 | 54 | The default optional value of this function is equal to the first value, 55 | but may be otherwise specified. 56 | 57 | When trying to bump a part which has already the maximum value in the list 58 | you get a ValueError exception. 59 | """ 60 | 61 | def __init__(self, values, optional_value=None, first_value=None, independent=False): 62 | 63 | if not values: 64 | raise ValueError("Version part values cannot be empty") 65 | 66 | self._values = values 67 | 68 | if optional_value is None: 69 | optional_value = values[0] 70 | 71 | if optional_value not in values: 72 | raise ValueError( 73 | "Optional value {} must be included in values {}".format( 74 | optional_value, values 75 | ) 76 | ) 77 | 78 | self.optional_value = optional_value 79 | 80 | if first_value is None: 81 | first_value = values[0] 82 | 83 | if first_value not in values: 84 | raise ValueError( 85 | "First value {} must be included in values {}".format( 86 | first_value, values 87 | ) 88 | ) 89 | 90 | self.first_value = first_value 91 | self.independent = independent 92 | 93 | def bump(self, value): 94 | try: 95 | return self._values[self._values.index(value) + 1] 96 | except IndexError: 97 | raise ValueError( 98 | "The part has already the maximum value among {} and cannot be bumped.".format( 99 | self._values 100 | ) 101 | ) 102 | -------------------------------------------------------------------------------- /bumpversion/utils.py: -------------------------------------------------------------------------------- 1 | from argparse import _AppendAction 2 | from difflib import unified_diff 3 | import io 4 | import logging 5 | import os 6 | 7 | from bumpversion.exceptions import VersionNotFoundException 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class DiscardDefaultIfSpecifiedAppendAction(_AppendAction): 13 | 14 | """ 15 | Fixes bug http://bugs.python.org/issue16399 for 'append' action 16 | """ 17 | 18 | def __call__(self, parser, namespace, values, option_string=None): 19 | if getattr(self, "_discarded_default", None) is None: 20 | setattr(namespace, self.dest, []) 21 | self._discarded_default = True # pylint: disable=attribute-defined-outside-init 22 | 23 | super().__call__( 24 | parser, namespace, values, option_string=None 25 | ) 26 | 27 | 28 | def keyvaluestring(d): 29 | return ", ".join("{}={}".format(k, v) for k, v in sorted(d.items())) 30 | 31 | 32 | def prefixed_environ(): 33 | return {"${}".format(key): value for key, value in os.environ.items()} 34 | 35 | 36 | class ConfiguredFile: 37 | 38 | def __init__(self, path, versionconfig): 39 | self.path = path 40 | self._versionconfig = versionconfig 41 | 42 | def should_contain_version(self, version, context): 43 | """ 44 | Raise VersionNotFound if the version number isn't present in this file. 45 | 46 | Return normally if the version number is in fact present. 47 | """ 48 | context["current_version"] = self._versionconfig.serialize(version, context) 49 | search_expression = self._versionconfig.search.format(**context) 50 | 51 | if self.contains(search_expression): 52 | return 53 | 54 | # the `search` pattern did not match, but the original supplied 55 | # version number (representing the same version part values) might 56 | # match instead. 57 | 58 | # check whether `search` isn't customized, i.e. should match only 59 | # very specific parts of the file 60 | search_pattern_is_default = self._versionconfig.search == "{current_version}" 61 | 62 | if search_pattern_is_default and self.contains(version.original): 63 | # original version is present and we're not looking for something 64 | # more specific -> this is accepted as a match 65 | return 66 | 67 | # version not found 68 | raise VersionNotFoundException( 69 | "Did not find '{}' in file: '{}'".format( 70 | search_expression, self.path 71 | ) 72 | ) 73 | 74 | def contains(self, search): 75 | if not search: 76 | return False 77 | 78 | with open(self.path, "rt", encoding="utf-8") as f: 79 | search_lines = search.splitlines() 80 | lookbehind = [] 81 | 82 | for lineno, line in enumerate(f.readlines()): 83 | lookbehind.append(line.rstrip("\n")) 84 | 85 | if len(lookbehind) > len(search_lines): 86 | lookbehind = lookbehind[1:] 87 | 88 | if ( 89 | search_lines[0] in lookbehind[0] 90 | and search_lines[-1] in lookbehind[-1] 91 | and search_lines[1:-1] == lookbehind[1:-1] 92 | ): 93 | logger.info( 94 | "Found '%s' in %s at line %s: %s", 95 | search, 96 | self.path, 97 | lineno - (len(lookbehind) - 1), 98 | line.rstrip(), 99 | ) 100 | return True 101 | return False 102 | 103 | def replace(self, current_version, new_version, context, dry_run): 104 | 105 | with open(self.path, "rt", encoding="utf-8") as f: 106 | file_content_before = f.read() 107 | file_new_lines = f.newlines 108 | 109 | context["current_version"] = self._versionconfig.serialize( 110 | current_version, context 111 | ) 112 | context["new_version"] = self._versionconfig.serialize(new_version, context) 113 | 114 | search_for = self._versionconfig.search.format(**context) 115 | replace_with = self._versionconfig.replace.format(**context) 116 | 117 | file_content_after = file_content_before.replace(search_for, replace_with) 118 | 119 | if file_content_before == file_content_after: 120 | # TODO expose this to be configurable 121 | file_content_after = file_content_before.replace( 122 | current_version.original, replace_with 123 | ) 124 | 125 | if file_content_before != file_content_after: 126 | logger.info("%s file %s:", "Would change" if dry_run else "Changing", self.path) 127 | logger.info( 128 | "\n".join( 129 | list( 130 | unified_diff( 131 | file_content_before.splitlines(), 132 | file_content_after.splitlines(), 133 | lineterm="", 134 | fromfile="a/" + self.path, 135 | tofile="b/" + self.path, 136 | ) 137 | ) 138 | ) 139 | ) 140 | else: 141 | logger.info("%s file %s", "Would not change" if dry_run else "Not changing", self.path) 142 | 143 | if not dry_run: 144 | with open(self.path, "wt", encoding="utf-8", newline=file_new_lines) as f: 145 | f.write(file_content_after) 146 | 147 | def __str__(self): 148 | return self.path 149 | 150 | def __repr__(self): 151 | return "".format(self.path) 152 | -------------------------------------------------------------------------------- /bumpversion/vcs.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import logging 3 | import os 4 | import subprocess 5 | from tempfile import NamedTemporaryFile 6 | 7 | from bumpversion.exceptions import ( 8 | WorkingDirectoryIsDirtyException, 9 | MercurialDoesNotSupportSignedTagsException, 10 | ) 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class BaseVCS: 17 | 18 | _TEST_USABLE_COMMAND = None 19 | _COMMIT_COMMAND = None 20 | 21 | @classmethod 22 | def commit(cls, message, context, extra_args=None): 23 | extra_args = extra_args or [] 24 | with NamedTemporaryFile("wb", delete=False) as f: 25 | f.write(message.encode("utf-8")) 26 | env = os.environ.copy() 27 | env["HGENCODING"] = "utf-8" 28 | for key in ("current_version", "new_version"): 29 | env[str("BUMPVERSION_" + key.upper())] = str(context[key]) 30 | try: 31 | subprocess.check_output( 32 | cls._COMMIT_COMMAND + [f.name] + extra_args, env=env 33 | ) 34 | except subprocess.CalledProcessError as exc: 35 | err_msg = "Failed to run {}: return code {}, output: {}".format( 36 | exc.cmd, exc.returncode, exc.output 37 | ) 38 | logger.exception(err_msg) 39 | raise exc 40 | finally: 41 | os.unlink(f.name) 42 | 43 | @classmethod 44 | def is_usable(cls): 45 | try: 46 | return ( 47 | subprocess.call( 48 | cls._TEST_USABLE_COMMAND, 49 | stderr=subprocess.PIPE, 50 | stdout=subprocess.PIPE, 51 | ) 52 | == 0 53 | ) 54 | except OSError as e: 55 | if e.errno in (errno.ENOENT, errno.EACCES, errno.ENOTDIR): 56 | return False 57 | raise 58 | 59 | 60 | class Git(BaseVCS): 61 | 62 | _TEST_USABLE_COMMAND = ["git", "rev-parse", "--git-dir"] 63 | _COMMIT_COMMAND = ["git", "commit", "-F"] 64 | 65 | @classmethod 66 | def assert_nondirty(cls): 67 | lines = [ 68 | line.strip() 69 | for line in subprocess.check_output( 70 | ["git", "status", "--porcelain"] 71 | ).splitlines() 72 | if not line.strip().startswith(b"??") 73 | ] 74 | 75 | if lines: 76 | raise WorkingDirectoryIsDirtyException( 77 | "Git working directory is not clean:\n{}".format( 78 | b"\n".join(lines).decode() 79 | ) 80 | ) 81 | 82 | @classmethod 83 | def latest_tag_info(cls): 84 | try: 85 | # git-describe doesn't update the git-index, so we do that 86 | subprocess.check_output(["git", "update-index", "--refresh"]) 87 | 88 | # get info about the latest tag in git 89 | describe_out = ( 90 | subprocess.check_output( 91 | [ 92 | "git", 93 | "describe", 94 | "--dirty", 95 | "--tags", 96 | "--long", 97 | "--abbrev=40", 98 | "--match=v*", 99 | ], 100 | stderr=subprocess.STDOUT, 101 | ) 102 | .decode() 103 | .split("-") 104 | ) 105 | except subprocess.CalledProcessError: 106 | logger.debug("Error when running git describe") 107 | return {} 108 | 109 | info = {} 110 | 111 | if describe_out[-1].strip() == "dirty": 112 | info["dirty"] = True 113 | describe_out.pop() 114 | 115 | info["commit_sha"] = describe_out.pop().lstrip("g") 116 | info["distance_to_latest_tag"] = int(describe_out.pop()) 117 | info["current_version"] = "-".join(describe_out).lstrip("v") 118 | 119 | return info 120 | 121 | @classmethod 122 | def add_path(cls, path): 123 | subprocess.check_output(["git", "add", "--update", path]) 124 | 125 | @classmethod 126 | def tag(cls, sign, name, message): 127 | """ 128 | Create a tag of the new_version in VCS. 129 | 130 | If only name is given, bumpversion uses a lightweight tag. 131 | Otherwise, it utilizes an annotated tag. 132 | """ 133 | command = ["git", "tag", name] 134 | if sign: 135 | command += ["--sign"] 136 | if message: 137 | command += ["--message", message] 138 | subprocess.check_output(command) 139 | 140 | 141 | class Mercurial(BaseVCS): 142 | 143 | _TEST_USABLE_COMMAND = ["hg", "root"] 144 | _COMMIT_COMMAND = ["hg", "commit", "--logfile"] 145 | 146 | @classmethod 147 | def latest_tag_info(cls): 148 | return {} 149 | 150 | @classmethod 151 | def assert_nondirty(cls): 152 | lines = [ 153 | line.strip() 154 | for line in subprocess.check_output(["hg", "status", "-mard"]).splitlines() 155 | if not line.strip().startswith(b"??") 156 | ] 157 | 158 | if lines: 159 | raise WorkingDirectoryIsDirtyException( 160 | "Mercurial working directory is not clean:\n{}".format( 161 | b"\n".join(lines).decode() 162 | ) 163 | ) 164 | 165 | @classmethod 166 | def add_path(cls, path): 167 | pass 168 | 169 | @classmethod 170 | def tag(cls, sign, name, message): 171 | command = ["hg", "tag", name] 172 | if sign: 173 | raise MercurialDoesNotSupportSignedTagsException( 174 | "Mercurial does not support signed tags." 175 | ) 176 | if message: 177 | command += ["--message", message] 178 | subprocess.check_output(command) 179 | -------------------------------------------------------------------------------- /bumpversion/version_part.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import sre_constants 4 | import string 5 | 6 | from bumpversion.exceptions import ( 7 | MissingValueForSerializationException, 8 | IncompleteVersionRepresentationException, 9 | InvalidVersionPartException, 10 | ) 11 | from bumpversion.functions import NumericFunction, ValuesFunction 12 | from bumpversion.utils import keyvaluestring 13 | 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class PartConfiguration: 19 | 20 | function_cls = NumericFunction 21 | 22 | def __init__(self, *args, **kwds): 23 | self.function = self.function_cls(*args, **kwds) 24 | 25 | @property 26 | def first_value(self): 27 | return str(self.function.first_value) 28 | 29 | @property 30 | def optional_value(self): 31 | return str(self.function.optional_value) 32 | 33 | @property 34 | def independent(self): 35 | return self.function.independent 36 | 37 | def bump(self, value=None): 38 | return self.function.bump(value) 39 | 40 | 41 | class ConfiguredVersionPartConfiguration(PartConfiguration): 42 | 43 | function_cls = ValuesFunction 44 | 45 | 46 | class NumericVersionPartConfiguration(PartConfiguration): 47 | 48 | function_cls = NumericFunction 49 | 50 | 51 | class VersionPart: 52 | """ 53 | Represent part of a version number. 54 | 55 | Offer a self.config object that rules how the part behaves when 56 | increased or reset. 57 | """ 58 | 59 | def __init__(self, value, config=None): 60 | self._value = value 61 | 62 | if config is None: 63 | config = NumericVersionPartConfiguration() 64 | 65 | self.config = config 66 | 67 | @property 68 | def value(self): 69 | return self._value or self.config.optional_value 70 | 71 | def copy(self): 72 | return VersionPart(self._value) 73 | 74 | def bump(self): 75 | return VersionPart(self.config.bump(self.value), self.config) 76 | 77 | def is_optional(self): 78 | return self.value == self.config.optional_value 79 | 80 | def is_independent(self): 81 | return self.config.independent 82 | 83 | def __format__(self, format_spec): 84 | return self.value 85 | 86 | def __repr__(self): 87 | return "".format( 88 | self.config.__class__.__name__, self.value 89 | ) 90 | 91 | def __eq__(self, other): 92 | return self.value == other.value 93 | 94 | def null(self): 95 | return VersionPart(self.config.first_value, self.config) 96 | 97 | 98 | class Version: 99 | 100 | def __init__(self, values, original=None): 101 | self._values = dict(values) 102 | self.original = original 103 | 104 | def __getitem__(self, key): 105 | return self._values[key] 106 | 107 | def __len__(self): 108 | return len(self._values) 109 | 110 | def __iter__(self): 111 | return iter(self._values) 112 | 113 | def __repr__(self): 114 | return "".format(keyvaluestring(self._values)) 115 | 116 | def bump(self, part_name, order): 117 | bumped = False 118 | 119 | new_values = {} 120 | 121 | for label in order: 122 | if label not in self._values: 123 | continue 124 | if label == part_name: 125 | new_values[label] = self._values[label].bump() 126 | bumped = True 127 | elif bumped and not self._values[label].is_independent(): 128 | new_values[label] = self._values[label].null() 129 | else: 130 | new_values[label] = self._values[label].copy() 131 | 132 | if not bumped: 133 | raise InvalidVersionPartException("No part named %r" % part_name) 134 | 135 | new_version = Version(new_values) 136 | 137 | return new_version 138 | 139 | 140 | def labels_for_format(serialize_format): 141 | return ( 142 | label for _, label, _, _ in string.Formatter().parse(serialize_format) if label 143 | ) 144 | 145 | 146 | class VersionConfig: 147 | """ 148 | Hold a complete representation of a version string. 149 | """ 150 | 151 | def __init__(self, parse, serialize, search, replace, part_configs=None): 152 | try: 153 | self.parse_regex = re.compile(parse, re.VERBOSE) 154 | except sre_constants.error as e: 155 | # TODO: use re.error here mayhaps 156 | logger.error("--parse '%s' is not a valid regex", parse) 157 | raise e 158 | 159 | self.serialize_formats = serialize 160 | 161 | if not part_configs: 162 | part_configs = {} 163 | 164 | self.part_configs = part_configs 165 | self.search = search 166 | self.replace = replace 167 | 168 | def order(self): 169 | # currently, order depends on the first given serialization format 170 | # this seems like a good idea because this should be the most complete format 171 | return labels_for_format(self.serialize_formats[0]) 172 | 173 | def parse(self, version_string): 174 | if not version_string: 175 | return None 176 | 177 | regexp_one_line = "".join( 178 | [l.split("#")[0].strip() for l in self.parse_regex.pattern.splitlines()] 179 | ) 180 | 181 | logger.info( 182 | "Parsing version '%s' using regexp '%s'", 183 | version_string, 184 | regexp_one_line, 185 | ) 186 | 187 | match = self.parse_regex.search(version_string) 188 | 189 | _parsed = {} 190 | if not match: 191 | logger.warning( 192 | "Evaluating 'parse' option: '%s' does not parse current version '%s'", 193 | self.parse_regex.pattern, 194 | version_string, 195 | ) 196 | return None 197 | 198 | for key, value in match.groupdict().items(): 199 | _parsed[key] = VersionPart(value, self.part_configs.get(key)) 200 | 201 | v = Version(_parsed, version_string) 202 | 203 | logger.info("Parsed the following values: %s", keyvaluestring(v._values)) 204 | 205 | return v 206 | 207 | def _serialize(self, version, serialize_format, context, raise_if_incomplete=False): 208 | """ 209 | Attempts to serialize a version with the given serialization format. 210 | 211 | Raises MissingValueForSerializationException if not serializable 212 | """ 213 | values = context.copy() 214 | for k in version: 215 | values[k] = version[k] 216 | 217 | # TODO dump complete context on debug level 218 | 219 | try: 220 | # test whether all parts required in the format have values 221 | serialized = serialize_format.format(**values) 222 | 223 | except KeyError as e: 224 | missing_key = getattr(e, "message", e.args[0]) 225 | raise MissingValueForSerializationException( 226 | "Did not find key {} in {} when serializing version number".format( 227 | repr(missing_key), repr(version) 228 | ) 229 | ) 230 | 231 | keys_needing_representation = set() 232 | 233 | keys = list(self.order()) 234 | for i, k in enumerate(keys): 235 | v = values[k] 236 | 237 | if not isinstance(v, VersionPart): 238 | # values coming from environment variables don't need 239 | # representation 240 | continue 241 | 242 | if not v.is_optional(): 243 | keys_needing_representation = set(keys[:i+1]) 244 | 245 | required_by_format = set(labels_for_format(serialize_format)) 246 | 247 | # try whether all parsed keys are represented 248 | if raise_if_incomplete: 249 | if not keys_needing_representation <= required_by_format: 250 | raise IncompleteVersionRepresentationException( 251 | "Could not represent '{}' in format '{}'".format( 252 | "', '".join(keys_needing_representation ^ required_by_format), 253 | serialize_format, 254 | ) 255 | ) 256 | 257 | return serialized 258 | 259 | def _choose_serialize_format(self, version, context): 260 | chosen = None 261 | 262 | logger.debug("Available serialization formats: '%s'", "', '".join(self.serialize_formats)) 263 | 264 | for serialize_format in self.serialize_formats: 265 | try: 266 | self._serialize( 267 | version, serialize_format, context, raise_if_incomplete=True 268 | ) 269 | # Prefer shorter or first search expression. 270 | chosen_part_count = None if not chosen else len(list(string.Formatter().parse(chosen))) 271 | serialize_part_count = len(list(string.Formatter().parse(serialize_format))) 272 | if not chosen or chosen_part_count > serialize_part_count: 273 | chosen = serialize_format 274 | logger.debug("Found '%s' to be a usable serialization format", chosen) 275 | else: 276 | logger.debug("Found '%s' usable serialization format, but it's longer", serialize_format) 277 | except IncompleteVersionRepresentationException as e: 278 | # If chosen, prefer shorter 279 | if not chosen: 280 | chosen = serialize_format 281 | except MissingValueForSerializationException as e: 282 | logger.info(e.message) 283 | raise e 284 | 285 | if not chosen: 286 | raise KeyError("Did not find suitable serialization format") 287 | 288 | logger.debug("Selected serialization format '%s'", chosen) 289 | 290 | return chosen 291 | 292 | def serialize(self, version, context): 293 | serialized = self._serialize( 294 | version, self._choose_serialize_format(version, context), context 295 | ) 296 | logger.debug("Serialized to '%s'", serialized) 297 | return serialized 298 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' 2 | services: 3 | 4 | test: 5 | build: 6 | context: . 7 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/1.0.0/.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.0.0 3 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)([-](?P(dev|rc))+(?P\d+))? 4 | serialize = 5 | {major}.{minor}.{patch}-{release}{build} 6 | {major}.{minor}.{patch} 7 | 8 | [bumpversion:part:release] 9 | first_value = dev 10 | optional_value = ga 11 | values = 12 | dev 13 | rc 14 | ga 15 | 16 | [bumpversion:part:build] 17 | first_value = 1 18 | 19 | [bumpversion:file:VERSION.txt] 20 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/1.0.0/VERSION.txt: -------------------------------------------------------------------------------- 1 | 1.0.0 2 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/1.0.0/bump-patch/ARGUMENTS.txt: -------------------------------------------------------------------------------- 1 | patch 2 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/1.0.0/bump-patch/VERSION.txt: -------------------------------------------------------------------------------- 1 | 1.0.1-dev1 2 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/1.0.1-dev1/.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.0.0 3 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)([-](?P(dev|rc))+(?P\d+))? 4 | serialize = 5 | {major}.{minor}.{patch}-{release}{build} 6 | {major}.{minor}.{patch} 7 | 8 | [bumpversion:part:release] 9 | first_value = dev 10 | optional_value = ga 11 | values = 12 | dev 13 | rc 14 | ga 15 | 16 | [bumpversion:part:build] 17 | first_value = 1 18 | 19 | [bumpversion:file:VERSION.txt] 20 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/1.0.1-dev1/VERSION.txt: -------------------------------------------------------------------------------- 1 | 1.0.0 2 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/1.0.1-dev1/bump-build/ARGUMENTS.txt: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/1.0.1-dev1/bump-build/VERSION.txt: -------------------------------------------------------------------------------- 1 | 1.0.1-dev2 2 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/1.0.1-dev1/bump-release/ARGUMENTS.txt: -------------------------------------------------------------------------------- 1 | release 2 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/1.0.1-dev1/bump-release/VERSION.txt: -------------------------------------------------------------------------------- 1 | 1.0.1-rc1 2 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/1.0.1-rc1/.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.0.0 3 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)([-](?P(dev|rc))+(?P\d+))? 4 | serialize = 5 | {major}.{minor}.{patch}-{release}{build} 6 | {major}.{minor}.{patch} 7 | 8 | [bumpversion:part:release] 9 | first_value = dev 10 | optional_value = ga 11 | values = 12 | dev 13 | rc 14 | ga 15 | 16 | [bumpversion:part:build] 17 | first_value = 1 18 | 19 | [bumpversion:file:VERSION.txt] 20 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/1.0.1-rc1/VERSION.txt: -------------------------------------------------------------------------------- 1 | 1.0.0 2 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/1.0.1-rc1/bump-build/ARGUMENTS.txt: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/1.0.1-rc1/bump-build/VERSION.txt: -------------------------------------------------------------------------------- 1 | 1.0.1-rc2 2 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/1.0.1-rc1/bump-release/ARGUMENTS.txt: -------------------------------------------------------------------------------- 1 | release 2 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/1.0.1-rc1/bump-release/VERSION.txt: -------------------------------------------------------------------------------- 1 | 1.0.1 2 | -------------------------------------------------------------------------------- /docs/examples/semantic-versioning/README.md: -------------------------------------------------------------------------------- 1 | # Semantic versioning example 2 | 3 | bumpversion flow: 4 | 5 | 1.0.0 => 1.0.1-dev1 => 1.0.1-dev2 = > 1.0.1-rc1 => 1.0.1-rc2 => 1.0.1 6 | patch build release build release 7 | 8 | ## Details 9 | 10 | Start with an initial release, say `1.0.0`. 11 | 12 | 1. Create a new release, starting with a development build. 13 | 14 | $ bumpversion patch 15 | => 1.0.1-dev1 16 | 17 | 2. Every time you build, bump `build`. 18 | 19 | $ bumpversion build 20 | => 1.0.1-dev2 21 | 22 | 3. Go to release candidate by bumping `release`. 23 | 24 | $ bumpversion release 25 | => 1.0.1-rc1 26 | 27 | 4. With every new build, bump `build`. 28 | 29 | $ bumpversion build 30 | => 1.0.1-rc2 31 | 32 | 4. Finally, bump `release` to generate a final release for the current 33 | `major` / `minor` / `patch` version. 34 | 35 | $ bumpversion release 36 | => 1.0.1 37 | 38 | 39 | ## Notes 40 | 41 | * Once the final release has been reached, it is not possible to bump 42 | the `release` before bumping `patch` again. Trying to bump the release 43 | while in final release state will issue 44 | `ValueError: The part has already the maximum value among ['dev', 'rc', 'ga'] and cannot be bumped`. 45 | 46 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | 4 | description = 'Version-bump your software with a single command!' 5 | 6 | # Import the README and use it as the long-description. 7 | # This requires 'README.md' to be present in MANIFEST.in. 8 | here = os.path.abspath(os.path.dirname(__file__)) 9 | with open(os.path.join(here, 'README.md'), encoding='utf-8') as f: 10 | long_description = '\n' + f.read() 11 | 12 | setup( 13 | name='bump2version', 14 | version='1.0.2-dev', 15 | url='https://github.com/c4urself/bump2version', 16 | author='Christian Verkerk', 17 | author_email='christianverkerk@ymail.com', 18 | license='MIT', 19 | packages=['bumpversion'], 20 | description=description, 21 | long_description=long_description, 22 | long_description_content_type='text/markdown', 23 | entry_points={ 24 | 'console_scripts': [ 25 | 'bumpversion = bumpversion.cli:main', 26 | 'bump2version = bumpversion.cli:main', 27 | ] 28 | }, 29 | python_requires='>=3.5', 30 | classifiers=[ 31 | 'Development Status :: 4 - Beta', 32 | 'Intended Audience :: Developers', 33 | 'License :: OSI Approved :: MIT License', 34 | 'Operating System :: OS Independent', 35 | 'Programming Language :: Python', 36 | 'Programming Language :: Python :: 3', 37 | 'Programming Language :: Python :: 3.5', 38 | 'Programming Language :: Python :: 3.6', 39 | 'Programming Language :: Python :: 3.7', 40 | 'Programming Language :: Python :: 3.8', 41 | 'Programming Language :: Python :: 3 :: Only', 42 | 'Programming Language :: Python :: Implementation :: PyPy', 43 | ], 44 | extras_require={ 45 | 'test': [ 46 | 'testfixtures>=1.2.0', 47 | 'pytest>=3.4.0', 48 | ], 49 | }, 50 | ) 51 | -------------------------------------------------------------------------------- /tests/test-cases/use-shortest-pattern/arguments.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c4urself/bump2version/1044c085ba7d32d73c9cd7ca4561a7ec624c6b19/tests/test-cases/use-shortest-pattern/arguments.txt -------------------------------------------------------------------------------- /tests/test-cases/use-shortest-pattern/input/.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.0.0 3 | commit = True 4 | tag = True 5 | parse = (?P\d+).(?P\d+).(?P\d+).?((?P.*))? 6 | serialize = 7 | {major}.{minor}.{patch}.{prerelease} 8 | {major}.{minor}.{patch} 9 | 10 | [bumpversion:file:package.json] 11 | search = "version": "{current_version}", 12 | replace = "version": "{new_version}", -------------------------------------------------------------------------------- /tests/test-cases/use-shortest-pattern/input/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "id":"package", 3 | "version":"0.0.0", 4 | "package":"20.0.0", 5 | } 6 | -------------------------------------------------------------------------------- /tests/test-cases/use-shortest-pattern/output/.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.0.0 3 | commit = True 4 | tag = True 5 | parse = (?P\d+).(?P\d+).(?P\d+).?((?P.*))? 6 | serialize = 7 | {major}.{minor}.{patch}.{prerelease} 8 | {major}.{minor}.{patch} 9 | 10 | [bumpversion:file:package.json] 11 | search = "version": "{current_version}", 12 | replace = "version": "{new_version}", -------------------------------------------------------------------------------- /tests/test-cases/use-shortest-pattern/output/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "id":"package", 3 | "version":"0.0.0", 4 | "package":"20.0.0", 5 | } 6 | -------------------------------------------------------------------------------- /tests/test-cases/use-shortest-pattern/run/.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.0.0 3 | commit = True 4 | tag = True 5 | parse = (?P\d+).(?P\d+).(?P\d+).?((?P.*))? 6 | serialize = 7 | {major}.{minor}.{patch}.{prerelease} 8 | {major}.{minor}.{patch} 9 | 10 | [bumpversion:file:package.json] 11 | search = "version": "{current_version}", 12 | replace = "version": "{new_version}", -------------------------------------------------------------------------------- /tests/test-cases/use-shortest-pattern/run/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "id":"package", 3 | "version":"0.0.0", 4 | "package":"20.0.0", 5 | } 6 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import os 4 | import platform 5 | import warnings 6 | import subprocess 7 | from configparser import RawConfigParser 8 | from datetime import datetime 9 | from functools import partial 10 | from shlex import split as shlex_split 11 | from textwrap import dedent 12 | from unittest import mock 13 | 14 | import pytest 15 | from testfixtures import LogCapture 16 | 17 | import bumpversion 18 | from bumpversion import exceptions 19 | from bumpversion.cli import DESCRIPTION, main, split_args_in_optional_and_positional 20 | 21 | 22 | def _get_subprocess_env(): 23 | env = os.environ.copy() 24 | env['HGENCODING'] = 'utf-8' 25 | return env 26 | 27 | 28 | SUBPROCESS_ENV = _get_subprocess_env() 29 | call = partial(subprocess.call, env=SUBPROCESS_ENV, shell=True) 30 | check_call = partial(subprocess.check_call, env=SUBPROCESS_ENV) 31 | check_output = partial(subprocess.check_output, env=SUBPROCESS_ENV) 32 | run = partial(subprocess.run, env=SUBPROCESS_ENV) 33 | 34 | xfail_if_no_git = pytest.mark.xfail( 35 | call("git version") != 0, 36 | reason="git is not installed" 37 | ) 38 | 39 | xfail_if_no_hg = pytest.mark.xfail( 40 | call("hg version") != 0, 41 | reason="hg is not installed" 42 | ) 43 | 44 | VCS_GIT = pytest.param("git", marks=xfail_if_no_git()) 45 | VCS_MERCURIAL = pytest.param("hg", marks=xfail_if_no_hg()) 46 | COMMIT = "[bumpversion]\ncommit = True" 47 | COMMIT_NOT_TAG = "[bumpversion]\ncommit = True\ntag = False" 48 | 49 | 50 | @pytest.fixture(params=[VCS_GIT, VCS_MERCURIAL]) 51 | def vcs(request): 52 | """Return all supported VCS systems (git, hg).""" 53 | return request.param 54 | 55 | 56 | @pytest.fixture(params=[VCS_GIT]) 57 | def git(request): 58 | """Return git as VCS (not hg).""" 59 | return request.param 60 | 61 | 62 | @pytest.fixture(params=['.bumpversion.cfg', 'setup.cfg']) 63 | def configfile(request): 64 | """Return both config-file styles ('.bumpversion.cfg', 'setup.cfg').""" 65 | return request.param 66 | 67 | 68 | @pytest.fixture(params=[ 69 | "file", 70 | "file(suffix)", 71 | "file (suffix with space)", 72 | "file (suffix lacking closing paren", 73 | ]) 74 | def file_keyword(request): 75 | """Return multiple possible styles for the bumpversion:file keyword.""" 76 | return request.param 77 | 78 | 79 | try: 80 | RawConfigParser(empty_lines_in_values=False) 81 | using_old_configparser = False 82 | except TypeError: 83 | using_old_configparser = True 84 | 85 | xfail_if_old_configparser = pytest.mark.xfail( 86 | using_old_configparser, 87 | reason="configparser doesn't support empty_lines_in_values" 88 | ) 89 | 90 | 91 | def _mock_calls_to_string(called_mock): 92 | return ["{}|{}|{}".format( 93 | name, 94 | args[0] if len(args) > 0 else args, 95 | repr(kwargs) if len(kwargs) > 0 else "" 96 | ) for name, args, kwargs in called_mock.mock_calls] 97 | 98 | 99 | EXPECTED_OPTIONS = r""" 100 | [-h] 101 | [--config-file FILE] 102 | [--verbose] 103 | [--list] 104 | [--allow-dirty] 105 | [--parse REGEX] 106 | [--serialize FORMAT] 107 | [--search SEARCH] 108 | [--replace REPLACE] 109 | [--current-version VERSION] 110 | [--no-configured-files] 111 | [--dry-run] 112 | --new-version VERSION 113 | [--commit | --no-commit] 114 | [--tag | --no-tag] 115 | [--sign-tags | --no-sign-tags] 116 | [--tag-name TAG_NAME] 117 | [--tag-message TAG_MESSAGE] 118 | [--message COMMIT_MSG] 119 | part 120 | [file ...] 121 | """.strip().splitlines() 122 | 123 | EXPECTED_USAGE = (r""" 124 | 125 | %s 126 | 127 | positional arguments: 128 | part Part of the version to be bumped. 129 | file Files to change (default: []) 130 | 131 | optional arguments: 132 | -h, --help show this help message and exit 133 | --config-file FILE Config file to read most of the variables from 134 | (default: .bumpversion.cfg) 135 | --verbose Print verbose logging to stderr (default: 0) 136 | --list List machine readable information (default: False) 137 | --allow-dirty Don't abort if working directory is dirty (default: 138 | False) 139 | --parse REGEX Regex parsing the version string (default: 140 | (?P\d+)\.(?P\d+)\.(?P\d+)) 141 | --serialize FORMAT How to format what is parsed back to a version 142 | (default: ['{major}.{minor}.{patch}']) 143 | --search SEARCH Template for complete string to search (default: 144 | {current_version}) 145 | --replace REPLACE Template for complete string to replace (default: 146 | {new_version}) 147 | --current-version VERSION 148 | Version that needs to be updated (default: None) 149 | --no-configured-files 150 | Only replace the version in files specified on the 151 | command line, ignoring the files from the 152 | configuration file. (default: False) 153 | --dry-run, -n Don't write any files, just pretend. (default: False) 154 | --new-version VERSION 155 | New version that should be in the files (default: 156 | None) 157 | --commit Commit to version control (default: False) 158 | --no-commit Do not commit to version control 159 | --tag Create a tag in version control (default: False) 160 | --no-tag Do not create a tag in version control 161 | --sign-tags Sign tags if created (default: False) 162 | --no-sign-tags Do not sign tags if created 163 | --tag-name TAG_NAME Tag name (only works with --tag) (default: 164 | v{new_version}) 165 | --tag-message TAG_MESSAGE 166 | Tag message (default: Bump version: {current_version} 167 | → {new_version}) 168 | --message COMMIT_MSG, -m COMMIT_MSG 169 | Commit message (default: Bump version: 170 | {current_version} → {new_version}) 171 | """ % DESCRIPTION).lstrip() 172 | 173 | 174 | def test_usage_string(tmpdir, capsys): 175 | tmpdir.chdir() 176 | 177 | with pytest.raises(SystemExit): 178 | main(['--help']) 179 | 180 | out, err = capsys.readouterr() 181 | assert err == "" 182 | 183 | for option_line in EXPECTED_OPTIONS: 184 | assert option_line in out, "Usage string is missing {}".format(option_line) 185 | 186 | assert EXPECTED_USAGE in out 187 | 188 | 189 | def test_usage_string_fork(tmpdir): 190 | tmpdir.chdir() 191 | 192 | if platform.system() == "Windows": 193 | # There are encoding problems on Windows with the encoding of → 194 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 195 | [bumpversion] 196 | message: Bump version: {current_version} to {new_version} 197 | tag_message: 'Bump version: {current_version} to {new_version} 198 | """)) 199 | 200 | try: 201 | out = check_output('bumpversion --help', shell=True, stderr=subprocess.STDOUT) 202 | except subprocess.CalledProcessError as e: 203 | out = e.output 204 | 205 | if b'usage: bumpversion [-h]' not in out: 206 | print(out) 207 | 208 | assert b'usage: bumpversion [-h]' in out 209 | 210 | 211 | def test_regression_help_in_work_dir(tmpdir, capsys, vcs): 212 | tmpdir.chdir() 213 | tmpdir.join("some_source.txt").write("1.7.2013") 214 | check_call([vcs, "init"]) 215 | check_call([vcs, "add", "some_source.txt"]) 216 | check_call([vcs, "commit", "-m", "initial commit"]) 217 | check_call([vcs, "tag", "v1.7.2013"]) 218 | 219 | with pytest.raises(SystemExit): 220 | main(['--help']) 221 | 222 | out, err = capsys.readouterr() 223 | 224 | for option_line in EXPECTED_OPTIONS: 225 | assert option_line in out, "Usage string is missing {}".format(option_line) 226 | 227 | if vcs == "git": 228 | assert "Version that needs to be updated (default: 1.7.2013)" in out 229 | else: 230 | assert EXPECTED_USAGE in out 231 | 232 | 233 | def test_defaults_in_usage_with_config(tmpdir, capsys): 234 | tmpdir.chdir() 235 | tmpdir.join("my_defaults.cfg").write("""[bumpversion] 236 | current_version: 18 237 | new_version: 19 238 | [bumpversion:file:file1] 239 | [bumpversion:file:file2] 240 | [bumpversion:file:file3]""") 241 | with pytest.raises(SystemExit): 242 | main(['--config-file', 'my_defaults.cfg', '--help']) 243 | 244 | out, err = capsys.readouterr() 245 | 246 | assert "Version that needs to be updated (default: 18)" in out 247 | assert "New version that should be in the files (default: 19)" in out 248 | assert "[--current-version VERSION]" in out 249 | assert "[--new-version VERSION]" in out 250 | assert "[file ...]" in out 251 | 252 | 253 | def test_missing_explicit_config_file(tmpdir): 254 | tmpdir.chdir() 255 | with pytest.raises(argparse.ArgumentTypeError): 256 | main(['--config-file', 'missing.cfg']) 257 | 258 | 259 | def test_simple_replacement(tmpdir): 260 | tmpdir.join("VERSION").write("1.2.0") 261 | tmpdir.chdir() 262 | main(shlex_split("patch --current-version 1.2.0 --new-version 1.2.1 VERSION")) 263 | assert "1.2.1" == tmpdir.join("VERSION").read() 264 | 265 | 266 | def test_simple_replacement_in_utf8_file(tmpdir): 267 | tmpdir.join("VERSION").write("Kröt1.3.0".encode(), 'wb') 268 | tmpdir.chdir() 269 | out = tmpdir.join("VERSION").read('rb') 270 | main(shlex_split("patch --verbose --current-version 1.3.0 --new-version 1.3.1 VERSION")) 271 | out = tmpdir.join("VERSION").read('rb') 272 | assert "'Kr\\xc3\\xb6t1.3.1'" in repr(out) 273 | 274 | 275 | def test_config_file(tmpdir): 276 | tmpdir.join("file1").write("0.9.34") 277 | tmpdir.join("my_bump_config.cfg").write("""[bumpversion] 278 | current_version: 0.9.34 279 | new_version: 0.9.35 280 | [bumpversion:file:file1]""") 281 | 282 | tmpdir.chdir() 283 | main(shlex_split("patch --config-file my_bump_config.cfg")) 284 | 285 | assert "0.9.35" == tmpdir.join("file1").read() 286 | 287 | 288 | def test_default_config_files(tmpdir, configfile): 289 | tmpdir.join("file2").write("0.10.2") 290 | tmpdir.join(configfile).write("""[bumpversion] 291 | current_version: 0.10.2 292 | new_version: 0.10.3 293 | [bumpversion:file:file2]""") 294 | 295 | tmpdir.chdir() 296 | main(['patch']) 297 | 298 | assert "0.10.3" == tmpdir.join("file2").read() 299 | 300 | 301 | def test_glob_keyword(tmpdir, configfile): 302 | tmpdir.join("file1.txt").write("0.9.34") 303 | tmpdir.join("file2.txt").write("0.9.34") 304 | tmpdir.join(configfile).write("""[bumpversion] 305 | current_version: 0.9.34 306 | new_version: 0.9.35 307 | [bumpversion:glob:*.txt]""") 308 | 309 | tmpdir.chdir() 310 | main(["patch"]) 311 | assert "0.9.35" == tmpdir.join("file1.txt").read() 312 | assert "0.9.35" == tmpdir.join("file2.txt").read() 313 | 314 | def test_glob_keyword_recursive(tmpdir, configfile): 315 | tmpdir.mkdir("subdir").mkdir("subdir2") 316 | file1 = tmpdir.join("subdir").join("file1.txt") 317 | file1.write("0.9.34") 318 | file2 = tmpdir.join("subdir").join("subdir2").join("file2.txt") 319 | file2.write("0.9.34") 320 | tmpdir.join(configfile).write("""[bumpversion] 321 | current_version: 0.9.34 322 | new_version: 0.9.35 323 | [bumpversion:glob:**/*.txt]""") 324 | 325 | tmpdir.chdir() 326 | main(["patch"]) 327 | assert "0.9.35" == file1.read() 328 | assert "0.9.35" == file2.read() 329 | 330 | 331 | def test_file_keyword_with_suffix_is_accepted(tmpdir, configfile, file_keyword): 332 | tmpdir.join("file2").write("0.10.2") 333 | tmpdir.join(configfile).write( 334 | """[bumpversion] 335 | current_version: 0.10.2 336 | new_version: 0.10.3 337 | [bumpversion:%s:file2] 338 | """ % file_keyword 339 | ) 340 | 341 | tmpdir.chdir() 342 | main(['patch']) 343 | 344 | assert "0.10.3" == tmpdir.join("file2").read() 345 | 346 | 347 | def test_multiple_config_files(tmpdir): 348 | tmpdir.join("file2").write("0.10.2") 349 | tmpdir.join("setup.cfg").write("""[bumpversion] 350 | current_version: 0.10.2 351 | new_version: 0.10.3 352 | [bumpversion:file:file2]""") 353 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion] 354 | current_version: 0.10.2 355 | new_version: 0.10.4 356 | [bumpversion:file:file2]""") 357 | 358 | tmpdir.chdir() 359 | main(['patch']) 360 | 361 | assert "0.10.4" == tmpdir.join("file2").read() 362 | 363 | 364 | def test_single_file_processed_twice(tmpdir): 365 | """ 366 | Verify that a single file "file2" can be processed twice. 367 | 368 | Use two file_ entries, both with a different suffix after 369 | the underscore. 370 | Employ different parse/serialize and search/replace configs 371 | to verify correct interpretation. 372 | """ 373 | tmpdir.join("file2").write("dots: 0.10.2\ndashes: 0-10-2") 374 | tmpdir.join("setup.cfg").write("""[bumpversion] 375 | current_version: 0.10.2 376 | new_version: 0.10.3 377 | [bumpversion:file:file2]""") 378 | tmpdir.join(".bumpversion.cfg").write(r"""[bumpversion] 379 | current_version: 0.10.2 380 | new_version: 0.10.4 381 | [bumpversion:file (version with dots):file2] 382 | search = dots: {current_version} 383 | replace = dots: {new_version} 384 | [bumpversion:file (version with dashes):file2] 385 | search = dashes: {current_version} 386 | replace = dashes: {new_version} 387 | parse = (?P\d+)-(?P\d+)-(?P\d+) 388 | serialize = {major}-{minor}-{patch} 389 | """) 390 | 391 | tmpdir.chdir() 392 | main(['patch']) 393 | 394 | assert "dots: 0.10.4\ndashes: 0-10-4" == tmpdir.join("file2").read() 395 | 396 | 397 | def test_config_file_is_updated(tmpdir): 398 | tmpdir.join("file3").write("0.0.13") 399 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion] 400 | current_version: 0.0.13 401 | new_version: 0.0.14 402 | [bumpversion:file:file3]""") 403 | 404 | tmpdir.chdir() 405 | main(['patch', '--verbose']) 406 | 407 | assert """[bumpversion] 408 | current_version = 0.0.14 409 | 410 | [bumpversion:file:file3] 411 | """ == tmpdir.join(".bumpversion.cfg").read() 412 | 413 | 414 | def test_dry_run(tmpdir, vcs): 415 | tmpdir.chdir() 416 | 417 | config = """[bumpversion] 418 | current_version = 0.12.0 419 | tag = True 420 | commit = True 421 | message = DO NOT BUMP VERSIONS WITH THIS FILE 422 | [bumpversion:file:file4] 423 | """ 424 | 425 | version = "0.12.0" 426 | 427 | tmpdir.join("file4").write(version) 428 | tmpdir.join(".bumpversion.cfg").write(config) 429 | 430 | check_call([vcs, "init"]) 431 | check_call([vcs, "add", "file4"]) 432 | check_call([vcs, "add", ".bumpversion.cfg"]) 433 | check_call([vcs, "commit", "-m", "initial commit"]) 434 | 435 | main(['patch', '--dry-run']) 436 | 437 | assert config == tmpdir.join(".bumpversion.cfg").read() 438 | assert version == tmpdir.join("file4").read() 439 | 440 | vcs_log = check_output([vcs, "log"]).decode('utf-8') 441 | 442 | assert "initial commit" in vcs_log 443 | assert "DO NOT" not in vcs_log 444 | 445 | 446 | def test_dry_run_verbose_log(tmpdir, vcs): 447 | tmpdir.chdir() 448 | 449 | version = "0.12.0" 450 | patch = "0.12.1" 451 | v_parts = version.split('.') 452 | p_parts = patch.split('.') 453 | file = "file4" 454 | message = "DO NOT BUMP VERSIONS WITH THIS FILE" 455 | config = """[bumpversion] 456 | current_version = {version} 457 | tag = True 458 | commit = True 459 | message = {message} 460 | 461 | [bumpversion:file:{file}] 462 | 463 | """.format(version=version, file=file, message=message) 464 | 465 | bumpcfg = ".bumpversion.cfg" 466 | tmpdir.join(file).write(version) 467 | tmpdir.join(bumpcfg).write(config) 468 | 469 | check_call([vcs, "init"]) 470 | check_call([vcs, "add", file]) 471 | check_call([vcs, "add", bumpcfg]) 472 | check_call([vcs, "commit", "-m", "initial commit"]) 473 | 474 | with LogCapture(level=logging.INFO) as log_capture: 475 | main(['patch', '--dry-run', '--verbose']) 476 | 477 | vcs_name = "Mercurial" if vcs == "hg" else "Git" 478 | log_capture.check_present( 479 | # generic --verbose entries 480 | ('bumpversion.cli', 'INFO', 'Reading config file {}:'.format(bumpcfg)), 481 | ('bumpversion.cli', 'INFO', config), 482 | ('bumpversion.version_part', 'INFO', 483 | "Parsing version '{}' using regexp '(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)'".format(version)), 484 | ('bumpversion.version_part', 'INFO', 485 | 'Parsed the following values: major={}, minor={}, patch={}'.format(v_parts[0], v_parts[1], v_parts[2])), 486 | ('bumpversion.cli', 'INFO', "Attempting to increment part 'patch'"), 487 | ('bumpversion.cli', 'INFO', 488 | 'Values are now: major={}, minor={}, patch={}'.format(p_parts[0], p_parts[1], p_parts[2])), 489 | ('bumpversion.cli', 'INFO', "Dry run active, won't touch any files."), # only in dry-run mode 490 | ('bumpversion.version_part', 'INFO', 491 | "Parsing version '{}' using regexp '(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)'".format(patch)), 492 | ('bumpversion.version_part', 'INFO', 493 | 'Parsed the following values: major={}, minor={}, patch={}'.format(p_parts[0], p_parts[1], p_parts[2])), 494 | ('bumpversion.cli', 'INFO', "New version will be '{}'".format(patch)), 495 | ('bumpversion.cli', 'INFO', 'Asserting files {} contain the version string...'.format(file)), 496 | ('bumpversion.utils', 'INFO', "Found '{v}' in {f} at line 0: {v}".format(v=version, f=file)), # verbose 497 | ('bumpversion.utils', 'INFO', 'Would change file {}:'.format(file)), # dry-run change to 'would' 498 | ('bumpversion.utils', 'INFO', 499 | '--- a/{f}\n+++ b/{f}\n@@ -1 +1 @@\n-{v}\n+{p}'.format(f=file, v=version, p=patch)), 500 | ('bumpversion.list', 'INFO', 'current_version={}'.format(version)), 501 | ('bumpversion.list', 'INFO', 'tag=True'), 502 | ('bumpversion.list', 'INFO', 'commit=True'), 503 | ('bumpversion.list', 'INFO', 'message={}'.format(message)), 504 | ('bumpversion.list', 'INFO', 'new_version={}'.format(patch)), 505 | ('bumpversion.cli', 'INFO', 'Would write to config file {}:'.format(bumpcfg)), # dry-run 'would' 506 | ('bumpversion.cli', 'INFO', config.replace(version, patch)), 507 | # following entries are only present if both --verbose and --dry-run are specified 508 | # all entries use 'would do x' variants instead of 'doing x' 509 | ('bumpversion.cli', 'INFO', 'Would prepare {vcs} commit'.format(vcs=vcs_name)), 510 | ('bumpversion.cli', 'INFO', "Would add changes in file '{file}' to {vcs}".format(file=file, vcs=vcs_name)), 511 | ('bumpversion.cli', 'INFO', "Would add changes in file '{file}' to {vcs}".format(file=bumpcfg, vcs=vcs_name)), 512 | ('bumpversion.cli', 'INFO', "Would commit to {vcs} with message '{msg}'".format(msg=message, vcs=vcs_name)), 513 | ('bumpversion.cli', 'INFO', 514 | "Would tag 'v{p}' with message 'Bump version: {v} → {p}' in {vcs} and not signing" 515 | .format(v=version, p=patch, vcs=vcs_name)), 516 | order_matters=True 517 | ) 518 | 519 | 520 | def test_bump_version(tmpdir): 521 | tmpdir.join("file5").write("1.0.0") 522 | tmpdir.chdir() 523 | main(['patch', '--current-version', '1.0.0', 'file5']) 524 | 525 | assert '1.0.1' == tmpdir.join("file5").read() 526 | 527 | 528 | def test_bump_version_custom_main(tmpdir): 529 | tmpdir.join("file6").write("XXX1;0;0") 530 | tmpdir.chdir() 531 | main([ 532 | '--current-version', 'XXX1;0;0', 533 | '--parse', r'XXX(?P\d+);(?P\d+);(?P\d+)', 534 | '--serialize', 'XXX{spam};{blob};{slurp}', 535 | 'blob', 536 | 'file6' 537 | ]) 538 | 539 | assert 'XXX1;1;0' == tmpdir.join("file6").read() 540 | 541 | 542 | def test_bump_version_custom_parse_serialize_configfile(tmpdir): 543 | tmpdir.join("file12").write("ZZZ8;0;0") 544 | tmpdir.chdir() 545 | 546 | tmpdir.join(".bumpversion.cfg").write(r"""[bumpversion] 547 | current_version = ZZZ8;0;0 548 | serialize = ZZZ{spam};{blob};{slurp} 549 | parse = ZZZ(?P\d+);(?P\d+);(?P\d+) 550 | [bumpversion:file:file12] 551 | """) 552 | 553 | main(['blob']) 554 | 555 | assert 'ZZZ8;1;0' == tmpdir.join("file12").read() 556 | 557 | 558 | def test_bumpversion_custom_parse_semver(tmpdir): 559 | tmpdir.join("file15").write("XXX1.1.7-master+allan1") 560 | tmpdir.chdir() 561 | main([ 562 | '--current-version', '1.1.7-master+allan1', 563 | '--parse', r'(?P\d+).(?P\d+).(?P\d+)(-(?P[^\+]+))?(\+(?P.*))?', 564 | '--serialize', '{major}.{minor}.{patch}-{pre_release}+{meta}', 565 | 'meta', 566 | 'file15' 567 | ]) 568 | 569 | assert 'XXX1.1.7-master+allan2' == tmpdir.join("file15").read() 570 | 571 | 572 | def test_bump_version_missing_part(tmpdir): 573 | tmpdir.join("file5").write("1.0.0") 574 | tmpdir.chdir() 575 | with pytest.raises( 576 | exceptions.InvalidVersionPartException, 577 | match="No part named 'bugfix'" 578 | ): 579 | main(['bugfix', '--current-version', '1.0.0', 'file5']) 580 | 581 | 582 | def test_dirty_work_dir(tmpdir, vcs): 583 | tmpdir.chdir() 584 | check_call([vcs, "init"]) 585 | tmpdir.join("dirty").write("i'm dirty") 586 | 587 | check_call([vcs, "add", "dirty"]) 588 | vcs_name = "Mercurial" if vcs == "hg" else "Git" 589 | vcs_output = "A dirty" if vcs == "hg" else "A dirty" 590 | 591 | with pytest.raises(exceptions.WorkingDirectoryIsDirtyException): 592 | with LogCapture() as log_capture: 593 | main(['patch', '--current-version', '1', '--new-version', '2', 'file7']) 594 | 595 | log_capture.check_present( 596 | ( 597 | 'bumpversion.cli', 598 | 'WARNING', 599 | "{} working directory is not clean:\n" 600 | "{}\n" 601 | "\n" 602 | "Use --allow-dirty to override this if you know what you're doing.".format( 603 | vcs_name, 604 | vcs_output 605 | ) 606 | ) 607 | ) 608 | 609 | 610 | def test_force_dirty_work_dir(tmpdir, vcs): 611 | tmpdir.chdir() 612 | check_call([vcs, "init"]) 613 | tmpdir.join("dirty2").write("i'm dirty! 1.1.1") 614 | 615 | check_call([vcs, "add", "dirty2"]) 616 | 617 | main([ 618 | 'patch', 619 | '--allow-dirty', 620 | '--current-version', 621 | '1.1.1', 622 | 'dirty2' 623 | ]) 624 | 625 | assert "i'm dirty! 1.1.2" == tmpdir.join("dirty2").read() 626 | 627 | 628 | def test_bump_major(tmpdir): 629 | tmpdir.join("fileMAJORBUMP").write("4.2.8") 630 | tmpdir.chdir() 631 | main(['--current-version', '4.2.8', 'major', 'fileMAJORBUMP']) 632 | 633 | assert '5.0.0' == tmpdir.join("fileMAJORBUMP").read() 634 | 635 | 636 | def test_commit_and_tag(tmpdir, vcs): 637 | tmpdir.chdir() 638 | check_call([vcs, "init"]) 639 | tmpdir.join("VERSION").write("47.1.1") 640 | check_call([vcs, "add", "VERSION"]) 641 | check_call([vcs, "commit", "-m", "initial commit"]) 642 | 643 | main(['patch', '--current-version', '47.1.1', '--commit', 'VERSION']) 644 | 645 | assert '47.1.2' == tmpdir.join("VERSION").read() 646 | 647 | log = check_output([vcs, "log", "-p"]).decode("utf-8") 648 | 649 | assert '-47.1.1' in log 650 | assert '+47.1.2' in log 651 | assert 'Bump version: 47.1.1 → 47.1.2' in log 652 | 653 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 654 | 655 | assert b'v47.1.2' not in tag_out 656 | 657 | main(['patch', '--current-version', '47.1.2', '--commit', '--tag', 'VERSION']) 658 | 659 | assert '47.1.3' == tmpdir.join("VERSION").read() 660 | 661 | check_output([vcs, "log", "-p"]) 662 | 663 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 664 | 665 | assert b'v47.1.3' in tag_out 666 | 667 | 668 | def test_commit_and_tag_with_configfile(tmpdir, vcs): 669 | tmpdir.chdir() 670 | 671 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion]\ncommit = True\ntag = True""") 672 | 673 | check_call([vcs, "init"]) 674 | tmpdir.join("VERSION").write("48.1.1") 675 | check_call([vcs, "add", "VERSION"]) 676 | check_call([vcs, "commit", "-m", "initial commit"]) 677 | 678 | main(['patch', '--current-version', '48.1.1', '--no-tag', 'VERSION']) 679 | 680 | assert '48.1.2' == tmpdir.join("VERSION").read() 681 | 682 | log = check_output([vcs, "log", "-p"]).decode("utf-8") 683 | 684 | assert '-48.1.1' in log 685 | assert '+48.1.2' in log 686 | assert 'Bump version: 48.1.1 → 48.1.2' in log 687 | 688 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 689 | 690 | assert b'v48.1.2' not in tag_out 691 | 692 | main(['patch', '--current-version', '48.1.2', 'VERSION']) 693 | 694 | assert '48.1.3' == tmpdir.join("VERSION").read() 695 | 696 | check_output([vcs, "log", "-p"]) 697 | 698 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 699 | 700 | assert b'v48.1.3' in tag_out 701 | 702 | 703 | @pytest.mark.parametrize("config", [COMMIT, COMMIT_NOT_TAG]) 704 | def test_commit_and_not_tag_with_configfile(tmpdir, vcs, config): 705 | tmpdir.chdir() 706 | 707 | tmpdir.join(".bumpversion.cfg").write(config) 708 | 709 | check_call([vcs, "init"]) 710 | tmpdir.join("VERSION").write("48.1.1") 711 | check_call([vcs, "add", "VERSION"]) 712 | check_call([vcs, "commit", "-m", "initial commit"]) 713 | 714 | main(['patch', '--current-version', '48.1.1', 'VERSION']) 715 | 716 | assert '48.1.2' == tmpdir.join("VERSION").read() 717 | 718 | log = check_output([vcs, "log", "-p"]).decode("utf-8") 719 | 720 | assert '-48.1.1' in log 721 | assert '+48.1.2' in log 722 | assert 'Bump version: 48.1.1 → 48.1.2' in log 723 | 724 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 725 | 726 | assert b'v48.1.2' not in tag_out 727 | 728 | 729 | def test_commit_explicitly_false(tmpdir, vcs): 730 | tmpdir.chdir() 731 | 732 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion] 733 | current_version: 10.0.0 734 | commit = False 735 | tag = False""") 736 | 737 | check_call([vcs, "init"]) 738 | tmpdir.join("tracked_file").write("10.0.0") 739 | check_call([vcs, "add", "tracked_file"]) 740 | check_call([vcs, "commit", "-m", "initial commit"]) 741 | 742 | main(['patch', 'tracked_file']) 743 | 744 | assert '10.0.1' == tmpdir.join("tracked_file").read() 745 | 746 | log = check_output([vcs, "log", "-p"]).decode("utf-8") 747 | assert "10.0.1" not in log 748 | 749 | diff = check_output([vcs, "diff"]).decode("utf-8") 750 | assert "10.0.1" in diff 751 | 752 | 753 | def test_commit_configfile_true_cli_false_override(tmpdir, vcs): 754 | tmpdir.chdir() 755 | 756 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion] 757 | current_version: 27.0.0 758 | commit = True""") 759 | 760 | check_call([vcs, "init"]) 761 | tmpdir.join("dont_commit_file").write("27.0.0") 762 | check_call([vcs, "add", "dont_commit_file"]) 763 | check_call([vcs, "commit", "-m", "initial commit"]) 764 | 765 | main(['patch', '--no-commit', 'dont_commit_file']) 766 | 767 | assert '27.0.1' == tmpdir.join("dont_commit_file").read() 768 | 769 | log = check_output([vcs, "log", "-p"]).decode("utf-8") 770 | assert "27.0.1" not in log 771 | 772 | diff = check_output([vcs, "diff"]).decode("utf-8") 773 | assert "27.0.1" in diff 774 | 775 | 776 | def test_bump_version_environment(tmpdir): 777 | tmpdir.join("on_jenkins").write("2.3.4") 778 | tmpdir.chdir() 779 | os.environ['BUILD_NUMBER'] = "567" 780 | main([ 781 | '--verbose', 782 | '--current-version', '2.3.4', 783 | '--parse', r'(?P\d+)\.(?P\d+)\.(?P\d+).*', 784 | '--serialize', '{major}.{minor}.{patch}.pre{$BUILD_NUMBER}', 785 | 'patch', 786 | 'on_jenkins', 787 | ]) 788 | del os.environ['BUILD_NUMBER'] 789 | 790 | assert '2.3.5.pre567' == tmpdir.join("on_jenkins").read() 791 | 792 | 793 | def test_current_version_from_tag(tmpdir, git): 794 | # prepare 795 | tmpdir.join("update_from_tag").write("26.6.0") 796 | tmpdir.chdir() 797 | check_call([git, "init"]) 798 | check_call([git, "add", "update_from_tag"]) 799 | check_call([git, "commit", "-m", "initial"]) 800 | check_call([git, "tag", "v26.6.0"]) 801 | 802 | # don't give current-version, that should come from tag 803 | main(['patch', 'update_from_tag']) 804 | 805 | assert '26.6.1' == tmpdir.join("update_from_tag").read() 806 | 807 | 808 | def test_current_version_from_tag_written_to_config_file(tmpdir, git): 809 | # prepare 810 | tmpdir.join("updated_also_in_config_file").write("14.6.0") 811 | tmpdir.chdir() 812 | 813 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion]""") 814 | 815 | check_call([git, "init"]) 816 | check_call([git, "add", "updated_also_in_config_file"]) 817 | check_call([git, "commit", "-m", "initial"]) 818 | check_call([git, "tag", "v14.6.0"]) 819 | 820 | # don't give current-version, that should come from tag 821 | main([ 822 | 'patch', 823 | 'updated_also_in_config_file', 824 | '--commit', 825 | '--tag', 826 | ]) 827 | 828 | assert '14.6.1' == tmpdir.join("updated_also_in_config_file").read() 829 | assert '14.6.1' in tmpdir.join(".bumpversion.cfg").read() 830 | 831 | 832 | def test_distance_to_latest_tag_as_part_of_new_version(tmpdir, git): 833 | # prepare 834 | tmpdir.join("my_source_file").write("19.6.0") 835 | tmpdir.chdir() 836 | 837 | check_call([git, "init"]) 838 | check_call([git, "add", "my_source_file"]) 839 | check_call([git, "commit", "-m", "initial"]) 840 | check_call([git, "tag", "v19.6.0"]) 841 | check_call([git, "commit", "--allow-empty", "-m", "Just a commit 1"]) 842 | check_call([git, "commit", "--allow-empty", "-m", "Just a commit 2"]) 843 | check_call([git, "commit", "--allow-empty", "-m", "Just a commit 3"]) 844 | 845 | # don't give current-version, that should come from tag 846 | main([ 847 | 'patch', 848 | '--parse', r'(?P\d+)\.(?P\d+)\.(?P\d+).*', 849 | '--serialize', '{major}.{minor}.{patch}-pre{distance_to_latest_tag}', 850 | 'my_source_file', 851 | ]) 852 | 853 | assert '19.6.1-pre3' == tmpdir.join("my_source_file").read() 854 | 855 | 856 | def test_override_vcs_current_version(tmpdir, git): 857 | # prepare 858 | tmpdir.join("contains_actual_version").write("6.7.8") 859 | tmpdir.chdir() 860 | check_call([git, "init"]) 861 | check_call([git, "add", "contains_actual_version"]) 862 | check_call([git, "commit", "-m", "initial"]) 863 | check_call([git, "tag", "v6.7.8"]) 864 | 865 | # update file 866 | tmpdir.join("contains_actual_version").write("7.0.0") 867 | check_call([git, "add", "contains_actual_version"]) 868 | 869 | # but forgot to tag or forgot to push --tags 870 | check_call([git, "commit", "-m", "major release"]) 871 | 872 | # if we don't give current-version here we get 873 | # "AssertionError: Did not find string 6.7.8 in file contains_actual_version" 874 | main(['patch', '--current-version', '7.0.0', 'contains_actual_version']) 875 | 876 | assert '7.0.1' == tmpdir.join("contains_actual_version").read() 877 | 878 | 879 | def test_non_existing_file(tmpdir): 880 | tmpdir.chdir() 881 | with pytest.raises(IOError): 882 | main(shlex_split("patch --current-version 1.2.0 --new-version 1.2.1 does_not_exist.txt")) 883 | 884 | 885 | def test_non_existing_second_file(tmpdir): 886 | tmpdir.chdir() 887 | tmpdir.join("my_source_code.txt").write("1.2.3") 888 | with pytest.raises(IOError): 889 | main(shlex_split("patch --current-version 1.2.3 my_source_code.txt does_not_exist2.txt")) 890 | 891 | # first file is unchanged because second didn't exist 892 | assert '1.2.3' == tmpdir.join("my_source_code.txt").read() 893 | 894 | 895 | def test_read_version_tags_only(tmpdir, git): 896 | # prepare 897 | tmpdir.join("update_from_tag").write("29.6.0") 898 | tmpdir.chdir() 899 | check_call([git, "init"]) 900 | check_call([git, "add", "update_from_tag"]) 901 | check_call([git, "commit", "-m", "initial"]) 902 | check_call([git, "tag", "v29.6.0"]) 903 | check_call([git, "commit", "--allow-empty", "-m", "a commit"]) 904 | check_call([git, "tag", "jenkins-deploy-my-project-2"]) 905 | 906 | # don't give current-version, that should come from tag 907 | main(['patch', 'update_from_tag']) 908 | 909 | assert '29.6.1' == tmpdir.join("update_from_tag").read() 910 | 911 | 912 | def test_tag_name(tmpdir, vcs): 913 | tmpdir.chdir() 914 | check_call([vcs, "init"]) 915 | tmpdir.join("VERSION").write("31.1.1") 916 | check_call([vcs, "add", "VERSION"]) 917 | check_call([vcs, "commit", "-m", "initial commit"]) 918 | 919 | main([ 920 | 'patch', '--current-version', '31.1.1', '--commit', '--tag', 921 | 'VERSION', '--tag-name', 'ReleasedVersion-{new_version}' 922 | ]) 923 | 924 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 925 | 926 | assert b'ReleasedVersion-31.1.2' in tag_out 927 | 928 | 929 | def test_message_from_config_file(tmpdir, vcs): 930 | tmpdir.chdir() 931 | check_call([vcs, "init"]) 932 | tmpdir.join("VERSION").write("400.0.0") 933 | check_call([vcs, "add", "VERSION"]) 934 | check_call([vcs, "commit", "-m", "initial commit"]) 935 | 936 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion] 937 | current_version: 400.0.0 938 | new_version: 401.0.0 939 | commit: True 940 | tag: True 941 | message: {current_version} was old, {new_version} is new 942 | tag_name: from-{current_version}-to-{new_version}""") 943 | 944 | main(['major', 'VERSION']) 945 | 946 | log = check_output([vcs, "log", "-p"]) 947 | 948 | assert b'400.0.0 was old, 401.0.0 is new' in log 949 | 950 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 951 | 952 | assert b'from-400.0.0-to-401.0.0' in tag_out 953 | 954 | 955 | def test_all_parts_in_message_and_serialize_and_tag_name_from_config_file(tmpdir, vcs): 956 | """ 957 | Ensure that major/minor/patch *and* custom parts can be used everywhere. 958 | 959 | - As [part] in 'serialize'. 960 | - As new_[part] and previous_[part] in 'message'. 961 | - As new_[part] and previous_[part] in 'tag_name'. 962 | 963 | In message and tag_name, also ensure that new_version and 964 | current_version are correct. 965 | """ 966 | tmpdir.chdir() 967 | check_call([vcs, "init"]) 968 | tmpdir.join("VERSION").write("400.1.2.101") 969 | check_call([vcs, "add", "VERSION"]) 970 | check_call([vcs, "commit", "-m", "initial commit"]) 971 | 972 | tmpdir.join(".bumpversion.cfg").write(r"""[bumpversion] 973 | current_version: 400.1.2.101 974 | new_version: 401.2.3.102 975 | parse = (?P\d+)\.(?P\d+)\.(?P\d+).(?P\d+) 976 | serialize = {major}.{minor}.{patch}.{custom} 977 | commit: True 978 | tag: True 979 | message: {current_version}/{current_major}.{current_minor}.{current_patch} custom {current_custom} becomes {new_version}/{new_major}.{new_minor}.{new_patch} custom {new_custom} 980 | tag_name: from-{current_version}-aka-{current_major}.{current_minor}.{current_patch}-custom-{current_custom}-to-{new_version}-aka-{new_major}.{new_minor}.{new_patch}-custom-{new_custom} 981 | 982 | [bumpversion:part:custom] 983 | """) 984 | 985 | main(['major', 'VERSION']) 986 | 987 | log = check_output([vcs, "log", "-p"]) 988 | assert b'400.1.2.101/400.1.2 custom 101 becomes 401.2.3.102/401.2.3 custom 102' in log 989 | 990 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 991 | assert b'from-400.1.2.101-aka-400.1.2-custom-101-to-401.2.3.102-aka-401.2.3-custom-102' in tag_out 992 | 993 | 994 | def test_all_parts_in_replace_from_config_file(tmpdir, vcs): 995 | """ 996 | Ensure that major/minor/patch *and* custom parts can be used in 'replace'. 997 | """ 998 | tmpdir.chdir() 999 | check_call([vcs, "init"]) 1000 | tmpdir.join("VERSION").write("my version is 400.1.2.101\n") 1001 | check_call([vcs, "add", "VERSION"]) 1002 | check_call([vcs, "commit", "-m", "initial commit"]) 1003 | 1004 | tmpdir.join(".bumpversion.cfg").write(r"""[bumpversion] 1005 | current_version: 400.1.2.101 1006 | new_version: 401.2.3.102 1007 | parse = (?P\d+)\.(?P\d+)\.(?P\d+).(?P\d+) 1008 | serialize = {major}.{minor}.{patch}.{custom} 1009 | commit: True 1010 | tag: False 1011 | 1012 | [bumpversion:part:custom] 1013 | 1014 | [bumpversion:VERSION] 1015 | search = my version is {current_version} 1016 | replace = my version is {new_major}.{new_minor}.{new_patch}.{new_custom}""") 1017 | 1018 | main(['major', 'VERSION']) 1019 | log = check_output([vcs, "log", "-p"]) 1020 | assert b'+my version is 401.2.3.102' in log 1021 | 1022 | 1023 | def test_unannotated_tag(tmpdir, vcs): 1024 | tmpdir.chdir() 1025 | check_call([vcs, "init"]) 1026 | tmpdir.join("VERSION").write("42.3.1") 1027 | check_call([vcs, "add", "VERSION"]) 1028 | check_call([vcs, "commit", "-m", "initial commit"]) 1029 | 1030 | main([ 1031 | 'patch', '--current-version', '42.3.1', '--commit', '--tag', 'VERSION', 1032 | '--tag-name', 'ReleasedVersion-{new_version}', '--tag-message', '' 1033 | ]) 1034 | 1035 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 1036 | assert b'ReleasedVersion-42.3.2' in tag_out 1037 | 1038 | if vcs == "git": 1039 | describe_out = subprocess.call([vcs, "describe"]) 1040 | assert 128 == describe_out 1041 | 1042 | describe_out = subprocess.check_output([vcs, "describe", "--tags"]) 1043 | assert describe_out.startswith(b'ReleasedVersion-42.3.2') 1044 | 1045 | 1046 | def test_annotated_tag(tmpdir, vcs): 1047 | tmpdir.chdir() 1048 | check_call([vcs, "init"]) 1049 | tmpdir.join("VERSION").write("42.4.1") 1050 | check_call([vcs, "add", "VERSION"]) 1051 | check_call([vcs, "commit", "-m", "initial commit"]) 1052 | 1053 | main([ 1054 | 'patch', '--current-version', '42.4.1', '--commit', '--tag', 1055 | 'VERSION', '--tag-message', 'test {new_version}-tag'] 1056 | ) 1057 | assert '42.4.2' == tmpdir.join("VERSION").read() 1058 | 1059 | tag_out = check_output([vcs, {"git": "tag", "hg": "tags"}[vcs]]) 1060 | assert b'v42.4.2' in tag_out 1061 | 1062 | if vcs == "git": 1063 | describe_out = subprocess.check_output([vcs, "describe"]) 1064 | assert describe_out == b'v42.4.2\n' 1065 | 1066 | describe_out = subprocess.check_output([vcs, "show", "v42.4.2"]) 1067 | assert describe_out.startswith(b"tag v42.4.2\n") 1068 | assert b'test 42.4.2-tag' in describe_out 1069 | elif vcs == "hg": 1070 | describe_out = subprocess.check_output([vcs, "log"]) 1071 | assert b'test 42.4.2-tag' in describe_out 1072 | else: 1073 | raise ValueError("Unknown VCS") 1074 | 1075 | 1076 | def test_vcs_describe(tmpdir, git): 1077 | tmpdir.chdir() 1078 | check_call([git, "init"]) 1079 | tmpdir.join("VERSION").write("42.5.1") 1080 | check_call([git, "add", "VERSION"]) 1081 | check_call([git, "commit", "-m", "initial commit"]) 1082 | 1083 | main([ 1084 | 'patch', '--current-version', '42.5.1', '--commit', '--tag', 1085 | 'VERSION', '--tag-message', 'test {new_version}-tag' 1086 | ]) 1087 | assert '42.5.2' == tmpdir.join("VERSION").read() 1088 | 1089 | describe_out = subprocess.check_output([git, "describe"]) 1090 | assert b'v42.5.2\n' == describe_out 1091 | 1092 | main([ 1093 | 'patch', '--current-version', '42.5.2', '--commit', '--tag', 'VERSION', 1094 | '--tag-name', 'ReleasedVersion-{new_version}', '--tag-message', '' 1095 | ]) 1096 | assert '42.5.3' == tmpdir.join("VERSION").read() 1097 | 1098 | describe_only_annotated_out = subprocess.check_output([git, "describe"]) 1099 | assert describe_only_annotated_out.startswith(b'v42.5.2-1-g') 1100 | 1101 | describe_all_out = subprocess.check_output([git, "describe", "--tags"]) 1102 | assert b'ReleasedVersion-42.5.3\n' == describe_all_out 1103 | 1104 | 1105 | config_parser_handles_utf8 = True 1106 | try: 1107 | import configparser 1108 | except ImportError: 1109 | config_parser_handles_utf8 = False 1110 | 1111 | 1112 | @pytest.mark.xfail(not config_parser_handles_utf8, 1113 | reason="old ConfigParser uses non-utf-8-strings internally") 1114 | def test_utf8_message_from_config_file(tmpdir, vcs): 1115 | tmpdir.chdir() 1116 | check_call([vcs, "init"]) 1117 | tmpdir.join("VERSION").write("500.0.0") 1118 | check_call([vcs, "add", "VERSION"]) 1119 | check_call([vcs, "commit", "-m", "initial commit"]) 1120 | 1121 | initial_config = """[bumpversion] 1122 | current_version = 500.0.0 1123 | commit = True 1124 | message = Nová verze: {current_version} ☃, {new_version} ☀ 1125 | 1126 | """ 1127 | 1128 | tmpdir.join(".bumpversion.cfg").write(initial_config.encode('utf-8'), mode='wb') 1129 | main(['major', 'VERSION']) 1130 | check_output([vcs, "log", "-p"]) 1131 | expected_new_config = initial_config.replace('500', '501') 1132 | assert expected_new_config.encode('utf-8') == tmpdir.join(".bumpversion.cfg").read(mode='rb') 1133 | 1134 | 1135 | def test_utf8_message_from_config_file(tmpdir, vcs): 1136 | tmpdir.chdir() 1137 | check_call([vcs, "init"]) 1138 | tmpdir.join("VERSION").write("10.10.0") 1139 | check_call([vcs, "add", "VERSION"]) 1140 | check_call([vcs, "commit", "-m", "initial commit"]) 1141 | 1142 | initial_config = """[bumpversion] 1143 | current_version = 10.10.0 1144 | commit = True 1145 | message = [{now}] [{utcnow} {utcnow:%YXX%mYY%d}] 1146 | 1147 | """ 1148 | tmpdir.join(".bumpversion.cfg").write(initial_config) 1149 | 1150 | main(['major', 'VERSION']) 1151 | 1152 | log = check_output([vcs, "log", "-p"]) 1153 | 1154 | assert b'[20' in log 1155 | assert b'] [' in log 1156 | assert b'XX' in log 1157 | assert b'YY' in log 1158 | 1159 | 1160 | def test_commit_and_tag_from_below_vcs_root(tmpdir, vcs, monkeypatch): 1161 | tmpdir.chdir() 1162 | check_call([vcs, "init"]) 1163 | tmpdir.join("VERSION").write("30.0.3") 1164 | check_call([vcs, "add", "VERSION"]) 1165 | check_call([vcs, "commit", "-m", "initial commit"]) 1166 | 1167 | tmpdir.mkdir("subdir") 1168 | monkeypatch.chdir(tmpdir.join("subdir")) 1169 | 1170 | main(['major', '--current-version', '30.0.3', '--commit', '../VERSION']) 1171 | 1172 | assert '31.0.0' == tmpdir.join("VERSION").read() 1173 | 1174 | 1175 | def test_non_vcs_operations_if_vcs_is_not_installed(tmpdir, vcs, monkeypatch): 1176 | monkeypatch.setenv("PATH", "") 1177 | 1178 | tmpdir.chdir() 1179 | tmpdir.join("VERSION").write("31.0.3") 1180 | 1181 | main(['major', '--current-version', '31.0.3', 'VERSION']) 1182 | 1183 | assert '32.0.0' == tmpdir.join("VERSION").read() 1184 | 1185 | 1186 | def test_serialize_newline(tmpdir): 1187 | tmpdir.join("file_new_line").write("MAJOR=31\nMINOR=0\nPATCH=3\n") 1188 | tmpdir.chdir() 1189 | main([ 1190 | '--current-version', 'MAJOR=31\nMINOR=0\nPATCH=3\n', 1191 | '--parse', 'MAJOR=(?P\\d+)\\nMINOR=(?P\\d+)\\nPATCH=(?P\\d+)\\n', 1192 | '--serialize', 'MAJOR={major}\nMINOR={minor}\nPATCH={patch}\n', 1193 | '--verbose', 1194 | 'major', 1195 | 'file_new_line' 1196 | ]) 1197 | assert 'MAJOR=32\nMINOR=0\nPATCH=0\n' == tmpdir.join("file_new_line").read() 1198 | 1199 | 1200 | def test_multiple_serialize_three_part(tmpdir): 1201 | tmpdir.join("fileA").write("Version: 0.9") 1202 | tmpdir.chdir() 1203 | main([ 1204 | '--current-version', 'Version: 0.9', 1205 | '--parse', r'Version:\ (?P\d+)(\.(?P\d+)(\.(?P\d+))?)?', 1206 | '--serialize', 'Version: {major}.{minor}.{patch}', 1207 | '--serialize', 'Version: {major}.{minor}', 1208 | '--serialize', 'Version: {major}', 1209 | '--verbose', 1210 | 'major', 1211 | 'fileA' 1212 | ]) 1213 | 1214 | assert 'Version: 1' == tmpdir.join("fileA").read() 1215 | 1216 | 1217 | def test_multiple_serialize_two_part(tmpdir): 1218 | tmpdir.join("fileB").write("0.9") 1219 | tmpdir.chdir() 1220 | main([ 1221 | '--current-version', '0.9', 1222 | '--parse', r'(?P\d+)\.(?P\d+)(\.(?P\d+))?', 1223 | '--serialize', '{major}.{minor}.{patch}', 1224 | '--serialize', '{major}.{minor}', 1225 | 'minor', 1226 | 'fileB' 1227 | ]) 1228 | 1229 | assert '0.10' == tmpdir.join("fileB").read() 1230 | 1231 | 1232 | def test_multiple_serialize_two_part_patch(tmpdir): 1233 | tmpdir.join("fileC").write("0.7") 1234 | tmpdir.chdir() 1235 | main([ 1236 | '--current-version', '0.7', 1237 | '--parse', r'(?P\d+)\.(?P\d+)(\.(?P\d+))?', 1238 | '--serialize', '{major}.{minor}.{patch}', 1239 | '--serialize', '{major}.{minor}', 1240 | 'patch', 1241 | 'fileC' 1242 | ]) 1243 | 1244 | assert '0.7.1' == tmpdir.join("fileC").read() 1245 | 1246 | 1247 | def test_multiple_serialize_two_part_patch_configfile(tmpdir): 1248 | tmpdir.join("fileD").write("0.6") 1249 | tmpdir.chdir() 1250 | 1251 | tmpdir.join(".bumpversion.cfg").write(r"""[bumpversion] 1252 | current_version = 0.6 1253 | serialize = 1254 | {major}.{minor}.{patch} 1255 | {major}.{minor} 1256 | parse = (?P\d+)\.(?P\d+)(\.(?P\d+))? 1257 | [bumpversion:file:fileD] 1258 | """) 1259 | 1260 | main(['patch']) 1261 | 1262 | assert '0.6.1' == tmpdir.join("fileD").read() 1263 | 1264 | 1265 | def test_search_uses_shortest_possible_custom_search_pattern(tmpdir): 1266 | config = dedent(r""" 1267 | [bumpversion] 1268 | current_version = 0.0.0 1269 | commit = True 1270 | tag = True 1271 | parse = (?P\d+).(?P\d+).(?P\d+).?((?P.*))? 1272 | serialize = 1273 | {major}.{minor}.{patch}.{prerelease} 1274 | {major}.{minor}.{patch} 1275 | 1276 | [bumpversion:file:package.json] 1277 | search = "version": "{current_version}", 1278 | replace = "version": "{new_version}", 1279 | """) 1280 | tmpdir.join(".bumpversion.cfg").write(config.encode('utf-8'), mode='wb') 1281 | 1282 | tmpdir.join("package.json").write("""{ 1283 | "version": "0.0.0", 1284 | "package": "20.0.0", 1285 | }""") 1286 | 1287 | tmpdir.chdir() 1288 | main(["patch"]) 1289 | 1290 | assert """{ 1291 | "version": "0.0.1", 1292 | "package": "20.0.0", 1293 | }""" == tmpdir.join("package.json").read() 1294 | 1295 | 1296 | def test_log_no_config_file_info_message(tmpdir): 1297 | tmpdir.chdir() 1298 | 1299 | tmpdir.join("a_file.txt").write("1.0.0") 1300 | 1301 | with LogCapture(level=logging.INFO) as log_capture: 1302 | main(['--verbose', '--verbose', '--current-version', '1.0.0', 'patch', 'a_file.txt']) 1303 | 1304 | log_capture.check_present( 1305 | ('bumpversion.cli', 'INFO', 'Could not read config file at .bumpversion.cfg'), 1306 | ('bumpversion.version_part', 'INFO', "Parsing version '1.0.0' using regexp '(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)'"), 1307 | ('bumpversion.version_part', 'INFO', 'Parsed the following values: major=1, minor=0, patch=0'), 1308 | ('bumpversion.cli', 'INFO', "Attempting to increment part 'patch'"), 1309 | ('bumpversion.cli', 'INFO', 'Values are now: major=1, minor=0, patch=1'), 1310 | ('bumpversion.version_part', 'INFO', "Parsing version '1.0.1' using regexp '(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)'"), 1311 | ('bumpversion.version_part', 'INFO', 'Parsed the following values: major=1, minor=0, patch=1'), 1312 | ('bumpversion.cli', 'INFO', "New version will be '1.0.1'"), 1313 | ('bumpversion.cli', 'INFO', 'Asserting files a_file.txt contain the version string...'), 1314 | ('bumpversion.utils', 'INFO', "Found '1.0.0' in a_file.txt at line 0: 1.0.0"), 1315 | ('bumpversion.utils', 'INFO', 'Changing file a_file.txt:'), 1316 | ('bumpversion.utils', 'INFO', '--- a/a_file.txt\n+++ b/a_file.txt\n@@ -1 +1 @@\n-1.0.0\n+1.0.1'), 1317 | ('bumpversion.cli', 'INFO', 'Would write to config file .bumpversion.cfg:'), 1318 | ('bumpversion.cli', 'INFO', '[bumpversion]\ncurrent_version = 1.0.1\n\n'), 1319 | order_matters=True 1320 | ) 1321 | 1322 | 1323 | def test_log_parse_doesnt_parse_current_version(tmpdir): 1324 | tmpdir.chdir() 1325 | 1326 | with LogCapture() as log_capture: 1327 | main(['--verbose', '--parse', 'xxx', '--current-version', '12', '--new-version', '13', 'patch']) 1328 | 1329 | log_capture.check_present( 1330 | ('bumpversion.cli', 'INFO', "Could not read config file at .bumpversion.cfg"), 1331 | ('bumpversion.version_part', 'INFO', "Parsing version '12' using regexp 'xxx'"), 1332 | ('bumpversion.version_part', 'WARNING', "Evaluating 'parse' option: 'xxx' does not parse current version '12'"), 1333 | ('bumpversion.version_part', 'INFO', "Parsing version '13' using regexp 'xxx'"), 1334 | ('bumpversion.version_part', 'WARNING', "Evaluating 'parse' option: 'xxx' does not parse current version '13'"), 1335 | ('bumpversion.cli', 'INFO', "New version will be '13'"), 1336 | ('bumpversion.cli', 'INFO', "Asserting files contain the version string..."), 1337 | ('bumpversion.cli', 'INFO', "Would write to config file .bumpversion.cfg:"), 1338 | ('bumpversion.cli', 'INFO', '[bumpversion]\ncurrent_version = 13\n\n'), 1339 | ) 1340 | 1341 | 1342 | def test_log_invalid_regex_exit(tmpdir): 1343 | tmpdir.chdir() 1344 | 1345 | with pytest.raises(SystemExit): 1346 | with LogCapture() as log_capture: 1347 | main(['--parse', '*kittens*', '--current-version', '12', '--new-version', '13', 'patch']) 1348 | 1349 | log_capture.check_present( 1350 | ('bumpversion.version_part', 'ERROR', "--parse '*kittens*' is not a valid regex"), 1351 | ) 1352 | 1353 | 1354 | def test_complex_info_logging(tmpdir): 1355 | tmpdir.join("fileE").write("0.4") 1356 | tmpdir.chdir() 1357 | 1358 | tmpdir.join(".bumpversion.cfg").write(dedent(r""" 1359 | [bumpversion] 1360 | current_version = 0.4 1361 | serialize = 1362 | {major}.{minor}.{patch} 1363 | {major}.{minor} 1364 | parse = (?P\d+)\.(?P\d+)(\.(?P\d+))? 1365 | [bumpversion:file:fileE] 1366 | """).strip()) 1367 | 1368 | with LogCapture() as log_capture: 1369 | main(['patch', '--verbose']) 1370 | 1371 | log_capture.check( 1372 | ('bumpversion.cli', 'INFO', 'Reading config file .bumpversion.cfg:'), 1373 | ('bumpversion.cli', 'INFO', '[bumpversion]\ncurrent_version = 0.4\nserialize =\n {major}.{minor}.{patch}\n {major}.{minor}\nparse = (?P\\d+)\\.(?P\\d+)(\\.(?P\\d+))?\n[bumpversion:file:fileE]'), 1374 | ('bumpversion.version_part', 'INFO', "Parsing version '0.4' using regexp '(?P\\d+)\\.(?P\\d+)(\\.(?P\\d+))?'"), 1375 | ('bumpversion.version_part', 'INFO', 'Parsed the following values: major=0, minor=4, patch=0'), 1376 | ('bumpversion.cli', 'INFO', "Attempting to increment part 'patch'"), 1377 | ('bumpversion.cli', 'INFO', 'Values are now: major=0, minor=4, patch=1'), 1378 | ('bumpversion.version_part', 'INFO', "Parsing version '0.4.1' using regexp '(?P\\d+)\\.(?P\\d+)(\\.(?P\\d+))?'"), 1379 | ('bumpversion.version_part', 'INFO', 'Parsed the following values: major=0, minor=4, patch=1'), 1380 | ('bumpversion.cli', 'INFO', "New version will be '0.4.1'"), 1381 | ('bumpversion.cli', 'INFO', 'Asserting files fileE contain the version string...'), 1382 | ('bumpversion.utils', 'INFO', "Found '0.4' in fileE at line 0: 0.4"), 1383 | ('bumpversion.utils', 'INFO', 'Changing file fileE:'), 1384 | ('bumpversion.utils', 'INFO', '--- a/fileE\n+++ b/fileE\n@@ -1 +1 @@\n-0.4\n+0.4.1'), 1385 | ('bumpversion.list', 'INFO', 'current_version=0.4'), 1386 | ('bumpversion.list', 'INFO', 'serialize=\n{major}.{minor}.{patch}\n{major}.{minor}'), 1387 | ('bumpversion.list', 'INFO', 'parse=(?P\\d+)\\.(?P\\d+)(\\.(?P\\d+))?'), 1388 | ('bumpversion.list', 'INFO', 'new_version=0.4.1'), 1389 | ('bumpversion.cli', 'INFO', 'Writing to config file .bumpversion.cfg:'), 1390 | ('bumpversion.cli', 'INFO', '[bumpversion]\ncurrent_version = 0.4.1\nserialize = \n\t{major}.{minor}.{patch}\n\t{major}.{minor}\nparse = (?P\\d+)\\.(?P\\d+)(\\.(?P\\d+))?\n\n[bumpversion:file:fileE]\n\n') 1391 | ) 1392 | 1393 | 1394 | def test_subjunctive_dry_run_logging(tmpdir, vcs): 1395 | tmpdir.join("dont_touch_me.txt").write("0.8") 1396 | tmpdir.chdir() 1397 | 1398 | tmpdir.join(".bumpversion.cfg").write(dedent(r""" 1399 | [bumpversion] 1400 | current_version = 0.8 1401 | commit = True 1402 | tag = True 1403 | serialize = 1404 | {major}.{minor}.{patch} 1405 | {major}.{minor} 1406 | parse = (?P\d+)\.(?P\d+)(\.(?P\d+))? 1407 | [bumpversion:file:dont_touch_me.txt] 1408 | """).strip()) 1409 | 1410 | check_call([vcs, "init"]) 1411 | check_call([vcs, "add", "dont_touch_me.txt"]) 1412 | check_call([vcs, "commit", "-m", "initial commit"]) 1413 | 1414 | vcs_name = 'Mercurial' if vcs == 'hg' else 'Git' 1415 | 1416 | with LogCapture() as log_capture: 1417 | main(['patch', '--verbose', '--dry-run']) 1418 | 1419 | log_capture.check( 1420 | ('bumpversion.cli', 'INFO', 'Reading config file .bumpversion.cfg:'), 1421 | ('bumpversion.cli', 'INFO', '[bumpversion]\ncurrent_version = 0.8\ncommit = True\ntag = True\nserialize =\n\t{major}.{minor}.{patch}\n\t{major}.{minor}\nparse = (?P\\d+)\\.(?P\\d+)(\\.(?P\\d+))?\n[bumpversion:file:dont_touch_me.txt]'), 1422 | ('bumpversion.version_part', 'INFO', "Parsing version '0.8' using regexp '(?P\\d+)\\.(?P\\d+)(\\.(?P\\d+))?'"), 1423 | ('bumpversion.version_part', 'INFO', 'Parsed the following values: major=0, minor=8, patch=0'), 1424 | ('bumpversion.cli', 'INFO', "Attempting to increment part 'patch'"), 1425 | ('bumpversion.cli', 'INFO', 'Values are now: major=0, minor=8, patch=1'), 1426 | ('bumpversion.cli', 'INFO', "Dry run active, won't touch any files."), 1427 | ('bumpversion.version_part', 'INFO', "Parsing version '0.8.1' using regexp '(?P\\d+)\\.(?P\\d+)(\\.(?P\\d+))?'"), 1428 | ('bumpversion.version_part', 'INFO', 'Parsed the following values: major=0, minor=8, patch=1'), 1429 | ('bumpversion.cli', 'INFO', "New version will be '0.8.1'"), 1430 | ('bumpversion.cli', 'INFO', 'Asserting files dont_touch_me.txt contain the version string...'), 1431 | ('bumpversion.utils', 'INFO', "Found '0.8' in dont_touch_me.txt at line 0: 0.8"), 1432 | ('bumpversion.utils', 'INFO', 'Would change file dont_touch_me.txt:'), 1433 | ('bumpversion.utils', 'INFO', '--- a/dont_touch_me.txt\n+++ b/dont_touch_me.txt\n@@ -1 +1 @@\n-0.8\n+0.8.1'), 1434 | ('bumpversion.list', 'INFO', 'current_version=0.8'), 1435 | ('bumpversion.list', 'INFO', 'commit=True'), 1436 | ('bumpversion.list', 'INFO', 'tag=True'), 1437 | ('bumpversion.list', 'INFO', 'serialize=\n{major}.{minor}.{patch}\n{major}.{minor}'), 1438 | ('bumpversion.list', 'INFO', 'parse=(?P\\d+)\\.(?P\\d+)(\\.(?P\\d+))?'), 1439 | ('bumpversion.list', 'INFO', 'new_version=0.8.1'), 1440 | ('bumpversion.cli', 'INFO', 'Would write to config file .bumpversion.cfg:'), 1441 | ('bumpversion.cli', 'INFO', '[bumpversion]\ncurrent_version = 0.8.1\ncommit = True\ntag = True\nserialize = \n\t{major}.{minor}.{patch}\n\t{major}.{minor}\nparse = (?P\\d+)\\.(?P\\d+)(\\.(?P\\d+))?\n\n[bumpversion:file:dont_touch_me.txt]\n\n'), 1442 | ('bumpversion.cli', 'INFO', 'Would prepare {vcs} commit'.format(vcs=vcs_name)), 1443 | ('bumpversion.cli', 'INFO', "Would add changes in file 'dont_touch_me.txt' to {vcs}".format(vcs=vcs_name)), 1444 | ('bumpversion.cli', 'INFO', "Would add changes in file '.bumpversion.cfg' to {vcs}".format(vcs=vcs_name)), 1445 | ('bumpversion.cli', 'INFO', "Would commit to {vcs} with message 'Bump version: 0.8 \u2192 0.8.1'".format(vcs=vcs_name)), 1446 | ('bumpversion.cli', 'INFO', "Would tag 'v0.8.1' with message 'Bump version: 0.8 \u2192 0.8.1' in {vcs} and not signing".format(vcs=vcs_name)) 1447 | ) 1448 | 1449 | 1450 | def test_log_commit_message_if_no_commit_tag_but_usable_vcs(tmpdir, vcs): 1451 | tmpdir.join("please_touch_me.txt").write("0.3.3") 1452 | tmpdir.chdir() 1453 | 1454 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1455 | [bumpversion] 1456 | current_version = 0.3.3 1457 | commit = False 1458 | tag = False 1459 | [bumpversion:file:please_touch_me.txt] 1460 | """).strip()) 1461 | 1462 | check_call([vcs, "init"]) 1463 | check_call([vcs, "add", "please_touch_me.txt"]) 1464 | check_call([vcs, "commit", "-m", "initial commit"]) 1465 | 1466 | vcs_name = 'Mercurial' if vcs == 'hg' else 'Git' 1467 | 1468 | with LogCapture() as log_capture: 1469 | main(['patch', '--verbose']) 1470 | 1471 | log_capture.check( 1472 | ('bumpversion.cli', 'INFO', 'Reading config file .bumpversion.cfg:'), 1473 | ('bumpversion.cli', 'INFO', '[bumpversion]\ncurrent_version = 0.3.3\ncommit = False\ntag = False\n[bumpversion:file:please_touch_me.txt]'), 1474 | ('bumpversion.version_part', 'INFO', "Parsing version '0.3.3' using regexp '(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)'"), 1475 | ('bumpversion.version_part', 'INFO', 'Parsed the following values: major=0, minor=3, patch=3'), 1476 | ('bumpversion.cli', 'INFO', "Attempting to increment part 'patch'"), 1477 | ('bumpversion.cli', 'INFO', 'Values are now: major=0, minor=3, patch=4'), 1478 | ('bumpversion.version_part', 'INFO', "Parsing version '0.3.4' using regexp '(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)'"), 1479 | ('bumpversion.version_part', 'INFO', 'Parsed the following values: major=0, minor=3, patch=4'), 1480 | ('bumpversion.cli', 'INFO', "New version will be '0.3.4'"), 1481 | ('bumpversion.cli', 'INFO', 'Asserting files please_touch_me.txt contain the version string...'), 1482 | ('bumpversion.utils', 'INFO', "Found '0.3.3' in please_touch_me.txt at line 0: 0.3.3"), 1483 | ('bumpversion.utils', 'INFO', 'Changing file please_touch_me.txt:'), 1484 | ('bumpversion.utils', 'INFO', '--- a/please_touch_me.txt\n+++ b/please_touch_me.txt\n@@ -1 +1 @@\n-0.3.3\n+0.3.4'), 1485 | ('bumpversion.list', 'INFO', 'current_version=0.3.3'), 1486 | ('bumpversion.list', 'INFO', 'commit=False'), 1487 | ('bumpversion.list', 'INFO', 'tag=False'), 1488 | ('bumpversion.list', 'INFO', 'new_version=0.3.4'), 1489 | ('bumpversion.cli', 'INFO', 'Writing to config file .bumpversion.cfg:'), 1490 | ('bumpversion.cli', 'INFO', '[bumpversion]\ncurrent_version = 0.3.4\ncommit = False\ntag = False\n\n[bumpversion:file:please_touch_me.txt]\n\n'), 1491 | ('bumpversion.cli', 'INFO', 'Would prepare {vcs} commit'.format(vcs=vcs_name)), 1492 | ('bumpversion.cli', 'INFO', "Would add changes in file 'please_touch_me.txt' to {vcs}".format(vcs=vcs_name)), 1493 | ('bumpversion.cli', 'INFO', "Would add changes in file '.bumpversion.cfg' to {vcs}".format(vcs=vcs_name)), 1494 | ('bumpversion.cli', 'INFO', "Would commit to {vcs} with message 'Bump version: 0.3.3 \u2192 0.3.4'".format(vcs=vcs_name)), 1495 | ('bumpversion.cli', 'INFO', "Would tag 'v0.3.4' with message 'Bump version: 0.3.3 \u2192 0.3.4' in {vcs} and not signing".format(vcs=vcs_name)), 1496 | ) 1497 | 1498 | 1499 | def test_listing(tmpdir, vcs): 1500 | tmpdir.join("please_list_me.txt").write("0.5.5") 1501 | tmpdir.chdir() 1502 | 1503 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1504 | [bumpversion] 1505 | current_version = 0.5.5 1506 | commit = False 1507 | tag = False 1508 | [bumpversion:file:please_list_me.txt] 1509 | """).strip()) 1510 | 1511 | check_call([vcs, "init"]) 1512 | check_call([vcs, "add", "please_list_me.txt"]) 1513 | check_call([vcs, "commit", "-m", "initial commit"]) 1514 | 1515 | with LogCapture() as log_capture: 1516 | main(['--list', 'patch']) 1517 | 1518 | log_capture.check( 1519 | ('bumpversion.list', 'INFO', 'current_version=0.5.5'), 1520 | ('bumpversion.list', 'INFO', 'commit=False'), 1521 | ('bumpversion.list', 'INFO', 'tag=False'), 1522 | ('bumpversion.list', 'INFO', 'new_version=0.5.6'), 1523 | ) 1524 | 1525 | 1526 | def test_no_list_no_stdout(tmpdir, vcs): 1527 | tmpdir.join("please_dont_list_me.txt").write("0.5.5") 1528 | tmpdir.chdir() 1529 | 1530 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1531 | [bumpversion] 1532 | files = please_dont_list_me.txt 1533 | current_version = 0.5.5 1534 | commit = False 1535 | tag = False 1536 | """).strip()) 1537 | 1538 | check_call([vcs, "init"]) 1539 | check_call([vcs, "add", "please_dont_list_me.txt"]) 1540 | check_call([vcs, "commit", "-m", "initial commit"]) 1541 | 1542 | out = run( 1543 | ['bumpversion', 'patch'], 1544 | stdout=subprocess.PIPE, 1545 | stderr=subprocess.STDOUT, 1546 | ).stdout.decode('utf-8') 1547 | 1548 | assert out == "" 1549 | 1550 | 1551 | def test_bump_non_numeric_parts(tmpdir): 1552 | tmpdir.join("with_pre_releases.txt").write("1.5.dev") 1553 | tmpdir.chdir() 1554 | 1555 | tmpdir.join(".bumpversion.cfg").write(dedent(r""" 1556 | [bumpversion] 1557 | current_version = 1.5.dev 1558 | parse = (?P\d+)\.(?P\d+)(\.(?P[a-z]+))? 1559 | serialize = 1560 | {major}.{minor}.{release} 1561 | {major}.{minor} 1562 | 1563 | [bumpversion:part:release] 1564 | optional_value = gamma 1565 | values = 1566 | dev 1567 | gamma 1568 | [bumpversion:file:with_pre_releases.txt] 1569 | """).strip()) 1570 | 1571 | main(['release', '--verbose']) 1572 | 1573 | assert '1.5' == tmpdir.join("with_pre_releases.txt").read() 1574 | 1575 | main(['minor', '--verbose']) 1576 | 1577 | assert '1.6.dev' == tmpdir.join("with_pre_releases.txt").read() 1578 | 1579 | 1580 | def test_optional_value_from_documentation(tmpdir): 1581 | tmpdir.join("optional_value_from_doc.txt").write("1.alpha") 1582 | tmpdir.chdir() 1583 | 1584 | tmpdir.join(".bumpversion.cfg").write(dedent(r""" 1585 | [bumpversion] 1586 | current_version = 1.alpha 1587 | parse = (?P\d+)(\.(?P.*))?(\.)? 1588 | serialize = 1589 | {num}.{release} 1590 | {num} 1591 | 1592 | [bumpversion:part:release] 1593 | optional_value = gamma 1594 | values = 1595 | alpha 1596 | beta 1597 | gamma 1598 | 1599 | [bumpversion:file:optional_value_from_doc.txt] 1600 | """).strip()) 1601 | 1602 | main(['release', '--verbose']) 1603 | 1604 | assert '1.beta' == tmpdir.join("optional_value_from_doc.txt").read() 1605 | 1606 | main(['release', '--verbose']) 1607 | 1608 | assert '1' == tmpdir.join("optional_value_from_doc.txt").read() 1609 | 1610 | 1611 | def test_python_pre_release_release_post_release(tmpdir): 1612 | tmpdir.join("python386.txt").write("1.0a") 1613 | tmpdir.chdir() 1614 | 1615 | tmpdir.join(".bumpversion.cfg").write(dedent(r""" 1616 | [bumpversion] 1617 | current_version = 1.0a 1618 | 1619 | # adapted from http://legacy.python.org/dev/peps/pep-0386/#the-new-versioning-algorithm 1620 | parse = ^ 1621 | (?P\d+)\.(?P\d+) # minimum 'N.N' 1622 | (?: 1623 | (?P[abc]|rc|dev) # 'a' = alpha, 'b' = beta 1624 | # 'c' or 'rc' = release candidate 1625 | (?: 1626 | (?P\d+(?:\.\d+)*) 1627 | )? 1628 | )? 1629 | (?P(\.post(?P\d+))?(\.dev(?P\d+))?)? 1630 | 1631 | serialize = 1632 | {major}.{minor}{prerel}{prerelversion} 1633 | {major}.{minor}{prerel} 1634 | {major}.{minor} 1635 | 1636 | [bumpversion:part:prerel] 1637 | optional_value = d 1638 | values = 1639 | dev 1640 | a 1641 | b 1642 | c 1643 | rc 1644 | d 1645 | [bumpversion:file:python386.txt] 1646 | """)) 1647 | 1648 | def file_content(): 1649 | return tmpdir.join("python386.txt").read() 1650 | 1651 | main(['prerel']) 1652 | assert '1.0b' == file_content() 1653 | 1654 | main(['prerelversion']) 1655 | assert '1.0b1' == file_content() 1656 | 1657 | main(['prerelversion']) 1658 | assert '1.0b2' == file_content() 1659 | 1660 | main(['prerel']) # now it's 1.0c 1661 | main(['prerel']) 1662 | assert '1.0rc' == file_content() 1663 | 1664 | main(['prerel']) 1665 | assert '1.0' == file_content() 1666 | 1667 | main(['minor']) 1668 | assert '1.1dev' == file_content() 1669 | 1670 | main(['prerel', '--verbose']) 1671 | assert '1.1a' == file_content() 1672 | 1673 | 1674 | def test_part_first_value(tmpdir): 1675 | tmpdir.join("the_version.txt").write("0.9.4") 1676 | tmpdir.chdir() 1677 | 1678 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1679 | [bumpversion] 1680 | current_version = 0.9.4 1681 | 1682 | [bumpversion:part:minor] 1683 | first_value = 1 1684 | 1685 | [bumpversion:file:the_version.txt] 1686 | """)) 1687 | 1688 | main(['major', '--verbose']) 1689 | 1690 | assert '1.1.0' == tmpdir.join("the_version.txt").read() 1691 | 1692 | 1693 | def test_multi_file_configuration(tmpdir): 1694 | tmpdir.join("FULL_VERSION.txt").write("1.0.3") 1695 | tmpdir.join("MAJOR_VERSION.txt").write("1") 1696 | 1697 | tmpdir.chdir() 1698 | 1699 | tmpdir.join(".bumpversion.cfg").write(dedent(r""" 1700 | [bumpversion] 1701 | current_version = 1.0.3 1702 | 1703 | [bumpversion:file:FULL_VERSION.txt] 1704 | 1705 | [bumpversion:file:MAJOR_VERSION.txt] 1706 | serialize = {major} 1707 | parse = \d+ 1708 | 1709 | """)) 1710 | 1711 | main(['major', '--verbose']) 1712 | assert '2.0.0' in tmpdir.join("FULL_VERSION.txt").read() 1713 | assert '2' in tmpdir.join("MAJOR_VERSION.txt").read() 1714 | 1715 | main(['patch']) 1716 | assert '2.0.1' in tmpdir.join("FULL_VERSION.txt").read() 1717 | assert '2' in tmpdir.join("MAJOR_VERSION.txt").read() 1718 | 1719 | 1720 | def test_multi_file_configuration2(tmpdir): 1721 | tmpdir.join("setup.cfg").write("1.6.6") 1722 | tmpdir.join("README.txt").write("MyAwesomeSoftware(TM) v1.6") 1723 | tmpdir.join("BUILD_NUMBER").write("1.6.6+joe+38943") 1724 | 1725 | tmpdir.chdir() 1726 | 1727 | tmpdir.join(r".bumpversion.cfg").write(dedent(r""" 1728 | [bumpversion] 1729 | current_version = 1.6.6 1730 | 1731 | [something:else] 1732 | 1733 | [foo] 1734 | 1735 | [bumpversion:file:setup.cfg] 1736 | 1737 | [bumpversion:file:README.txt] 1738 | parse = '(?P\d+)\.(?P\d+)' 1739 | serialize = 1740 | {major}.{minor} 1741 | 1742 | [bumpversion:file:BUILD_NUMBER] 1743 | serialize = 1744 | {major}.{minor}.{patch}+{$USER}+{$BUILD_NUMBER} 1745 | 1746 | """)) 1747 | 1748 | os.environ['BUILD_NUMBER'] = "38944" 1749 | os.environ['USER'] = "bob" 1750 | main(['minor', '--verbose']) 1751 | del os.environ['BUILD_NUMBER'] 1752 | del os.environ['USER'] 1753 | 1754 | assert '1.7.0' in tmpdir.join("setup.cfg").read() 1755 | assert 'MyAwesomeSoftware(TM) v1.7' in tmpdir.join("README.txt").read() 1756 | assert '1.7.0+bob+38944' in tmpdir.join("BUILD_NUMBER").read() 1757 | 1758 | os.environ['BUILD_NUMBER'] = "38945" 1759 | os.environ['USER'] = "bob" 1760 | main(['patch', '--verbose']) 1761 | del os.environ['BUILD_NUMBER'] 1762 | del os.environ['USER'] 1763 | 1764 | assert '1.7.1' in tmpdir.join("setup.cfg").read() 1765 | assert 'MyAwesomeSoftware(TM) v1.7' in tmpdir.join("README.txt").read() 1766 | assert '1.7.1+bob+38945' in tmpdir.join("BUILD_NUMBER").read() 1767 | 1768 | 1769 | def test_search_replace_to_avoid_updating_unconcerned_lines(tmpdir): 1770 | tmpdir.chdir() 1771 | 1772 | tmpdir.join("requirements.txt").write("Django>=1.5.6,<1.6\nMyProject==1.5.6") 1773 | tmpdir.join("CHANGELOG.md").write(dedent(""" 1774 | # https://keepachangelog.com/en/1.0.0/ 1775 | 1776 | ## [Unreleased] 1777 | ### Added 1778 | - Foobar 1779 | 1780 | ## [0.0.1] - 2014-05-31 1781 | ### Added 1782 | - This CHANGELOG file to hopefully serve as an evolving example of a 1783 | standardized open source project CHANGELOG. 1784 | """)) 1785 | 1786 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 1787 | [bumpversion] 1788 | current_version = 1.5.6 1789 | 1790 | [bumpversion:file:requirements.txt] 1791 | search = MyProject=={current_version} 1792 | replace = MyProject=={new_version} 1793 | 1794 | [bumpversion:file:CHANGELOG.md] 1795 | search = {#}{#} [Unreleased] 1796 | replace = {#}{#} [Unreleased] 1797 | 1798 | {#}{#} [{new_version}] - {utcnow:%Y-%m-%d} 1799 | """).strip()) 1800 | 1801 | with LogCapture() as log_capture: 1802 | main(['minor', '--verbose']) 1803 | 1804 | utc_today = datetime.utcnow().strftime("%Y-%m-%d") 1805 | 1806 | log_capture.check( 1807 | ('bumpversion.cli', 'INFO', 'Reading config file .bumpversion.cfg:'), 1808 | ('bumpversion.cli', 'INFO', '[bumpversion]\ncurrent_version = 1.5.6\n\n[bumpversion:file:requirements.txt]\nsearch = MyProject=={current_version}\nreplace = MyProject=={new_version}\n\n[bumpversion:file:CHANGELOG.md]\nsearch = {#}{#} [Unreleased]\nreplace = {#}{#} [Unreleased]\n\n {#}{#} [{new_version}] - {utcnow:%Y-%m-%d}'), 1809 | ('bumpversion.version_part', 'INFO', "Parsing version '1.5.6' using regexp '(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)'"), 1810 | ('bumpversion.version_part', 'INFO', 'Parsed the following values: major=1, minor=5, patch=6'), 1811 | ('bumpversion.cli', 'INFO', "Attempting to increment part 'minor'"), 1812 | ('bumpversion.cli', 'INFO', 'Values are now: major=1, minor=6, patch=0'), 1813 | ('bumpversion.version_part', 'INFO', "Parsing version '1.6.0' using regexp '(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)'"), 1814 | ('bumpversion.version_part', 'INFO', 'Parsed the following values: major=1, minor=6, patch=0'), 1815 | ('bumpversion.cli', 'INFO', "New version will be '1.6.0'"), 1816 | ('bumpversion.cli', 'INFO', 'Asserting files requirements.txt, CHANGELOG.md contain the version string...'), 1817 | ('bumpversion.utils', 'INFO', "Found 'MyProject==1.5.6' in requirements.txt at line 1: MyProject==1.5.6"), 1818 | ('bumpversion.utils', 'INFO', "Found '## [Unreleased]' in CHANGELOG.md at line 3: ## [Unreleased]"), 1819 | ('bumpversion.utils', 'INFO', 'Changing file requirements.txt:'), 1820 | ('bumpversion.utils', 'INFO', '--- a/requirements.txt\n+++ b/requirements.txt\n@@ -1,2 +1,2 @@\n Django>=1.5.6,<1.6\n-MyProject==1.5.6\n+MyProject==1.6.0'), 1821 | ('bumpversion.utils', 'INFO', 'Changing file CHANGELOG.md:'), 1822 | ('bumpversion.utils', 'INFO', '--- a/CHANGELOG.md\n+++ b/CHANGELOG.md\n@@ -2,6 +2,8 @@\n # https://keepachangelog.com/en/1.0.0/\n \n ## [Unreleased]\n+\n+## [1.6.0] - %s\n ### Added\n - Foobar\n ' % utc_today), 1823 | ('bumpversion.list', 'INFO', 'current_version=1.5.6'), 1824 | ('bumpversion.list', 'INFO', 'new_version=1.6.0'), 1825 | ('bumpversion.cli', 'INFO', 'Writing to config file .bumpversion.cfg:'), 1826 | ('bumpversion.cli', 'INFO', '[bumpversion]\ncurrent_version = 1.6.0\n\n[bumpversion:file:requirements.txt]\nsearch = MyProject=={current_version}\nreplace = MyProject=={new_version}\n\n[bumpversion:file:CHANGELOG.md]\nsearch = {#}{#} [Unreleased]\nreplace = {#}{#} [Unreleased]\n\t\n\t{#}{#} [{new_version}] - {utcnow:%Y-%m-%d}\n\n') 1827 | ) 1828 | 1829 | assert 'MyProject==1.6.0' in tmpdir.join("requirements.txt").read() 1830 | assert 'Django>=1.5.6' in tmpdir.join("requirements.txt").read() 1831 | 1832 | 1833 | def test_search_replace_expanding_changelog(tmpdir): 1834 | tmpdir.chdir() 1835 | 1836 | tmpdir.join("CHANGELOG.md").write(dedent(""" 1837 | My awesome software project Changelog 1838 | ===================================== 1839 | 1840 | Unreleased 1841 | ---------- 1842 | 1843 | * Some nice feature 1844 | * Some other nice feature 1845 | 1846 | Version v8.1.1 (2014-05-28) 1847 | --------------------------- 1848 | 1849 | * Another old nice feature 1850 | 1851 | """)) 1852 | 1853 | config_content = dedent(""" 1854 | [bumpversion] 1855 | current_version = 8.1.1 1856 | 1857 | [bumpversion:file:CHANGELOG.md] 1858 | search = 1859 | Unreleased 1860 | ---------- 1861 | replace = 1862 | Unreleased 1863 | ---------- 1864 | Version v{new_version} ({now:%Y-%m-%d}) 1865 | --------------------------- 1866 | """) 1867 | 1868 | tmpdir.join(".bumpversion.cfg").write(config_content) 1869 | 1870 | with mock.patch("bumpversion.cli.logger"): 1871 | main(['minor', '--verbose']) 1872 | 1873 | predate = dedent(''' 1874 | Unreleased 1875 | ---------- 1876 | Version v8.2.0 (20 1877 | ''').strip() 1878 | 1879 | postdate = dedent(''' 1880 | ) 1881 | --------------------------- 1882 | 1883 | * Some nice feature 1884 | * Some other nice feature 1885 | ''').strip() 1886 | 1887 | assert predate in tmpdir.join("CHANGELOG.md").read() 1888 | assert postdate in tmpdir.join("CHANGELOG.md").read() 1889 | 1890 | 1891 | def test_non_matching_search_does_not_modify_file(tmpdir): 1892 | tmpdir.chdir() 1893 | 1894 | changelog_content = dedent(""" 1895 | # Unreleased 1896 | 1897 | * bullet point A 1898 | 1899 | # Release v'older' (2019-09-17) 1900 | 1901 | * bullet point B 1902 | """) 1903 | 1904 | config_content = dedent(""" 1905 | [bumpversion] 1906 | current_version = 1.0.3 1907 | 1908 | [bumpversion:file:CHANGELOG.md] 1909 | search = Not-yet-released 1910 | replace = Release v{new_version} ({now:%Y-%m-%d}) 1911 | """) 1912 | 1913 | tmpdir.join("CHANGELOG.md").write(changelog_content) 1914 | tmpdir.join(".bumpversion.cfg").write(config_content) 1915 | 1916 | with pytest.raises( 1917 | exceptions.VersionNotFoundException, 1918 | match="Did not find 'Not-yet-released' in file: 'CHANGELOG.md'" 1919 | ): 1920 | main(['patch', '--verbose']) 1921 | 1922 | assert changelog_content == tmpdir.join("CHANGELOG.md").read() 1923 | assert config_content in tmpdir.join(".bumpversion.cfg").read() 1924 | 1925 | 1926 | def test_search_replace_cli(tmpdir): 1927 | tmpdir.join("file89").write("My birthday: 3.5.98\nCurrent version: 3.5.98") 1928 | tmpdir.chdir() 1929 | main([ 1930 | '--current-version', '3.5.98', 1931 | '--search', 'Current version: {current_version}', 1932 | '--replace', 'Current version: {new_version}', 1933 | 'minor', 1934 | 'file89', 1935 | ]) 1936 | 1937 | assert 'My birthday: 3.5.98\nCurrent version: 3.6.0' == tmpdir.join("file89").read() 1938 | 1939 | 1940 | def test_deprecation_warning_files_in_global_configuration(tmpdir): 1941 | tmpdir.chdir() 1942 | 1943 | tmpdir.join("fileX").write("3.2.1") 1944 | tmpdir.join("fileY").write("3.2.1") 1945 | tmpdir.join("fileZ").write("3.2.1") 1946 | 1947 | tmpdir.join(".bumpversion.cfg").write("""[bumpversion] 1948 | current_version = 3.2.1 1949 | files = fileX fileY fileZ 1950 | """) 1951 | 1952 | warning_registry = getattr(bumpversion, '__warningregistry__', None) 1953 | if warning_registry: 1954 | warning_registry.clear() 1955 | warnings.resetwarnings() 1956 | warnings.simplefilter('always') 1957 | with warnings.catch_warnings(record=True) as received_warnings: 1958 | main(['patch']) 1959 | 1960 | w = received_warnings.pop() 1961 | assert issubclass(w.category, PendingDeprecationWarning) 1962 | assert "'files =' configuration will be deprecated, please use" in str(w.message) 1963 | 1964 | 1965 | def test_deprecation_warning_multiple_files_cli(tmpdir): 1966 | tmpdir.chdir() 1967 | 1968 | tmpdir.join("fileA").write("1.2.3") 1969 | tmpdir.join("fileB").write("1.2.3") 1970 | tmpdir.join("fileC").write("1.2.3") 1971 | 1972 | warning_registry = getattr(bumpversion, '__warningregistry__', None) 1973 | if warning_registry: 1974 | warning_registry.clear() 1975 | warnings.resetwarnings() 1976 | warnings.simplefilter('always') 1977 | with warnings.catch_warnings(record=True) as received_warnings: 1978 | main(['--current-version', '1.2.3', 'patch', 'fileA', 'fileB', 'fileC']) 1979 | 1980 | w = received_warnings.pop() 1981 | assert issubclass(w.category, PendingDeprecationWarning) 1982 | assert 'Giving multiple files on the command line will be deprecated' in str(w.message) 1983 | 1984 | 1985 | def test_file_specific_config_inherits_parse_serialize(tmpdir): 1986 | tmpdir.chdir() 1987 | 1988 | tmpdir.join("todays_ice_cream").write("14-chocolate") 1989 | tmpdir.join("todays_cake").write("14-chocolate") 1990 | 1991 | tmpdir.join(".bumpversion.cfg").write(dedent(r""" 1992 | [bumpversion] 1993 | current_version = 14-chocolate 1994 | parse = (?P\d+)(\-(?P[a-z]+))? 1995 | serialize = 1996 | {major}-{flavor} 1997 | {major} 1998 | 1999 | [bumpversion:file:todays_ice_cream] 2000 | serialize = 2001 | {major}-{flavor} 2002 | 2003 | [bumpversion:file:todays_cake] 2004 | 2005 | [bumpversion:part:flavor] 2006 | values = 2007 | vanilla 2008 | chocolate 2009 | strawberry 2010 | """)) 2011 | 2012 | main(['flavor']) 2013 | 2014 | assert '14-strawberry' == tmpdir.join("todays_cake").read() 2015 | assert '14-strawberry' == tmpdir.join("todays_ice_cream").read() 2016 | 2017 | main(['major']) 2018 | 2019 | assert '15-vanilla' == tmpdir.join("todays_ice_cream").read() 2020 | assert '15' == tmpdir.join("todays_cake").read() 2021 | 2022 | 2023 | def test_multi_line_search_is_found(tmpdir): 2024 | tmpdir.chdir() 2025 | 2026 | tmpdir.join("the_alphabet.txt").write(dedent(""" 2027 | A 2028 | B 2029 | C 2030 | """)) 2031 | 2032 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 2033 | [bumpversion] 2034 | current_version = 9.8.7 2035 | 2036 | [bumpversion:file:the_alphabet.txt] 2037 | search = 2038 | A 2039 | B 2040 | C 2041 | replace = 2042 | A 2043 | B 2044 | C 2045 | {new_version} 2046 | """).strip()) 2047 | 2048 | main(['major']) 2049 | 2050 | assert dedent(""" 2051 | A 2052 | B 2053 | C 2054 | 10.0.0 2055 | """) == tmpdir.join("the_alphabet.txt").read() 2056 | 2057 | 2058 | @xfail_if_old_configparser 2059 | def test_configparser_empty_lines_in_values(tmpdir): 2060 | tmpdir.chdir() 2061 | 2062 | tmpdir.join("CHANGES.rst").write(dedent(""" 2063 | My changelog 2064 | ============ 2065 | 2066 | current 2067 | ------- 2068 | 2069 | """)) 2070 | 2071 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 2072 | [bumpversion] 2073 | current_version = 0.4.1 2074 | 2075 | [bumpversion:file:CHANGES.rst] 2076 | search = 2077 | current 2078 | ------- 2079 | replace = current 2080 | ------- 2081 | 2082 | 2083 | {new_version} 2084 | ------- 2085 | """).strip()) 2086 | 2087 | main(['patch']) 2088 | assert dedent(""" 2089 | My changelog 2090 | ============ 2091 | current 2092 | ------- 2093 | 2094 | 2095 | 0.4.2 2096 | ------- 2097 | 2098 | """) == tmpdir.join("CHANGES.rst").read() 2099 | 2100 | 2101 | def test_regression_tag_name_with_hyphens(tmpdir, git): 2102 | tmpdir.chdir() 2103 | tmpdir.join("some_source.txt").write("2014.10.22") 2104 | check_call([git, "init"]) 2105 | check_call([git, "add", "some_source.txt"]) 2106 | check_call([git, "commit", "-m", "initial commit"]) 2107 | check_call([git, "tag", "very-unrelated-but-containing-lots-of-hyphens"]) 2108 | 2109 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 2110 | [bumpversion] 2111 | current_version = 2014.10.22 2112 | """)) 2113 | 2114 | main(['patch', 'some_source.txt']) 2115 | 2116 | 2117 | def test_unclean_repo_exception(tmpdir, git, caplog): 2118 | tmpdir.chdir() 2119 | 2120 | config = """[bumpversion] 2121 | current_version = 0.0.0 2122 | tag = True 2123 | commit = True 2124 | message = XXX 2125 | """ 2126 | 2127 | tmpdir.join("file1").write("foo") 2128 | 2129 | # If I have a repo with an initial commit 2130 | check_call([git, "init"]) 2131 | check_call([git, "add", "file1"]) 2132 | check_call([git, "commit", "-m", "initial commit"]) 2133 | 2134 | # If I add the bumpversion config, uncommitted 2135 | tmpdir.join(".bumpversion.cfg").write(config) 2136 | 2137 | # I expect bumpversion patch to fail 2138 | with pytest.raises(subprocess.CalledProcessError): 2139 | main(['patch']) 2140 | 2141 | # And return the output of the failing command 2142 | assert "Failed to run" in caplog.text 2143 | 2144 | 2145 | def test_regression_characters_after_last_label_serialize_string(tmpdir): 2146 | tmpdir.chdir() 2147 | tmpdir.join("bower.json").write(''' 2148 | { 2149 | "version": "1.0.0", 2150 | "dependency1": "1.0.0", 2151 | } 2152 | ''') 2153 | 2154 | tmpdir.join(".bumpversion.cfg").write(dedent(r""" 2155 | [bumpversion] 2156 | current_version = 1.0.0 2157 | 2158 | [bumpversion:file:bower.json] 2159 | parse = "version": "(?P\d+)\.(?P\d+)\.(?P\d+)" 2160 | serialize = "version": "{major}.{minor}.{patch}" 2161 | """)) 2162 | 2163 | main(['patch', 'bower.json']) 2164 | 2165 | 2166 | def test_regression_dont_touch_capitalization_of_keys_in_config(tmpdir): 2167 | tmpdir.chdir() 2168 | tmpdir.join("setup.cfg").write(dedent(""" 2169 | [bumpversion] 2170 | current_version = 0.1.0 2171 | 2172 | [other] 2173 | DJANGO_SETTINGS = Value 2174 | """)) 2175 | 2176 | main(['patch']) 2177 | 2178 | assert dedent(""" 2179 | [bumpversion] 2180 | current_version = 0.1.1 2181 | 2182 | [other] 2183 | DJANGO_SETTINGS = Value 2184 | """).strip() == tmpdir.join("setup.cfg").read().strip() 2185 | 2186 | 2187 | def test_regression_new_version_cli_in_files(tmpdir): 2188 | """ 2189 | Reported here: https://github.com/peritus/bumpversion/issues/60 2190 | """ 2191 | tmpdir.chdir() 2192 | tmpdir.join("myp___init__.py").write("__version__ = '0.7.2'") 2193 | tmpdir.chdir() 2194 | 2195 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 2196 | [bumpversion] 2197 | current_version = 0.7.2 2198 | message = v{new_version} 2199 | tag_name = {new_version} 2200 | tag = true 2201 | commit = true 2202 | [bumpversion:file:myp___init__.py] 2203 | """).strip()) 2204 | 2205 | main("patch --allow-dirty --verbose --new-version 0.9.3".split(" ")) 2206 | 2207 | assert "__version__ = '0.9.3'" == tmpdir.join("myp___init__.py").read() 2208 | assert "current_version = 0.9.3" in tmpdir.join(".bumpversion.cfg").read() 2209 | 2210 | 2211 | def test_correct_interpolation_for_setup_cfg_files(tmpdir, configfile): 2212 | """ 2213 | Reported here: https://github.com/c4urself/bump2version/issues/21 2214 | """ 2215 | tmpdir.chdir() 2216 | tmpdir.join("file.py").write("XX-XX-XXXX v. X.X.X") 2217 | tmpdir.chdir() 2218 | 2219 | if configfile == "setup.cfg": 2220 | tmpdir.join(configfile).write(dedent(""" 2221 | [bumpversion] 2222 | current_version = 0.7.2 2223 | search = XX-XX-XXXX v. X.X.X 2224 | replace = {now:%%m-%%d-%%Y} v. {new_version} 2225 | [bumpversion:file:file.py] 2226 | """).strip()) 2227 | else: 2228 | tmpdir.join(configfile).write(dedent(""" 2229 | [bumpversion] 2230 | current_version = 0.7.2 2231 | search = XX-XX-XXXX v. X.X.X 2232 | replace = {now:%m-%d-%Y} v. {new_version} 2233 | [bumpversion:file:file.py] 2234 | """).strip()) 2235 | 2236 | main(["major"]) 2237 | 2238 | assert datetime.now().strftime('%m-%d-%Y') + ' v. 1.0.0' == tmpdir.join("file.py").read() 2239 | assert "current_version = 1.0.0" in tmpdir.join(configfile).read() 2240 | 2241 | 2242 | @pytest.mark.parametrize("newline", [b'\n', b'\r\n']) 2243 | def test_retain_newline(tmpdir, configfile, newline): 2244 | tmpdir.join("file.py").write_binary(dedent(""" 2245 | 0.7.2 2246 | Some Content 2247 | """).strip().encode(encoding='UTF-8').replace(b'\n', newline)) 2248 | tmpdir.chdir() 2249 | 2250 | tmpdir.join(configfile).write_binary(dedent(""" 2251 | [bumpversion] 2252 | current_version = 0.7.2 2253 | search = {current_version} 2254 | replace = {new_version} 2255 | [bumpversion:file:file.py] 2256 | """).strip().encode(encoding='UTF-8').replace(b'\n', newline)) 2257 | 2258 | main(["major"]) 2259 | 2260 | assert newline in tmpdir.join("file.py").read_binary() 2261 | new_config = tmpdir.join(configfile).read_binary() 2262 | assert newline in new_config 2263 | 2264 | # Ensure there is only a single newline (not two) at the end of the file 2265 | # and that it is of the right type 2266 | assert new_config.endswith(b"[bumpversion:file:file.py]" + newline) 2267 | 2268 | 2269 | def test_no_configured_files(tmpdir, vcs): 2270 | tmpdir.join("please_ignore_me.txt").write("0.5.5") 2271 | tmpdir.chdir() 2272 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 2273 | [bumpversion] 2274 | current_version = 1.1.1 2275 | [bumpversion:file:please_ignore_me.txt] 2276 | """).strip()) 2277 | main(['--no-configured-files', 'patch']) 2278 | assert "0.5.5" == tmpdir.join("please_ignore_me.txt").read() 2279 | 2280 | 2281 | def test_no_configured_files_still_file_args_work(tmpdir, vcs): 2282 | tmpdir.join("please_ignore_me.txt").write("0.5.5") 2283 | tmpdir.join("please_update_me.txt").write("1.1.1") 2284 | tmpdir.chdir() 2285 | tmpdir.join(".bumpversion.cfg").write(dedent(""" 2286 | [bumpversion] 2287 | current_version = 1.1.1 2288 | [bumpversion:file:please_ignore_me.txt] 2289 | """).strip()) 2290 | main(['--no-configured-files', 'patch', "please_update_me.txt"]) 2291 | assert "0.5.5" == tmpdir.join("please_ignore_me.txt").read() 2292 | assert "1.1.2" == tmpdir.join("please_update_me.txt").read() 2293 | 2294 | 2295 | class TestSplitArgsInOptionalAndPositional: 2296 | 2297 | def test_all_optional(self): 2298 | params = ['--allow-dirty', '--verbose', '-n', '--tag-name', '"Tag"'] 2299 | positional, optional = \ 2300 | split_args_in_optional_and_positional(params) 2301 | 2302 | assert positional == [] 2303 | assert optional == params 2304 | 2305 | def test_all_positional(self): 2306 | params = ['minor', 'setup.py'] 2307 | positional, optional = \ 2308 | split_args_in_optional_and_positional(params) 2309 | 2310 | assert positional == params 2311 | assert optional == [] 2312 | 2313 | def test_no_args(self): 2314 | assert split_args_in_optional_and_positional([]) == \ 2315 | ([], []) 2316 | 2317 | def test_short_optionals(self): 2318 | params = ['-m', '"Commit"', '-n'] 2319 | positional, optional = \ 2320 | split_args_in_optional_and_positional(params) 2321 | 2322 | assert positional == [] 2323 | assert optional == params 2324 | 2325 | def test_1optional_2positional(self): 2326 | params = ['-n', 'major', 'setup.py'] 2327 | positional, optional = \ 2328 | split_args_in_optional_and_positional(params) 2329 | 2330 | assert positional == ['major', 'setup.py'] 2331 | assert optional == ['-n'] 2332 | 2333 | def test_2optional_1positional(self): 2334 | params = ['-n', '-m', '"Commit"', 'major'] 2335 | positional, optional = \ 2336 | split_args_in_optional_and_positional(params) 2337 | 2338 | assert positional == ['major'] 2339 | assert optional == ['-n', '-m', '"Commit"'] 2340 | 2341 | def test_2optional_mixed_2positional(self): 2342 | params = ['--allow-dirty', '-m', '"Commit"', 'minor', 'setup.py'] 2343 | positional, optional = \ 2344 | split_args_in_optional_and_positional(params) 2345 | 2346 | assert positional == ['minor', 'setup.py'] 2347 | assert optional == ['--allow-dirty', '-m', '"Commit"'] 2348 | 2349 | 2350 | def test_build_number_configuration(tmpdir): 2351 | tmpdir.join("VERSION.txt").write("2.1.6-5123") 2352 | tmpdir.chdir() 2353 | tmpdir.join(".bumpversion.cfg").write(dedent(r""" 2354 | [bumpversion] 2355 | current_version: 2.1.6-5123 2356 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)\-(?P\d+) 2357 | serialize = {major}.{minor}.{patch}-{build} 2358 | 2359 | [bumpversion:file:VERSION.txt] 2360 | 2361 | [bumpversion:part:build] 2362 | independent = True 2363 | """)) 2364 | 2365 | main(['build']) 2366 | assert '2.1.6-5124' == tmpdir.join("VERSION.txt").read() 2367 | 2368 | main(['major']) 2369 | assert '3.0.0-5124' == tmpdir.join("VERSION.txt").read() 2370 | 2371 | main(['build']) 2372 | assert '3.0.0-5125' == tmpdir.join("VERSION.txt").read() 2373 | 2374 | 2375 | def test_independent_falsy_value_in_config_does_not_bump_independently(tmpdir): 2376 | tmpdir.join("VERSION").write("2.1.0-5123") 2377 | tmpdir.chdir() 2378 | tmpdir.join(".bumpversion.cfg").write(dedent(r""" 2379 | [bumpversion] 2380 | current_version: 2.1.0-5123 2381 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)\-(?P\d+) 2382 | serialize = {major}.{minor}.{patch}-{build} 2383 | 2384 | [bumpversion:file:VERSION] 2385 | 2386 | [bumpversion:part:build] 2387 | independent = 0 2388 | """)) 2389 | 2390 | main(['build']) 2391 | assert '2.1.0-5124' == tmpdir.join("VERSION").read() 2392 | 2393 | main(['major']) 2394 | assert '3.0.0-0' == tmpdir.join("VERSION").read() 2395 | -------------------------------------------------------------------------------- /tests/test_functions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bumpversion.functions import NumericFunction, ValuesFunction 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 | -------------------------------------------------------------------------------- /tests/test_version_part.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bumpversion.version_part import ( 4 | ConfiguredVersionPartConfiguration, 5 | NumericVersionPartConfiguration, 6 | VersionPart, 7 | ) 8 | 9 | 10 | @pytest.fixture(params=[None, (('0', '1', '2'),), (('0', '3'),)]) 11 | def confvpc(request): 12 | """Return a three-part and a two-part version part configuration.""" 13 | if request.param is None: 14 | return NumericVersionPartConfiguration() 15 | else: 16 | return ConfiguredVersionPartConfiguration(*request.param) 17 | 18 | 19 | # VersionPart 20 | 21 | def test_version_part_init(confvpc): 22 | assert VersionPart( 23 | confvpc.first_value, confvpc).value == confvpc.first_value 24 | 25 | 26 | def test_version_part_copy(confvpc): 27 | vp = VersionPart(confvpc.first_value, confvpc) 28 | vc = vp.copy() 29 | assert vp.value == vc.value 30 | assert id(vp) != id(vc) 31 | 32 | 33 | def test_version_part_bump(confvpc): 34 | vp = VersionPart(confvpc.first_value, confvpc) 35 | vc = vp.bump() 36 | assert vc.value == confvpc.bump(confvpc.first_value) 37 | 38 | 39 | def test_version_part_check_optional_false(confvpc): 40 | assert not VersionPart(confvpc.first_value, confvpc).bump().is_optional() 41 | 42 | 43 | def test_version_part_check_optional_true(confvpc): 44 | assert VersionPart(confvpc.first_value, confvpc).is_optional() 45 | 46 | 47 | def test_version_part_format(confvpc): 48 | assert "{}".format( 49 | VersionPart(confvpc.first_value, confvpc)) == confvpc.first_value 50 | 51 | 52 | def test_version_part_equality(confvpc): 53 | assert VersionPart(confvpc.first_value, confvpc) == VersionPart( 54 | confvpc.first_value, confvpc) 55 | 56 | 57 | def test_version_part_null(confvpc): 58 | assert VersionPart(confvpc.first_value, confvpc).null() == VersionPart( 59 | confvpc.first_value, confvpc) 60 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35, py36, py37, py38, pypy3 3 | 4 | [testenv] 5 | passenv = HOME 6 | deps= 7 | pytest>=3.4.0 8 | testfixtures>=1.2.0 9 | commands= 10 | pytest -r a [] tests 11 | 12 | [pytest] 13 | minversion= 2.0 14 | norecursedirs= .git .hg .tox build dist tmp* 15 | python_files = test*.py 16 | 17 | [gh-actions] 18 | python = 19 | 2.7: py27 20 | 3.6: py36 21 | 3.7: py37 22 | 3.8: py38, mypy 23 | pypy-3.7: pypy3 24 | --------------------------------------------------------------------------------